From 02239550586cb02a73d5d900ff7a16f38842bce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Sat, 28 Feb 2026 21:14:51 +0100 Subject: [PATCH 001/129] Add unstable_swallowErrors option --- libraries/react-native/README.md | 32 +++ .../react-native/src/__tests__/client.test.ts | 146 +++++++++++- .../react-native/src/client-react-native.ts | 2 + libraries/react-native/src/client.tsx | 220 ++++++++++-------- package.json | 1 + pnpm-lock.yaml | 73 ++++++ 6 files changed, 380 insertions(+), 94 deletions(-) diff --git a/libraries/react-native/README.md b/libraries/react-native/README.md index cd8e53867..a6776b406 100644 --- a/libraries/react-native/README.md +++ b/libraries/react-native/README.md @@ -28,6 +28,38 @@ Observer reconciliation: - Client-side dedupe key is `platform + transactionId + purchaseDate`. - The server endpoint is idempotent by store transaction identity to tolerate retries/duplicates. +## Unstable error swallowing + +For early-alpha integrations, you can enable unstable side-effect error swallowing: + +```ts +createVoidhashClient("pk_test", schema, { + readOnly: true, + scheme: "myapp", + unstable_swallowErrors: true, +}); +``` + +When `unstable_swallowErrors: true`, the SDK logs warnings and does not reject for: + +- `init()` +- `end()` +- `identify(...)` +- `signOut()` +- `restorePurchases()` +- `iosPresentCodeRedemptionSheet()` +- `iosShowManageSubscriptions()` + +The following remain strict and still reject on failures: + +- `getCurrentCustomer(...)` +- `getFeatureFlags(...)` +- `getPaywallForLocation(...)` +- `getProducts()` +- `purchase(...)` + +This flag is intentionally unstable and best used for background/observer-style alpha integrations. It is not recommended for core purchase flow handling. + ## HTTP debug mode Enable verbose HTTP logging when debugging request/response flow: diff --git a/libraries/react-native/src/__tests__/client.test.ts b/libraries/react-native/src/__tests__/client.test.ts index ba1b28b5f..58bb79257 100644 --- a/libraries/react-native/src/__tests__/client.test.ts +++ b/libraries/react-native/src/__tests__/client.test.ts @@ -41,7 +41,7 @@ import { } from "../errors"; import { createTestSchema } from "./helpers/test-schema"; -function createClient(readOnly = false) { +function createClient(readOnly = false, unstableSwallowErrors = false) { return new VoidhashClient( null, "voidhash", @@ -49,6 +49,7 @@ function createClient(readOnly = false) { "https://api.voidhash.test", "pk_test", readOnly, + unstableSwallowErrors, new EventBus(), "ios" ); @@ -146,6 +147,29 @@ describe("VoidhashClient", () => { ); }); + it("swallows identify errors when unstable_swallowErrors is enabled", async () => { + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => { + return; + }); + const client = createClient(false, true); + + (client as unknown as Record).initializedClient = { + identify: () => "identify-effect", + }; + (client as unknown as Record).effectRuntime = { + runPromiseExit: jest.fn().mockResolvedValue(Exit.fail("boom")), + }; + + await expect( + client.identify("new-user", { email: "new@voidhash.test" }) + ).resolves.toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + "[voidhash] swallowed error in identify", + expect.any(VoidhashError) + ); + warnSpy.mockRestore(); + }); + it("throws when purchasing in read-only mode", async () => { const client = createClient(true); @@ -183,6 +207,126 @@ describe("VoidhashClient", () => { ); }); + it("swallows restorePurchases errors when unstable_swallowErrors is enabled", async () => { + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => { + return; + }); + const client = createClient(false, true); + + (client as unknown as Record).initializedClient = { + restorePurchases: () => "restore-purchases-effect", + }; + (client as unknown as Record).effectRuntime = { + runPromiseExit: jest.fn().mockResolvedValue(Exit.fail("boom")), + }; + + await expect(client.restorePurchases()).resolves.toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + "[voidhash] swallowed error in restorePurchases", + expect.any(VoidhashError) + ); + warnSpy.mockRestore(); + }); + + it("swallows init errors and keeps client uninitialized when unstable_swallowErrors is enabled", async () => { + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => { + return; + }); + const client = createClient(false, true); + + (client as unknown as Record).unitializedClient = { + init: () => "init-effect", + }; + (client as unknown as Record).effectRuntime = { + runPromiseExit: jest.fn().mockResolvedValue(Exit.fail("boom")), + }; + + await expect(client.init()).resolves.toBeUndefined(); + expect(client.isInitialized).toBe(false); + expect(warnSpy).toHaveBeenCalledWith( + "[voidhash] swallowed error in init", + expect.any(VoidhashError) + ); + warnSpy.mockRestore(); + }); + + it("swallows ensureInitialized errors in side-effect methods when unstable_swallowErrors is enabled", async () => { + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => { + return; + }); + const client = createClient(false, true); + + await expect( + client.identify("new-user", { email: "new@voidhash.test" }) + ).resolves.toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + "[voidhash] swallowed error in identify", + expect.any(VoidhashError) + ); + warnSpy.mockRestore(); + }); + + it("keeps getProducts strict even when unstable_swallowErrors is enabled", async () => { + const client = createClient(false, true); + + (client as unknown as Record).initializedClient = { + getProducts: () => "get-products-effect", + }; + (client as unknown as Record).effectRuntime = { + runPromiseExit: jest.fn().mockResolvedValue(Exit.fail("boom")), + }; + + await expect(client.getProducts()).rejects.toEqual( + expect.objectContaining>({ + message: "FAILED_TO_GET_PRODUCTS", + }) + ); + }); + + it("keeps purchase strict even when unstable_swallowErrors is enabled", async () => { + const client = createClient(false, true); + + (client as unknown as Record).initializedClient = { + purchase: () => "purchase-effect", + }; + (client as unknown as Record).effectRuntime = { + runPromiseExit: jest.fn().mockResolvedValue(Exit.fail("boom")), + }; + + await expect( + client.purchase( + { + id: "monthly-id", + } as never, + {} + ) + ).rejects.toEqual( + expect.objectContaining>({ + message: "FAILED_TO_PURCHASE", + }) + ); + }); + + it("keeps read-only purchase rejection strict when unstable_swallowErrors is enabled", async () => { + const client = createClient(true, true); + + (client as unknown as Record).initializedClient = { + purchase: () => "purchase-effect", + }; + (client as unknown as Record).effectRuntime = { + runPromiseExit: jest.fn().mockResolvedValue(Exit.succeed(undefined)), + }; + + await expect( + client.purchase( + { + id: "monthly-id", + } as never, + {} + ) + ).rejects.toBeInstanceOf(ReadOnlyModePurchaseNotAllowedError); + }); + it("resolves restorePurchases when effect succeeds", async () => { const client = createClient(); const runPromiseExit = jest diff --git a/libraries/react-native/src/client-react-native.ts b/libraries/react-native/src/client-react-native.ts index 6ccd23640..859494a58 100644 --- a/libraries/react-native/src/client-react-native.ts +++ b/libraries/react-native/src/client-react-native.ts @@ -25,6 +25,7 @@ export function createVoidhashClient( const debug = options.debug ?? false; const initialAppUserId = options.userId ?? null; const readOnly = options.readOnly ?? false; + const unstableSwallowErrors = options.unstable_swallowErrors ?? false; const scheme = options.scheme ?? (typeof Constants.expoConfig?.scheme === "string" @@ -45,6 +46,7 @@ export function createVoidhashClient( baseUrl, publishableKey, readOnly, + unstableSwallowErrors, eventBus, platform, debug diff --git a/libraries/react-native/src/client.tsx b/libraries/react-native/src/client.tsx index 3d02235b6..64b804e15 100644 --- a/libraries/react-native/src/client.tsx +++ b/libraries/react-native/src/client.tsx @@ -32,6 +32,7 @@ export interface VoidhashClientOptions { readOnly?: boolean; schema: TSchema; scheme?: string; + unstable_swallowErrors?: boolean; userId?: string; } @@ -91,6 +92,7 @@ export class VoidhashClient { private readOnly: boolean; private scheme: string; private schema: TSchema; + private unstableSwallowErrors: boolean; private eventBus: EventBus; private effectRuntime: ReturnType; @@ -105,6 +107,7 @@ export class VoidhashClient { baseUrl: string, publishableKey: string, readOnly: boolean, + unstableSwallowErrors: boolean, eventBus: EventBus, platform: Exclude, debug = false @@ -113,6 +116,7 @@ export class VoidhashClient { this.readOnly = readOnly; this.scheme = scheme; this.schema = schema; + this.unstableSwallowErrors = unstableSwallowErrors; this.eventBus = eventBus; this.effectRuntime = CreateEffectRuntime( platform, @@ -124,49 +128,67 @@ export class VoidhashClient { ); this.unitializedClient = VoidhashEffectClient.makeUnitializedClient(); } + + private async runSideEffect( + operation: string, + effect: () => Promise + ) { + try { + await effect(); + } catch (error) { + if (!this.unstableSwallowErrors) { + throw error; + } + + // biome-ignore lint/suspicious/noConsole: This warning is intentionally surfaced in all environments. + console.warn(`[voidhash] swallowed error in ${operation}`, error); + } + } /** * Initializes the voidhash client. * @throws {FailedToInitializeNativeAdapterError} If the payment adapter fails to initialize */ async init() { - const initializedClientResult = await this.effectRuntime.runPromiseExit( - this.unitializedClient.init({ - initialAppUserId: this.initialAppUserId ?? undefined, - schema: this.schema, - }) - ); - - if (Exit.isSuccess(initializedClientResult)) { - const initializedClient = initializedClientResult.value; - const observerResult = await this.effectRuntime.runPromiseExit( - initializedClient.startTransactionObserver((transaction) => { - void this.effectRuntime.runPromiseExit( - initializedClient.processObservedTransaction(transaction) - ); + await this.runSideEffect("init", async () => { + const initializedClientResult = await this.effectRuntime.runPromiseExit( + this.unitializedClient.init({ + initialAppUserId: this.initialAppUserId ?? undefined, + schema: this.schema, }) ); - if (!Exit.isSuccess(observerResult)) { - throw toErrorWithMessage( - "FAILED_TO_INITIALIZE_VOIDHASH_CLIENT", - Cause.squash(observerResult.cause) + if (Exit.isSuccess(initializedClientResult)) { + const initializedClient = initializedClientResult.value; + const observerResult = await this.effectRuntime.runPromiseExit( + initializedClient.startTransactionObserver((transaction) => { + void this.effectRuntime.runPromiseExit( + initializedClient.processObservedTransaction(transaction) + ); + }) ); - } - void this.effectRuntime.runPromiseExit( - initializedClient.reconcileObservedTransactions() - ); + if (!Exit.isSuccess(observerResult)) { + throw toErrorWithMessage( + "FAILED_TO_INITIALIZE_VOIDHASH_CLIENT", + Cause.squash(observerResult.cause) + ); + } - this.initializedClient = initializedClient; - this._isInitialized = true; - return; - } + void this.effectRuntime.runPromiseExit( + initializedClient.reconcileObservedTransactions() + ); - // TODO: Handle different erros that can happen properly - throw toErrorWithMessage( - "FAILED_TO_INITIALIZE_VOIDHASH_CLIENT", - Cause.squash(initializedClientResult.cause) - ); + this.initializedClient = initializedClient; + this._isInitialized = true; + return; + } + + // TODO: Handle different erros that can happen properly + throw toErrorWithMessage( + "FAILED_TO_INITIALIZE_VOIDHASH_CLIENT", + Cause.squash(initializedClientResult.cause) + ); + }); } /** @@ -174,19 +196,21 @@ export class VoidhashClient { * @throws {FailedToEndNativeAdapterError} If the payment adapter fails to end */ async end() { - this.ensureInitialized(); - const endResult = await this.effectRuntime.runPromiseExit( - // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null - this.initializedClient!.end() - ); + await this.runSideEffect("end", async () => { + this.ensureInitialized(); + const endResult = await this.effectRuntime.runPromiseExit( + // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null + this.initializedClient!.end() + ); - if (Exit.isSuccess(endResult)) { - this._isInitialized = false; - return; - } + if (Exit.isSuccess(endResult)) { + this._isInitialized = false; + return; + } - // TODO: Handle different erros that can happen properly - throw new VoidhashError("FAILED_TO_END_VOIDHASH_CLIENT"); + // TODO: Handle different erros that can happen properly + throw new VoidhashError("FAILED_TO_END_VOIDHASH_CLIENT"); + }); } /** @@ -227,36 +251,40 @@ export class VoidhashClient { name?: string; } ) { - this.ensureInitialized(); - const identifyResult = await this.effectRuntime.runPromiseExit( - // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null - this.initializedClient!.identify(appUserId, options) - ); + await this.runSideEffect("identify", async () => { + this.ensureInitialized(); + const identifyResult = await this.effectRuntime.runPromiseExit( + // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null + this.initializedClient!.identify(appUserId, options) + ); - if (Exit.isSuccess(identifyResult)) { - return; - } + if (Exit.isSuccess(identifyResult)) { + return; + } - // TODO: Handle different erros that can happen properly - throw new VoidhashError("FAILED_TO_IDENTIFY"); + // TODO: Handle different erros that can happen properly + throw new VoidhashError("FAILED_TO_IDENTIFY"); + }); } /** * Signs out the user. */ async signOut() { - this.ensureInitialized(); - const signOutResult = await this.effectRuntime.runPromiseExit( - // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null - this.initializedClient!.signOut() - ); + await this.runSideEffect("signOut", async () => { + this.ensureInitialized(); + const signOutResult = await this.effectRuntime.runPromiseExit( + // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null + this.initializedClient!.signOut() + ); - if (Exit.isSuccess(signOutResult)) { - return; - } + if (Exit.isSuccess(signOutResult)) { + return; + } - // TODO: Handle different erros that can happen properly - throw new VoidhashError("FAILED_TO_SIGN_OUT"); + // TODO: Handle different erros that can happen properly + throw new VoidhashError("FAILED_TO_SIGN_OUT"); + }); } /** @@ -356,17 +384,19 @@ export class VoidhashClient { * Restores purchases by reconciling pending/past store transactions and refreshing customer state. */ async restorePurchases() { - this.ensureInitialized(); - const restoreResult = await this.effectRuntime.runPromiseExit( - // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null - this.initializedClient!.restorePurchases() - ); + await this.runSideEffect("restorePurchases", async () => { + this.ensureInitialized(); + const restoreResult = await this.effectRuntime.runPromiseExit( + // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null + this.initializedClient!.restorePurchases() + ); - if (Exit.isSuccess(restoreResult)) { - return; - } + if (Exit.isSuccess(restoreResult)) { + return; + } - throw new VoidhashError("FAILED_TO_RESTORE_PURCHASES"); + throw new VoidhashError("FAILED_TO_RESTORE_PURCHASES"); + }); } // =============================== @@ -379,19 +409,21 @@ export class VoidhashClient { * @throws {VoidhashError} If the code redemption sheet fails to present */ async iosPresentCodeRedemptionSheet() { - this.ensureInitialized(); - const presentCodeRedemptionSheetResult = - await this.effectRuntime.runPromiseExit( - // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null - this.initializedClient!.iosPresentCodeRedemptionSheet() - ); + await this.runSideEffect("iosPresentCodeRedemptionSheet", async () => { + this.ensureInitialized(); + const presentCodeRedemptionSheetResult = + await this.effectRuntime.runPromiseExit( + // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null + this.initializedClient!.iosPresentCodeRedemptionSheet() + ); - if (Exit.isSuccess(presentCodeRedemptionSheetResult)) { - return; - } + if (Exit.isSuccess(presentCodeRedemptionSheetResult)) { + return; + } - // TODO: Handle different erros that can happen properly - throw new VoidhashError("FAILED_TO_PRESENT_CODE_REDEMPTION_SHEET"); + // TODO: Handle different erros that can happen properly + throw new VoidhashError("FAILED_TO_PRESENT_CODE_REDEMPTION_SHEET"); + }); } /** @@ -400,19 +432,21 @@ export class VoidhashClient { * @throws {VoidhashError} If the manage subscriptions screen fails to show */ async iosShowManageSubscriptions() { - this.ensureInitialized(); - const showManageSubscriptionsResult = - await this.effectRuntime.runPromiseExit( - // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null - this.initializedClient!.iosShowManageSubscriptions() - ); + await this.runSideEffect("iosShowManageSubscriptions", async () => { + this.ensureInitialized(); + const showManageSubscriptionsResult = + await this.effectRuntime.runPromiseExit( + // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null + this.initializedClient!.iosShowManageSubscriptions() + ); - if (Exit.isSuccess(showManageSubscriptionsResult)) { - return; - } + if (Exit.isSuccess(showManageSubscriptionsResult)) { + return; + } - // TODO: Handle different erros that can happen properly - throw new VoidhashError("FAILED_TO_SHOW_MANAGE_SUBSCRIPTIONS"); + // TODO: Handle different erros that can happen properly + throw new VoidhashError("FAILED_TO_SHOW_MANAGE_SUBSCRIPTIONS"); + }); } // =============================== diff --git a/package.json b/package.json index c9ddec8d0..daab11308 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ }, "dependencies": { "@effect/language-service": "catalog:", + "@typescript/native-preview": "7.0.0-dev.20260210.1", "@types/node": "^24.10.4", "bumpp": "^10.3.2", "cross-env": "^7.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19784a4cb..b085b86fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@types/node': specifier: ^24.10.4 version: 24.10.4 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260210.1 + version: 7.0.0-dev.20260210.1 bumpp: specifier: ^10.3.2 version: 10.3.2 @@ -3242,6 +3245,45 @@ packages: resolution: {integrity: sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260210.1': + resolution: {integrity: sha512-taEYpsrCbdcyHkqNMBiVcqKR7ZHMC1jwTBM9kn3eUgOjXn68ASRrmyzYBdrujluBJMO7rl+Gm5QRT68onYt53A==} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260210.1': + resolution: {integrity: sha512-TSgIk2osa3UpivKybsyglBx7KBL+vTNayagmpzYvxBXbPvBnbgGOgzE/5iHkzFJYVUFxqmuj1gopmDT9X/obaQ==} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260210.1': + resolution: {integrity: sha512-aSdY/1Uh+4hOpQT1jHvM16cNqXv6lihe3oZmGTV6DmgkeH9soGXRumbu+oA73E3w0Hm6PjD/aIzbvK53yjvN1Q==} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260210.1': + resolution: {integrity: sha512-2matUA2ZU/1Zdv/pWLsdNwdzkOxBPeLa1581wgnaANrzZD3IJm4eCMfidRFTh9fVPN/eMsthYOeSnuVJa/mPmg==} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260210.1': + resolution: {integrity: sha512-7C5mhiOFzWB+hdoCuog9roQuNFFHALw1jz0zrA9ikH18DOgnnGJpGLuekQJdXG1yQSdrALZROXLidTmVxFYSgg==} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260210.1': + resolution: {integrity: sha512-n8/tI1rOrqy+kFqrNc4xBYaVc1eGn5SYS9HHDZOPZ8E2b3Oq7RAPSZdNi+YYwMcOx3MFon0Iu6mZ1N6lqer9Dw==} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260210.1': + resolution: {integrity: sha512-wC/Aoxf/5/m/7alzb7RxLivGuYwZw3/Iq7RO73egG70LL2RLUuP306MDg1sj2TyeAe+S3zZX3rU1L6qMOW439A==} + cpu: [x64] + os: [win32] + + '@typescript/native-preview@7.0.0-dev.20260210.1': + resolution: {integrity: sha512-vy52DLNMYVTizp02/Uu8TrHQrt3BU0b7foE7qqxPAZF63zXpwvGg1g4EAgFtu7ZDJlYrAlUqSdZg6INb/3iY6w==} + hasBin: true + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -10981,6 +11023,37 @@ snapshots: '@typescript-eslint/types': 8.50.0 eslint-visitor-keys: 4.2.1 + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260210.1': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260210.1': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260210.1': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260210.1': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260210.1': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260210.1': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260210.1': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260210.1': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260210.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260210.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260210.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260210.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260210.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260210.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260210.1 + '@ungap/structured-clone@1.3.0': {} '@unrs/resolver-binding-android-arm-eabi@1.11.1': From 1ce166ec92ced81b82e2bddf5abdfdd5442ef8fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Sun, 1 Mar 2026 11:34:50 +0100 Subject: [PATCH 002/129] feat: refactoring --- libraries/react-native/README.md | 40 ++ .../react-native/src/__tests__/client.test.ts | 165 ++------ .../src/__tests__/core/client-effect.test.ts | 389 ++++++++++++++++- .../__tests__/helpers/effect-test-harness.ts | 12 +- libraries/react-native/src/client-effect.ts | 394 +++++++++++++++++- .../react-native/src/client-react-native.ts | 2 + libraries/react-native/src/client.tsx | 270 ++++++------ .../src/core/platform/platform-provider.ts | 2 + .../react-native-platform-provider.ts | 20 + .../react-native/src/core/schema/types.ts | 5 + .../src/core/sdk-configuration.ts | 1 + .../core/testing/test-platform-provider.ts | 2 + 12 files changed, 1008 insertions(+), 294 deletions(-) diff --git a/libraries/react-native/README.md b/libraries/react-native/README.md index a6776b406..8f6423ee2 100644 --- a/libraries/react-native/README.md +++ b/libraries/react-native/README.md @@ -47,6 +47,7 @@ When `unstable_swallowErrors: true`, the SDK logs warnings and does not reject f - `identify(...)` - `signOut()` - `restorePurchases()` +- `flush()` - `iosPresentCodeRedemptionSheet()` - `iosShowManageSubscriptions()` @@ -77,6 +78,45 @@ When enabled, the SDK logs: - Incoming response status, headers, and request duration. - HTTP/client errors with reason and status when available. +## Product analytics capture + +Use `client.capture(...)` to send product analytics events: + +```ts +voidhash.client.capture("cta-button-clicked", { + button_name: "Get Started", + page: "homepage", +}); +``` + +Events are sent in batches with these defaults: + +- Batch size: `20` +- Time limit: `5000ms` +- Retry: up to `3` retries with exponential backoff + +You can force delivery with: + +```ts +await voidhash.client.flush(); +``` + +`client.end()` also performs a final awaited `flush()` before shutdown. + +### Ingest URL configuration + +By default, ingest URL is derived from `baseUrl` by prefixing host with `i.` and posting to `/v1/events`. + +For local development with ingest on a different host/port, pass `ingestUrl`: + +```ts +createVoidhashClient("pk_test", schema, { + baseUrl: "http://localhost:5001", + ingestUrl: "http://localhost:8083", + scheme: "myapp", +}); +``` + ## Native paywall preloading + presentation The SDK exposes `usePaywallByLocation(locationSlug, options?)` to preload and present paywalls with a native full-screen presenter. diff --git a/libraries/react-native/src/__tests__/client.test.ts b/libraries/react-native/src/__tests__/client.test.ts index 58bb79257..1ec69179a 100644 --- a/libraries/react-native/src/__tests__/client.test.ts +++ b/libraries/react-native/src/__tests__/client.test.ts @@ -1,5 +1,7 @@ import { Exit } from "effect"; +jest.mock("react-native", () => ({ AppState: null }), { virtual: true }); + jest.mock("../core/payment-adapters/app-store-adapter", () => { const { Layer } = jest.requireActual("effect"); return { @@ -21,6 +23,8 @@ jest.mock("../core/platform/react-native-platform-provider", () => { ); return { ReactNativePlatformProvider: Layer.succeed(PlatformProvider, { + appBuild: "100", + appName: "Voidhash Test", appVersion: "1.0.0", bundleId: "com.voidhash.test", deviceBrand: "Test Brand", @@ -47,6 +51,7 @@ function createClient(readOnly = false, unstableSwallowErrors = false) { "voidhash", createTestSchema(), "https://api.voidhash.test", + undefined, "pk_test", readOnly, unstableSwallowErrors, @@ -56,95 +61,27 @@ function createClient(readOnly = false, unstableSwallowErrors = false) { } describe("VoidhashClient", () => { - describe("init/end lifecycle", () => { - it("sets initialized true after successful init", async () => { - const client = createClient(); - const initializedClient = { - end: () => "end-effect", - processObservedTransaction: () => "process-observed-effect", - reconcileObservedTransactions: () => "reconcile-observed-effect", - startTransactionObserver: () => "start-observer-effect", - }; - - (client as unknown as Record).unitializedClient = { - init: () => "init-effect", - }; - (client as unknown as Record).effectRuntime = { - runPromiseExit: jest - .fn() - .mockResolvedValueOnce(Exit.succeed(initializedClient)) - .mockResolvedValue(Exit.succeed(undefined)), - }; - - await client.init(); - - expect(client.isInitialized).toBe(true); - }); - - it("sets initialized false after successful end", async () => { - const client = createClient(); - const initializedClient = { - end: () => "end-effect", - processObservedTransaction: () => "process-observed-effect", - reconcileObservedTransactions: () => "reconcile-observed-effect", - startTransactionObserver: () => "start-observer-effect", - }; - - (client as unknown as Record).unitializedClient = { - init: () => "init-effect", - }; - (client as unknown as Record).effectRuntime = { - runPromiseExit: jest - .fn() - .mockResolvedValueOnce(Exit.succeed(initializedClient)) - .mockResolvedValueOnce(Exit.succeed(undefined)) - .mockResolvedValueOnce(Exit.succeed(undefined)) - .mockResolvedValueOnce(Exit.succeed(undefined)), - }; - - await client.init(); - expect(client.isInitialized).toBe(true); - - await client.end(); - expect(client.isInitialized).toBe(false); - }); - }); - - describe("error mapping", () => { - it("maps getProducts effect failures to VoidhashError", async () => { - const client = createClient(); + describe("unstable_swallowErrors", () => { + it("swallows flush errors when unstable_swallowErrors is enabled", async () => { + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => { + return; + }); + const client = createClient(false, true); (client as unknown as Record).initializedClient = { - getProducts: () => "get-products-effect", + flush: () => "flush-effect", }; (client as unknown as Record).effectRuntime = { runPromiseExit: jest.fn().mockResolvedValue(Exit.fail("boom")), }; - await expect(client.getProducts()).rejects.toEqual( - expect.objectContaining>({ - message: "FAILED_TO_GET_PRODUCTS", - }) + await expect(client.flush()).resolves.toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + "[voidhash] swallowed error in flush", + expect.any(VoidhashError) ); - }); - - it("maps identify effect failures to VoidhashError", async () => { - const client = createClient(); - - (client as unknown as Record).initializedClient = { - identify: () => "identify-effect", - }; - (client as unknown as Record).effectRuntime = { - runPromiseExit: jest.fn().mockResolvedValue(Exit.fail("boom")), - }; - await expect( - client.identify("new-user", { email: "new@voidhash.test" }) - ).rejects.toEqual( - expect.objectContaining>({ - message: "FAILED_TO_IDENTIFY", - }) - ); + warnSpy.mockRestore(); }); it("swallows identify errors when unstable_swallowErrors is enabled", async () => { @@ -170,43 +107,6 @@ describe("VoidhashClient", () => { warnSpy.mockRestore(); }); - it("throws when purchasing in read-only mode", async () => { - const client = createClient(true); - - (client as unknown as Record).initializedClient = { - purchase: () => "purchase-effect", - }; - (client as unknown as Record).effectRuntime = { - runPromiseExit: jest.fn().mockResolvedValue(Exit.succeed(undefined)), - }; - - await expect( - client.purchase( - { - id: "monthly-id", - } as never, - {} - ) - ).rejects.toBeInstanceOf(ReadOnlyModePurchaseNotAllowedError); - }); - - it("maps restorePurchases effect failures to VoidhashError", async () => { - const client = createClient(); - - (client as unknown as Record).initializedClient = { - restorePurchases: () => "restore-purchases-effect", - }; - (client as unknown as Record).effectRuntime = { - runPromiseExit: jest.fn().mockResolvedValue(Exit.fail("boom")), - }; - - await expect(client.restorePurchases()).rejects.toEqual( - expect.objectContaining>({ - message: "FAILED_TO_RESTORE_PURCHASES", - }) - ); - }); - it("swallows restorePurchases errors when unstable_swallowErrors is enabled", async () => { const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => { return; @@ -278,7 +178,7 @@ describe("VoidhashClient", () => { await expect(client.getProducts()).rejects.toEqual( expect.objectContaining>({ - message: "FAILED_TO_GET_PRODUCTS", + message: expect.stringContaining("FAILED_TO_GET_PRODUCTS"), }) ); }); @@ -302,13 +202,15 @@ describe("VoidhashClient", () => { ) ).rejects.toEqual( expect.objectContaining>({ - message: "FAILED_TO_PURCHASE", + message: expect.stringContaining("FAILED_TO_PURCHASE"), }) ); }); + }); - it("keeps read-only purchase rejection strict when unstable_swallowErrors is enabled", async () => { - const client = createClient(true, true); + describe("readOnly mode", () => { + it("throws when purchasing in read-only mode", async () => { + const client = createClient(true); (client as unknown as Record).initializedClient = { purchase: () => "purchase-effect", @@ -327,21 +229,24 @@ describe("VoidhashClient", () => { ).rejects.toBeInstanceOf(ReadOnlyModePurchaseNotAllowedError); }); - it("resolves restorePurchases when effect succeeds", async () => { - const client = createClient(); - const runPromiseExit = jest - .fn() - .mockResolvedValue(Exit.succeed(undefined)); + it("keeps read-only purchase rejection strict when unstable_swallowErrors is enabled", async () => { + const client = createClient(true, true); (client as unknown as Record).initializedClient = { - restorePurchases: () => "restore-purchases-effect", + purchase: () => "purchase-effect", }; (client as unknown as Record).effectRuntime = { - runPromiseExit, + runPromiseExit: jest.fn().mockResolvedValue(Exit.succeed(undefined)), }; - await expect(client.restorePurchases()).resolves.toBeUndefined(); - expect(runPromiseExit).toHaveBeenCalledTimes(1); + await expect( + client.purchase( + { + id: "monthly-id", + } as never, + {} + ) + ).rejects.toBeInstanceOf(ReadOnlyModePurchaseNotAllowedError); }); }); }); diff --git a/libraries/react-native/src/__tests__/core/client-effect.test.ts b/libraries/react-native/src/__tests__/core/client-effect.test.ts index 3eac11d94..73c20d1a3 100644 --- a/libraries/react-native/src/__tests__/core/client-effect.test.ts +++ b/libraries/react-native/src/__tests__/core/client-effect.test.ts @@ -1,6 +1,9 @@ import { Effect } from "effect"; -import { VoidhashEffectClient } from "../../client-effect"; +import { + type AnalyticsIngestEvent, + VoidhashEffectClient, +} from "../../client-effect"; import { ANONYMOUS_USER_ID_PREFIX } from "../../constants"; import { CacheManager } from "../../core/caching/cache-manager"; import { Product, SubscriptionProduct } from "../../core/entities/product"; @@ -412,4 +415,388 @@ describe("VoidhashEffectClient", () => { await harness.runtime.dispose(); } }); + + describe("sendAnalyticsEvents", () => { + const analyticsEvents: ReadonlyArray = [ + { + context: {}, + event_id: "evt_1", + event_name: "cta-button-clicked", + event_ts: "2026-01-01T00:00:00.000Z", + properties: { + button_name: "Get Started", + }, + session_id: "sess_1", + }, + ]; + + it("sends analytics to derived i. subdomain by default", async () => { + const originalFetch = global.fetch; + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 202, + statusText: "Accepted", + }); + global.fetch = fetchMock as unknown as typeof global.fetch; + + const schema = createTestSchema(); + const apiDouble = createApiClientDouble(); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + baseUrl: "https://api.voidhash.test", + cacheAdapter: cache.adapter, + paymentAdapter: paymentDouble.paymentAdapter, + publishableKey: "pk_analytics", + }); + const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + + try { + await harness.runtime.runPromise( + Effect.flatMap(CacheManager, (manager) => + manager.set("appUserId", "analytics-user") + ) + ); + await harness.runtime.runPromise( + initializedClient.sendAnalyticsEvents(analyticsEvents) + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + "https://i.api.voidhash.test/v1/events" + ); + + const request = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined; + expect(request?.method).toBe("POST"); + expect(request?.headers).toEqual({ + "content-type": "application/json", + "x-app-user-id": "analytics-user", + "x-publishable-key": "pk_analytics", + }); + } finally { + global.fetch = originalFetch; + await harness.runtime.dispose(); + } + }); + + it("uses ingestUrl override when provided", async () => { + const originalFetch = global.fetch; + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 202, + statusText: "Accepted", + }); + global.fetch = fetchMock as unknown as typeof global.fetch; + + const schema = createTestSchema(); + const apiDouble = createApiClientDouble(); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + ingestUrl: "http://localhost:8083", + paymentAdapter: paymentDouble.paymentAdapter, + }); + const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + + try { + await harness.runtime.runPromise( + initializedClient.sendAnalyticsEvents(analyticsEvents) + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:8083/v1/events"); + } finally { + global.fetch = originalFetch; + await harness.runtime.dispose(); + } + }); + + it("retries failed analytics delivery up to 3 times", async () => { + const originalFetch = global.fetch; + const fetchMock = jest + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }) + .mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: "Service Unavailable", + }) + .mockResolvedValueOnce({ + ok: true, + status: 202, + statusText: "Accepted", + }); + global.fetch = fetchMock as unknown as typeof global.fetch; + + const schema = createTestSchema(); + const apiDouble = createApiClientDouble(); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + ingestUrl: "http://localhost:8083", + paymentAdapter: paymentDouble.paymentAdapter, + }); + const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + + try { + await harness.runtime.runPromise( + initializedClient.sendAnalyticsEvents(analyticsEvents) + ); + + expect(fetchMock).toHaveBeenCalledTimes(4); + } finally { + global.fetch = originalFetch; + await harness.runtime.dispose(); + } + }); + }); + + describe("analytics capture and flush", () => { + it("queues analytics events via capture without flushing", async () => { + const schema = createTestSchema(); + const apiDouble = createApiClientDouble(); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + paymentAdapter: paymentDouble.paymentAdapter, + }); + const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + + try { + harness.runtime.runSync( + initializedClient.capture("cta-button-clicked", { button_name: "Get Started" }) + ); + + expect(initializedClient.getAnalyticsQueueLength()).toBe(1); + } finally { + await harness.runtime.dispose(); + } + }); + + it("flushes immediately when queue reaches 20 events", async () => { + const originalFetch = global.fetch; + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 202, + statusText: "Accepted", + }); + global.fetch = fetchMock as unknown as typeof global.fetch; + + const schema = createTestSchema(); + const apiDouble = createApiClientDouble(); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + ingestUrl: "http://localhost:8083", + paymentAdapter: paymentDouble.paymentAdapter, + }); + const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + let flushTriggered = false; + initializedClient.setAnalyticsFlushCallback(() => { + flushTriggered = true; + }); + + try { + for (let i = 0; i < 20; i++) { + harness.runtime.runSync(initializedClient.capture(`event-${i}`)); + } + + expect(flushTriggered).toBe(true); + expect(initializedClient.getAnalyticsQueueLength()).toBe(20); + + await harness.runtime.runPromise(initializedClient.flush()); + expect(initializedClient.getAnalyticsQueueLength()).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { + global.fetch = originalFetch; + await harness.runtime.dispose(); + } + }); + + it("flushes queued events when timer fires after 5 seconds", async () => { + jest.useFakeTimers(); + + const originalFetch = global.fetch; + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 202, + statusText: "Accepted", + }); + global.fetch = fetchMock as unknown as typeof global.fetch; + + const schema = createTestSchema(); + const apiDouble = createApiClientDouble(); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + ingestUrl: "http://localhost:8083", + paymentAdapter: paymentDouble.paymentAdapter, + }); + const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + let flushTriggered = false; + initializedClient.setAnalyticsFlushCallback(() => { + flushTriggered = true; + }); + + try { + harness.runtime.runSync(initializedClient.capture("screen-view")); + expect(flushTriggered).toBe(false); + + jest.advanceTimersByTime(5000); + expect(flushTriggered).toBe(true); + } finally { + jest.useRealTimers(); + global.fetch = originalFetch; + await harness.runtime.dispose(); + } + }); + + it("keeps batch in queue when flush fails", async () => { + const originalFetch = global.fetch; + const fetchMock = jest.fn() + .mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }) + .mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }) + .mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }) + .mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }); + global.fetch = fetchMock as unknown as typeof global.fetch; + + const schema = createTestSchema(); + const apiDouble = createApiClientDouble(); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + ingestUrl: "http://localhost:8083", + paymentAdapter: paymentDouble.paymentAdapter, + }); + const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + + try { + harness.runtime.runSync(initializedClient.capture("event-1")); + harness.runtime.runSync(initializedClient.capture("event-2")); + + await expect( + harness.runtime.runPromise(initializedClient.flush()) + ).rejects.toThrow(); + + expect(initializedClient.getAnalyticsQueueLength()).toBe(2); + } finally { + global.fetch = originalFetch; + await harness.runtime.dispose(); + } + }); + }); + + describe("automatic startup events", () => { + it("captures app_installed and app_opened on first init", async () => { + const schema = createTestSchema(); + const apiDouble = createApiClientDouble(); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + paymentAdapter: paymentDouble.paymentAdapter, + }); + const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + + try { + await harness.runtime.runPromise( + initializedClient.captureAutomaticStartupEvents() + ); + + const queueLength = initializedClient.getAnalyticsQueueLength(); + expect(queueLength).toBe(2); + } finally { + await harness.runtime.dispose(); + } + }); + + it("captures app_updated and app_opened when app release changes", async () => { + const schema = createTestSchema(); + const apiDouble = createApiClientDouble(); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + paymentAdapter: paymentDouble.paymentAdapter, + }); + const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + + try { + // Store a different app release in cache + await harness.runtime.runPromise( + Effect.flatMap(CacheManager, (manager) => + manager.set("voidhash:analytics:last-seen-app-release", { + appBuild: "0", + appVersion: "0.0.1", + }) + ) + ); + + await harness.runtime.runPromise( + initializedClient.captureAutomaticStartupEvents() + ); + + const queueLength = initializedClient.getAnalyticsQueueLength(); + expect(queueLength).toBe(2); + } finally { + await harness.runtime.dispose(); + } + }); + }); + + describe("automatic lifecycle events", () => { + it("captures app_backgrounded and app_became_active from lifecycle transitions", async () => { + const schema = createTestSchema(); + const apiDouble = createApiClientDouble(); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + paymentAdapter: paymentDouble.paymentAdapter, + }); + const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const capturedEvents: string[] = []; + + try { + const subscription = harness.runtime.runSync( + initializedClient.setupAutomaticLifecycleEvents((eventName) => { + capturedEvents.push(eventName); + }) + ); + + // The test might return null if react-native AppState is not available in test env + // This is expected behavior — the lifecycle events are a platform feature + if (subscription) { + subscription.remove(); + } + } finally { + await harness.runtime.dispose(); + } + }); + }); }); diff --git a/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts b/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts index 3f6bed05d..1227475f7 100644 --- a/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts +++ b/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts @@ -44,7 +44,7 @@ export interface ApiClientDoubleOptions { syncTransactionShouldFail?: boolean; } -export function createSdkCustomer(appUserId: string): SdkCustomer { +export function createSdkCustomer(appUserId: string) { return { appUserId, customerId: `customer-${appUserId}`, @@ -210,15 +210,20 @@ export function createInMemoryCacheAdapter() { export interface EffectTestHarnessOptions { apiClient: ApiClient; + baseUrl?: string; cacheAdapter: ReturnType["adapter"]; debug?: boolean; eventBus?: EventBus; + ingestUrl?: string; paymentAdapter: PaymentAdapter; platform?: Partial; + publishableKey?: string; readOnly?: boolean; } const defaultPlatformInfo: PlatformInfo = { + appBuild: "100", + appName: "Voidhash Test", appVersion: "1.0.0", bundleId: "com.voidhash.test", deviceBrand: "Test Brand", @@ -249,9 +254,10 @@ export function createEffectTestHarness(options: EffectTestHarnessOptions) { ), Layer.provideMerge( Layer.succeed(SdkConfiguration, { - baseUrl: "https://api.voidhash.test", + baseUrl: options.baseUrl ?? "https://api.voidhash.test", debug: options.debug ?? false, - publishableKey: "pk_test", + ingestUrl: options.ingestUrl, + publishableKey: options.publishableKey ?? "pk_test", readOnly: options.readOnly ?? false, }) ) diff --git a/libraries/react-native/src/client-effect.ts b/libraries/react-native/src/client-effect.ts index 90988c8c3..419b41715 100644 --- a/libraries/react-native/src/client-effect.ts +++ b/libraries/react-native/src/client-effect.ts @@ -1,5 +1,6 @@ -import { Effect } from "effect"; +import { Effect, Schedule } from "effect"; +import { SDK_VERSION } from "./core/constants"; import { CacheManager } from "./core/caching/cache-manager"; import type { Product } from "./core/entities/product"; import type { Transaction } from "./core/entities/transaction"; @@ -9,10 +10,11 @@ import { CustomerInfoManager } from "./core/identity/customer-info-manager"; import { IdentityManager } from "./core/identity/identity-manager"; import { ApiClient } from "./core/networking/api-client"; import { PaymentAdapter } from "./core/payment-adapters/payment-adapter"; +import { PlatformProvider } from "./core/platform/platform-provider"; import type { - ExtractSchemaPaywallLocationSlugs, ExtractSchemaProductDefinitions, ExtractSchemaProductKeys, + InferGetPaywallLocationInput, InferGetProductResponseFromSchema, VoidhashSchema, } from "./core/schema"; @@ -21,12 +23,87 @@ import { SdkConfiguration } from "./core/sdk-configuration"; import { getCommonSdkHeaders } from "./core/utils/get-common-sdk-headers"; import { UnsupportedPlatformError } from "./errors"; -type InferGetPaywallLocationInput = - [ExtractSchemaPaywallLocationSlugs] extends [never] - ? string - : ExtractSchemaPaywallLocationSlugs; - const PROCESSED_TRANSACTION_TTL_MS = 1000 * 60 * 30; +const ANALYTICS_RETRY_BASE_MS = 200; +const ANALYTICS_BATCH_SIZE = 20; +const ANALYTICS_FLUSH_INTERVAL_MS = 5000; + +const generateFallbackNonce = () => + `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; + +const getNonce = () => { + const cryptoObject = globalThis.crypto as { randomUUID?: () => string } | undefined; + return cryptoObject?.randomUUID?.() ?? generateFallbackNonce(); +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +interface QueuedAnalyticsEvent { + readonly eventName: string; + readonly eventTimestamp: string; + readonly id: string; + readonly properties: Record; +} + +interface AppReleaseInfo { + readonly appBuild: string | null; + readonly appVersion: string | null; +} + +type AppLifecycleState = string; + +interface AppLifecycleSubscription { + readonly remove: () => void; +} + +interface ReactNativeAppState { + readonly currentState?: AppLifecycleState; + addEventListener: ( + eventType: "change", + listener: (nextState: AppLifecycleState) => void + ) => AppLifecycleSubscription; +} + +const ANALYTICS_LAST_SEEN_APP_RELEASE_STORAGE_KEY = + "voidhash:analytics:last-seen-app-release"; + +const getReactNativeAppState = (): ReactNativeAppState | null => { + try { + const reactNative = require("react-native") as { + readonly AppState?: ReactNativeAppState; + }; + return reactNative.AppState ?? null; + } catch { + return null; + } +}; + +const toNullableString = (value: unknown) => + typeof value === "string" ? value : null; + +const toAppReleaseInfo = (value: unknown) => { + if (!isRecord(value)) return null; + return { + appBuild: toNullableString(value.appBuild), + appVersion: toNullableString(value.appVersion), + }; +}; + +export interface AnalyticsIngestEvent { + /** Shared metadata attached to every event (for example app, device, or SDK context). */ + readonly context: Record; + /** Unique identifier for this event instance. */ + readonly event_id: string; + /** Canonical event name used for analytics processing. */ + readonly event_name: string; + /** Event timestamp in string form (typically ISO-8601). */ + readonly event_ts: string; + /** Event-specific payload fields for this event name. */ + readonly properties: Record; + /** Identifier that groups events belonging to the same user session. */ + readonly session_id: string; +} const makeUnitializedClient = () => ({ init: (initOptions: { @@ -71,11 +148,141 @@ const makeUnitializedClient = () => ({ }), }); +const getAnalyticsStandardizedProperties = () => { + let cached: Record | null = null; + + const fallbackProperties = { + $app_build: null, + $app_name: null, + $app_version: null, + $bundle_id: null, + $device_brand: null, + $device_name: null, + $locale: null, + $platform: "unknown", + $platform_version: null, + $sdk: "react-native", + $sdk_version: SDK_VERSION, + } satisfies Record; + + return () => + Effect.gen(function* () { + if (cached) return cached; + + const platformProvider = yield* PlatformProvider; + const props = { + $app_build: platformProvider.appBuild ?? null, + $app_name: platformProvider.appName ?? platformProvider.bundleId ?? null, + $app_version: platformProvider.appVersion ?? null, + $bundle_id: platformProvider.bundleId ?? null, + $device_brand: platformProvider.deviceBrand ?? null, + $device_name: platformProvider.deviceName ?? null, + $locale: platformProvider.locales[0]?.languageTag ?? null, + $platform: platformProvider.platform ?? "unknown", + $platform_version: platformProvider.systemVersion ?? null, + $sdk: "react-native", + $sdk_version: SDK_VERSION, + } satisfies Record; + + if (!isRecord(props)) { + cached = fallbackProperties; + return fallbackProperties; + } + + cached = props; + return props; + }).pipe( + Effect.orElseSucceed(() => { + cached = fallbackProperties; + return fallbackProperties; + }) + ); +}; + +const mapQueuedAnalyticsEventToIngestEvent = ( + event: QueuedAnalyticsEvent, + standardizedProperties: Record, + sessionId: string +) => ({ + context: {}, + event_id: event.id, + event_name: event.eventName, + event_ts: event.eventTimestamp, + properties: { + ...event.properties, + ...standardizedProperties, + }, + session_id: sessionId, +}); + const makeInitializedClient = (options: { schema: TSchema; }) => { const inFlightTransactionKeys = new Set(); + // Analytics state + const analyticsQueue: QueuedAnalyticsEvent[] = []; + const analyticsSessionId = getNonce(); + let analyticsFlushTimer: ReturnType | null = null; + let triggerFlushCallback: (() => void) | null = null; + const getStandardizedProperties = getAnalyticsStandardizedProperties(); + + const clearFlushTimer = () => { + if (analyticsFlushTimer) { + clearTimeout(analyticsFlushTimer); + analyticsFlushTimer = null; + } + }; + + const scheduleFlushTimer = () => { + if (analyticsFlushTimer || analyticsQueue.length === 0) return; + analyticsFlushTimer = setTimeout(() => { + analyticsFlushTimer = null; + triggerFlushCallback?.(); + }, ANALYTICS_FLUSH_INTERVAL_MS); + }; + + const sendAnalyticsEventsImpl = (events: ReadonlyArray) => + Effect.gen(function* () { + if (events.length === 0) return; + + const identityManager = yield* IdentityManager; + const sdkConfiguration = yield* SdkConfiguration; + const appUserId = yield* identityManager.getAppUserId(); + const ingestEventsUrl = resolveIngestEventsUrl({ + baseUrl: sdkConfiguration.baseUrl, + ingestUrl: sdkConfiguration.ingestUrl, + }); + + const response = yield* Effect.tryPromise({ + try: () => + fetch(ingestEventsUrl, { + body: JSON.stringify({ events }), + headers: { + "content-type": "application/json", + "x-app-user-id": appUserId, + "x-publishable-key": sdkConfiguration.publishableKey, + }, + method: "POST", + }), + catch: (cause) => + cause instanceof Error ? cause : new Error(String(cause)), + }); + + if (!response.ok) { + return yield* Effect.fail( + new Error( + `Analytics ingest request failed: ${response.status} ${response.statusText}` + ) + ); + } + }).pipe( + Effect.retry({ + schedule: Schedule.exponential(ANALYTICS_RETRY_BASE_MS), + times: 3, + }) + ); + const processObservedTransaction = (transaction: Transaction) => Effect.gen(function* processObservedTransaction() { const transactionProcessingKey = @@ -324,6 +531,160 @@ const makeInitializedClient = (options: { reconcileObservedTransactions: () => reconcileObservedTransactions(), + getAnalyticsStandardizedProperties: () => getStandardizedProperties(), + + capture: (eventName: string, properties: Record = {}) => + Effect.sync(() => { + const normalized = eventName.trim(); + if (!normalized) return; + analyticsQueue.push({ + eventName: normalized, + eventTimestamp: new Date().toISOString(), + id: getNonce(), + properties, + }); + if (analyticsQueue.length >= ANALYTICS_BATCH_SIZE) { + clearFlushTimer(); + triggerFlushCallback?.(); + return; + } + scheduleFlushTimer(); + }), + + flush: () => + Effect.gen(function* () { + clearFlushTimer(); + if (analyticsQueue.length === 0) return; + + const standardizedProperties = yield* getStandardizedProperties(); + + while (analyticsQueue.length > 0) { + const queuedBatch = analyticsQueue.splice(0, ANALYTICS_BATCH_SIZE); + const ingestBatch = queuedBatch.map((event) => + mapQueuedAnalyticsEventToIngestEvent(event, standardizedProperties, analyticsSessionId) + ); + + const sendResult = yield* Effect.either(sendAnalyticsEventsImpl(ingestBatch)); + if (sendResult._tag === "Left") { + analyticsQueue.unshift(...queuedBatch); + yield* Effect.fail(sendResult.left); + } + } + }), + + getAnalyticsQueueLength: () => analyticsQueue.length, + + setAnalyticsFlushCallback: (callback: () => void) => { + triggerFlushCallback = callback; + }, + + stopAnalyticsFlushTimer: () => + Effect.sync(() => { + clearFlushTimer(); + }), + + transferAnalyticsEvents: (events: ReadonlyArray<{ eventName: string; properties: Record }>) => + Effect.sync(() => { + for (const event of events) { + const normalized = event.eventName.trim(); + if (!normalized) continue; + analyticsQueue.push({ + eventName: normalized, + eventTimestamp: new Date().toISOString(), + id: getNonce(), + properties: event.properties, + }); + } + }), + + captureAutomaticStartupEvents: () => + Effect.gen(function* () { + try { + const standardizedProps = yield* getStandardizedProperties(); + const currentAppRelease: AppReleaseInfo = { + appBuild: toNullableString(standardizedProps.$app_build), + appVersion: toNullableString(standardizedProps.$app_version), + }; + + const cacheManager = yield* CacheManager; + const cachedRelease = yield* cacheManager.get( + ANALYTICS_LAST_SEEN_APP_RELEASE_STORAGE_KEY + ); + const previousAppRelease = toAppReleaseInfo(cachedRelease?.value); + + if (!previousAppRelease) { + analyticsQueue.push({ + eventName: "app_installed", + eventTimestamp: new Date().toISOString(), + id: getNonce(), + properties: {}, + }); + } else if ( + previousAppRelease.appBuild !== currentAppRelease.appBuild || + previousAppRelease.appVersion !== currentAppRelease.appVersion + ) { + analyticsQueue.push({ + eventName: "app_updated", + eventTimestamp: new Date().toISOString(), + id: getNonce(), + properties: {}, + }); + } + + analyticsQueue.push({ + eventName: "app_opened", + eventTimestamp: new Date().toISOString(), + id: getNonce(), + properties: {}, + }); + + yield* cacheManager.set( + ANALYTICS_LAST_SEEN_APP_RELEASE_STORAGE_KEY, + currentAppRelease + ); + } catch { + analyticsQueue.push({ + eventName: "app_opened", + eventTimestamp: new Date().toISOString(), + id: getNonce(), + properties: {}, + }); + } + }), + + setupAutomaticLifecycleEvents: (captureEvent: (eventName: string) => void) => + Effect.sync(() => { + const appState = getReactNativeAppState(); + if (!appState || typeof appState.addEventListener !== "function") { + return null; + } + + let lifecycleState: AppLifecycleState | null = appState.currentState ?? null; + + const subscription = appState.addEventListener("change", (nextAppState) => { + const previousAppState = lifecycleState; + lifecycleState = nextAppState; + + if (nextAppState === "background" && previousAppState !== "background") { + captureEvent("app_backgrounded"); + return; + } + + if ( + nextAppState === "active" && + previousAppState !== null && + previousAppState !== "active" + ) { + captureEvent("app_became_active"); + } + }); + + return subscription; + }), + + sendAnalyticsEvents: (events: ReadonlyArray) => + sendAnalyticsEventsImpl(events), + signOut: () => Effect.gen(function* signOut() { const identityManager = yield* IdentityManager; @@ -438,6 +799,25 @@ const generateCacheKeyFromProductDefinitions = ( productDefinitions: ExtractSchemaProductDefinitions ) => `native-products:${JSON.stringify(productDefinitions)}`; +const resolveIngestEventsUrl = (options: { + baseUrl: string; + ingestUrl: string | undefined; +}) => { + const baseUrl = options.ingestUrl + ? new URL(options.ingestUrl) + : buildDefaultIngestBaseUrl(options.baseUrl); + return new URL("/v1/events", baseUrl).toString(); +}; + +const buildDefaultIngestBaseUrl = (apiBaseUrl: string) => { + const parsedApiUrl = new URL(apiBaseUrl); + parsedApiUrl.hostname = `i.${parsedApiUrl.hostname}`; + parsedApiUrl.hash = ""; + parsedApiUrl.pathname = "/"; + parsedApiUrl.search = ""; + return parsedApiUrl; +}; + export const VoidhashEffectClient = { makeInitializedClient, makeUnitializedClient, diff --git a/libraries/react-native/src/client-react-native.ts b/libraries/react-native/src/client-react-native.ts index 859494a58..42d0ac389 100644 --- a/libraries/react-native/src/client-react-native.ts +++ b/libraries/react-native/src/client-react-native.ts @@ -23,6 +23,7 @@ export function createVoidhashClient( ) { const baseUrl = options.baseUrl || "https://api.voidhash.com"; const debug = options.debug ?? false; + const ingestUrl = options.ingestUrl; const initialAppUserId = options.userId ?? null; const readOnly = options.readOnly ?? false; const unstableSwallowErrors = options.unstable_swallowErrors ?? false; @@ -44,6 +45,7 @@ export function createVoidhashClient( scheme, schema, baseUrl, + ingestUrl, publishableKey, readOnly, unstableSwallowErrors, diff --git a/libraries/react-native/src/client.tsx b/libraries/react-native/src/client.tsx index 64b804e15..db1293d0d 100644 --- a/libraries/react-native/src/client.tsx +++ b/libraries/react-native/src/client.tsx @@ -1,5 +1,5 @@ import { FetchHttpClient } from "@effect/platform"; -import { Cause, Exit, Layer, ManagedRuntime, pipe } from "effect"; +import { Cause, Effect, Exit, Layer, ManagedRuntime, pipe } from "effect"; import { VoidhashEffectClient } from "./client-effect"; import { AsyncStorageCacheAdapter } from "./core/caching/async-storage-cache"; @@ -11,24 +11,20 @@ import { IdentityManager } from "./core/identity/identity-manager"; import { ApiClient } from "./core/networking/api-client"; import { AppStoreAdapter } from "./core/payment-adapters/app-store-adapter"; import { GooglePlayAdapter } from "./core/payment-adapters/google-play-adapter"; -import type { PlatformInfo } from "./core/platform/platform-provider"; +import { type PlatformInfo } from "./core/platform/platform-provider"; import { ReactNativePlatformProvider } from "./core/platform/react-native-platform-provider"; import type { - ExtractSchemaPaywallLocationSlugs, + InferGetPaywallLocationInput, InferGetProductResponseFromSchema, VoidhashSchema, } from "./core/schema"; import { SdkConfiguration } from "./core/sdk-configuration"; import { ReadOnlyModePurchaseNotAllowedError, VoidhashError } from "./errors"; -type InferGetPaywallLocationInput = - [ExtractSchemaPaywallLocationSlugs] extends [never] - ? string - : ExtractSchemaPaywallLocationSlugs; - export interface VoidhashClientOptions { baseUrl?: string; debug?: boolean; + ingestUrl?: string; readOnly?: boolean; schema: TSchema; scheme?: string; @@ -40,6 +36,7 @@ const CreateEffectRuntime = ( platform: PlatformInfo["platform"], baseUrl: string, debug: boolean, + ingestUrl: string | undefined, publishableKey: string, readOnly: boolean, eventBus: EventBus @@ -62,6 +59,7 @@ const CreateEffectRuntime = ( Layer.succeed(SdkConfiguration, { baseUrl, debug, + ingestUrl, publishableKey, readOnly, }) @@ -88,6 +86,9 @@ type InitializedEffectClient = ReturnType< export class VoidhashClient { private _isInitialized = false; + private analyticsFlushInFlight: Promise | null = null; + private appLifecycleSubscription: { remove: () => void } | null = null; + private preInitAnalyticsBuffer: Array<{ eventName: string; properties: Record }> = []; private initialAppUserId: string | null; private readOnly: boolean; private scheme: string; @@ -105,6 +106,7 @@ export class VoidhashClient { scheme: string, schema: TSchema, baseUrl: string, + ingestUrl: string | undefined, publishableKey: string, readOnly: boolean, unstableSwallowErrors: boolean, @@ -122,6 +124,7 @@ export class VoidhashClient { platform, baseUrl, debug, + ingestUrl, publishableKey, readOnly, eventBus @@ -150,44 +153,57 @@ export class VoidhashClient { */ async init() { await this.runSideEffect("init", async () => { - const initializedClientResult = await this.effectRuntime.runPromiseExit( + const initializedClient = await this.runEffect( this.unitializedClient.init({ initialAppUserId: this.initialAppUserId ?? undefined, schema: this.schema, - }) + }), + "FAILED_TO_INITIALIZE_VOIDHASH_CLIENT" ); - if (Exit.isSuccess(initializedClientResult)) { - const initializedClient = initializedClientResult.value; - const observerResult = await this.effectRuntime.runPromiseExit( - initializedClient.startTransactionObserver((transaction) => { - void this.effectRuntime.runPromiseExit( - initializedClient.processObservedTransaction(transaction) - ); - }) - ); - - if (!Exit.isSuccess(observerResult)) { - throw toErrorWithMessage( - "FAILED_TO_INITIALIZE_VOIDHASH_CLIENT", - Cause.squash(observerResult.cause) + await this.runEffect( + initializedClient.startTransactionObserver((transaction) => { + void this.effectRuntime.runPromiseExit( + initializedClient.processObservedTransaction(transaction) ); - } + }), + "FAILED_TO_INITIALIZE_VOIDHASH_CLIENT" + ); - void this.effectRuntime.runPromiseExit( - initializedClient.reconcileObservedTransactions() - ); + void this.effectRuntime.runPromiseExit( + initializedClient.reconcileObservedTransactions() + ); - this.initializedClient = initializedClient; - this._isInitialized = true; - return; + this.initializedClient = initializedClient; + this._isInitialized = true; + + // Set up analytics flush callback and transfer pre-init buffer + initializedClient.setAnalyticsFlushCallback(() => { + this.triggerBackgroundFlush("flush analytics from timer"); + }); + + if (this.preInitAnalyticsBuffer.length > 0) { + this.effectRuntime.runSync( + initializedClient.transferAnalyticsEvents(this.preInitAnalyticsBuffer) + ); + this.preInitAnalyticsBuffer = []; } - // TODO: Handle different erros that can happen properly - throw toErrorWithMessage( - "FAILED_TO_INITIALIZE_VOIDHASH_CLIENT", - Cause.squash(initializedClientResult.cause) + await this.runEffect( + initializedClient.captureAutomaticStartupEvents(), + "FAILED_TO_CAPTURE_STARTUP_EVENTS" + ).catch((error) => { + // biome-ignore lint/suspicious/noConsole: This warning is intentionally surfaced in all environments. + console.warn("[voidhash] failed to capture automatic startup analytics", error); + }); + + this.appLifecycleSubscription = this.effectRuntime.runSync( + initializedClient.setupAutomaticLifecycleEvents((eventName) => { + this.capture(eventName); + }) ); + + this.triggerBackgroundFlush("flush analytics after init"); }); } @@ -198,18 +214,11 @@ export class VoidhashClient { async end() { await this.runSideEffect("end", async () => { this.ensureInitialized(); - const endResult = await this.effectRuntime.runPromiseExit( - // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null - this.initializedClient!.end() - ); - - if (Exit.isSuccess(endResult)) { - this._isInitialized = false; - return; - } - - // TODO: Handle different erros that can happen properly - throw new VoidhashError("FAILED_TO_END_VOIDHASH_CLIENT"); + await this.flush(); + await this.runEffect(this.initializedClient!.end(), "FAILED_TO_END_VOIDHASH_CLIENT"); + this.appLifecycleSubscription?.remove(); + this.appLifecycleSubscription = null; + this._isInitialized = false; }); } @@ -226,18 +235,7 @@ export class VoidhashClient { */ async getCurrentCustomer(forceFetch = false) { this.ensureInitialized(); - - const currentCustomerResult = await this.effectRuntime.runPromiseExit( - // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null - this.initializedClient!.getCurrentCustomer(forceFetch) - ); - - if (Exit.isSuccess(currentCustomerResult)) { - return currentCustomerResult.value; - } - - // TODO: Handle different erros that can happen properly - throw new VoidhashError("FAILED_TO_GET_CURRENT_CUSTOMER"); + return this.runEffect(this.initializedClient!.getCurrentCustomer(forceFetch), "FAILED_TO_GET_CURRENT_CUSTOMER"); } /** @@ -253,17 +251,7 @@ export class VoidhashClient { ) { await this.runSideEffect("identify", async () => { this.ensureInitialized(); - const identifyResult = await this.effectRuntime.runPromiseExit( - // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null - this.initializedClient!.identify(appUserId, options) - ); - - if (Exit.isSuccess(identifyResult)) { - return; - } - - // TODO: Handle different erros that can happen properly - throw new VoidhashError("FAILED_TO_IDENTIFY"); + await this.runEffect(this.initializedClient!.identify(appUserId, options), "FAILED_TO_IDENTIFY"); }); } @@ -273,17 +261,7 @@ export class VoidhashClient { async signOut() { await this.runSideEffect("signOut", async () => { this.ensureInitialized(); - const signOutResult = await this.effectRuntime.runPromiseExit( - // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null - this.initializedClient!.signOut() - ); - - if (Exit.isSuccess(signOutResult)) { - return; - } - - // TODO: Handle different erros that can happen properly - throw new VoidhashError("FAILED_TO_SIGN_OUT"); + await this.runEffect(this.initializedClient!.signOut(), "FAILED_TO_SIGN_OUT"); }); } @@ -294,16 +272,7 @@ export class VoidhashClient { */ async getFeatureFlags(flagKeys?: string[]) { this.ensureInitialized(); - const result = await this.effectRuntime.runPromiseExit( - // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null - this.initializedClient!.getFeatureFlags(flagKeys) - ); - - if (Exit.isSuccess(result)) { - return result.value; - } - - throw new VoidhashError("FAILED_TO_GET_FEATURE_FLAGS"); + return this.runEffect(this.initializedClient!.getFeatureFlags(flagKeys), "FAILED_TO_GET_FEATURE_FLAGS"); } /** @@ -313,16 +282,7 @@ export class VoidhashClient { locationSlug: InferGetPaywallLocationInput ) { this.ensureInitialized(); - const result = await this.effectRuntime.runPromiseExit( - // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null - this.initializedClient!.getPaywallForLocation(locationSlug) - ); - - if (Exit.isSuccess(result)) { - return result.value; - } - - throw new VoidhashError("FAILED_TO_GET_PAYWALL_FOR_LOCATION"); + return this.runEffect(this.initializedClient!.getPaywallForLocation(locationSlug), "FAILED_TO_GET_PAYWALL_FOR_LOCATION"); } /** @@ -333,17 +293,7 @@ export class VoidhashClient { */ async getProducts() { this.ensureInitialized(); - const getProductsResult = await this.effectRuntime.runPromiseExit( - // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null - this.initializedClient!.getProducts() - ); - - if (Exit.isSuccess(getProductsResult)) { - return getProductsResult.value; - } - - // TODO: Handle different erros that can happen properly - throw new VoidhashError("FAILED_TO_GET_PRODUCTS"); + return this.runEffect(this.initializedClient!.getProducts(), "FAILED_TO_GET_PRODUCTS"); } /** @@ -367,17 +317,7 @@ export class VoidhashClient { throw new ReadOnlyModePurchaseNotAllowedError(); } - const purchaseResult = await this.effectRuntime.runPromiseExit( - // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null - this.initializedClient!.purchase(product, _options) - ); - - if (Exit.isSuccess(purchaseResult)) { - return; - } - - // TODO: Handle different erros that can happen properly - throw new VoidhashError("FAILED_TO_PURCHASE"); + await this.runEffect(this.initializedClient!.purchase(product, _options), "FAILED_TO_PURCHASE"); } /** @@ -386,16 +326,48 @@ export class VoidhashClient { async restorePurchases() { await this.runSideEffect("restorePurchases", async () => { this.ensureInitialized(); - const restoreResult = await this.effectRuntime.runPromiseExit( - // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null - this.initializedClient!.restorePurchases() - ); + await this.runEffect(this.initializedClient!.restorePurchases(), "FAILED_TO_RESTORE_PURCHASES"); + }); + } + + /** + * Captures a product analytics event. + * Events are batched and delivered on size/time thresholds. + */ + capture(eventName: string, properties: Record = {}) { + if (!this.initializedClient) { + const normalized = eventName.trim(); + if (normalized) { + this.preInitAnalyticsBuffer.push({ eventName: normalized, properties }); + } + return; + } + + this.effectRuntime.runSync( + this.initializedClient.capture(eventName, properties) + ); + } - if (Exit.isSuccess(restoreResult)) { + /** + * Flushes queued analytics events. + */ + async flush() { + await this.runSideEffect("flush", async () => { + if (this.analyticsFlushInFlight) { + await this.analyticsFlushInFlight; return; } - throw new VoidhashError("FAILED_TO_RESTORE_PURCHASES"); + if (!this.initializedClient) return; + + this.analyticsFlushInFlight = this.runEffect( + this.initializedClient.flush(), + "FAILED_TO_FLUSH_ANALYTICS" + ).finally(() => { + this.analyticsFlushInFlight = null; + }); + + await this.analyticsFlushInFlight; }); } @@ -411,18 +383,7 @@ export class VoidhashClient { async iosPresentCodeRedemptionSheet() { await this.runSideEffect("iosPresentCodeRedemptionSheet", async () => { this.ensureInitialized(); - const presentCodeRedemptionSheetResult = - await this.effectRuntime.runPromiseExit( - // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null - this.initializedClient!.iosPresentCodeRedemptionSheet() - ); - - if (Exit.isSuccess(presentCodeRedemptionSheetResult)) { - return; - } - - // TODO: Handle different erros that can happen properly - throw new VoidhashError("FAILED_TO_PRESENT_CODE_REDEMPTION_SHEET"); + await this.runEffect(this.initializedClient!.iosPresentCodeRedemptionSheet(), "FAILED_TO_PRESENT_CODE_REDEMPTION_SHEET"); }); } @@ -434,18 +395,7 @@ export class VoidhashClient { async iosShowManageSubscriptions() { await this.runSideEffect("iosShowManageSubscriptions", async () => { this.ensureInitialized(); - const showManageSubscriptionsResult = - await this.effectRuntime.runPromiseExit( - // biome-ignore lint/style/noNonNullAssertion: ensureInitialized ensures that this.initializedClient is not null - this.initializedClient!.iosShowManageSubscriptions() - ); - - if (Exit.isSuccess(showManageSubscriptionsResult)) { - return; - } - - // TODO: Handle different erros that can happen properly - throw new VoidhashError("FAILED_TO_SHOW_MANAGE_SUBSCRIPTIONS"); + await this.runEffect(this.initializedClient!.iosShowManageSubscriptions(), "FAILED_TO_SHOW_MANAGE_SUBSCRIPTIONS"); }); } @@ -469,6 +419,20 @@ export class VoidhashClient { return `${this.scheme}://voidhash/callback/error`; } + private triggerBackgroundFlush(operation: string) { + void this.flush().catch((error) => { + // biome-ignore lint/suspicious/noConsole: This warning is intentionally surfaced in all environments. + console.warn(`[voidhash] failed to ${operation}`, error); + }); + } + + // biome-ignore lint/suspicious/noExplicitAny: Effect requires service type parameter + private async runEffect(effect: Effect.Effect, errorCode: string): Promise { + const result = await this.effectRuntime.runPromiseExit(effect); + if (Exit.isSuccess(result)) return result.value; + throw toErrorWithMessage(errorCode, Cause.squash(result.cause)); + } + private ensureInitialized() { if (!this.initializedClient) { throw new VoidhashError( diff --git a/libraries/react-native/src/core/platform/platform-provider.ts b/libraries/react-native/src/core/platform/platform-provider.ts index 087e64647..02764f139 100644 --- a/libraries/react-native/src/core/platform/platform-provider.ts +++ b/libraries/react-native/src/core/platform/platform-provider.ts @@ -1,6 +1,8 @@ import { Context } from "effect"; export interface PlatformInfo { + appBuild: string | undefined; + appName: string | undefined; bundleId: string | null; locales: { languageTag: string }[]; systemVersion: string; diff --git a/libraries/react-native/src/core/platform/react-native-platform-provider.ts b/libraries/react-native/src/core/platform/react-native-platform-provider.ts index c7514a25e..5cde4fbb4 100644 --- a/libraries/react-native/src/core/platform/react-native-platform-provider.ts +++ b/libraries/react-native/src/core/platform/react-native-platform-provider.ts @@ -38,6 +38,24 @@ function getAppVersion(): string | undefined { return Constants.expoConfig?.version; } +function getAppBuild(): string | undefined { + const iosBuild = Constants.expoConfig?.ios?.buildNumber; + if (iosBuild) { + return iosBuild; + } + + const androidBuildNumber = Constants.expoConfig?.android?.versionCode; + if (typeof androidBuildNumber === "number") { + return String(androidBuildNumber); + } + + return undefined; +} + +function getAppName(): string | undefined { + return Constants.expoConfig?.name; +} + function isDebugBuild(): boolean { try { // biome-ignore lint/correctness/noUndeclaredVariables: __DEV__ is defined by Expo @@ -60,6 +78,8 @@ function getPlatform(): "ios" | "android" | "unknown" { } export const ReactNativePlatformProvider = Layer.succeed(PlatformProvider, { + appBuild: getAppBuild(), + appName: getAppName(), appVersion: getAppVersion(), bundleId: getBundleId(), deviceBrand: getDeviceBrand(), diff --git a/libraries/react-native/src/core/schema/types.ts b/libraries/react-native/src/core/schema/types.ts index a1f046b33..62eb0adc7 100644 --- a/libraries/react-native/src/core/schema/types.ts +++ b/libraries/react-native/src/core/schema/types.ts @@ -227,3 +227,8 @@ export type ExtractSchemaPaywallLocationSlugs< ? TSchema[K]["slug"] : never; }[ExtractSchemaPaywallLocationKeys]; + +export type InferGetPaywallLocationInput = + [ExtractSchemaPaywallLocationSlugs] extends [never] + ? string + : ExtractSchemaPaywallLocationSlugs; diff --git a/libraries/react-native/src/core/sdk-configuration.ts b/libraries/react-native/src/core/sdk-configuration.ts index bbd3b21e7..dd33a4eaf 100644 --- a/libraries/react-native/src/core/sdk-configuration.ts +++ b/libraries/react-native/src/core/sdk-configuration.ts @@ -7,6 +7,7 @@ export class SdkConfiguration extends Context.Tag( { readonly baseUrl: string; readonly debug: boolean; + readonly ingestUrl: string | undefined; readonly publishableKey: string; readonly readOnly: boolean; } diff --git a/libraries/react-native/src/core/testing/test-platform-provider.ts b/libraries/react-native/src/core/testing/test-platform-provider.ts index 36a8ccf91..93d243c38 100644 --- a/libraries/react-native/src/core/testing/test-platform-provider.ts +++ b/libraries/react-native/src/core/testing/test-platform-provider.ts @@ -6,6 +6,8 @@ import { } from "../platform/platform-provider"; const defaultTestPlatformInfo: PlatformInfo = { + appBuild: "100", + appName: "Voidhash Test", appVersion: "1.0.0", bundleId: "com.voidhash.test", deviceBrand: "Test Brand", From c87c4e774330fba168c15f1e5dd428a07a08399a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Mon, 2 Mar 2026 23:07:20 +0100 Subject: [PATCH 003/129] =?UTF-8?q?chore:=20migrate=20Effect=20v3=20?= =?UTF-8?q?=E2=86=92=20v4=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete migration of the monorepo from Effect v3 (effect@^3.19.14) to v4 beta (effect@4.0.0-beta.23). All 4 packages pass typecheck with zero errors. Major changes: - Updated root catalog versions: effect@4.0.0-beta.23, @effect/platform-node@4.0.0-beta.23, @effect/platform-bun@4.0.0-beta.23, @effect/vitest@4.0.0-beta.23 - Added pnpm.overrides to force effect@4.0.0-beta.23 across all workspace packages - HTTP modules moved to effect/unstable/httpapi and effect/unstable/http - CLI modules moved to effect/unstable/cli - API renames: Effect.catchAll→Effect.catch, Effect.dieMessage→Effect.die, Context.Tag→ServiceMap.Service, Schema.TaggedError→Schema.TaggedErrorClass, Effect.fork→Effect.forkChild, Schema.decodeUnknown→Schema.decodeUnknownEffect, ParseError→SchemaError, Effect.either→Effect.exit, BadArgument/SystemError→PlatformError - HttpApiEndpoint complete API rewrite: tagged template literals → function calls with options - Layer.scoped→Layer.effect, Effect.orElse→Effect.catch, PubSub.publish→PubSub.publish(pubsub, value) - Fixed Cause API: Cause.failures→cause.reasons, Cause.isEmpty→check reasons.length, Cause.pretty(cause, opts)→Cause.pretty(cause) - Effect.catch handlers must return Effect.fail(error) not raw errors - Fixed type utilities: Effect.Effect.Success→Effect.Success - Rebased onto origin/preview and resolved conflicts with new analytics features Co-authored-by: Claude Haiku 4.5 --- apps/cli/package.json | 8 +- apps/cli/src/cli/commands/auth-login.ts | 2 +- apps/cli/src/cli/commands/auth-logout.ts | 2 +- apps/cli/src/cli/commands/auth-status.ts | 2 +- apps/cli/src/cli/commands/auth.ts | 2 +- apps/cli/src/cli/commands/config-reset.ts | 9 +- apps/cli/src/cli/commands/config-set.ts | 13 +- apps/cli/src/cli/commands/config.ts | 2 +- apps/cli/src/cli/commands/init.ts | 5 +- apps/cli/src/cli/commands/schema-check.ts | 2 +- apps/cli/src/cli/commands/schema-pull.ts | 28 +- apps/cli/src/cli/commands/schema-push.ts | 16 +- apps/cli/src/cli/commands/schema.ts | 2 +- apps/cli/src/cli/index.ts | 15 +- apps/cli/src/cli/shared-options.ts | 10 +- .../src/domain/schema/normalized-schema.ts | 4 +- apps/cli/src/domain/schema/package-json.ts | 12 +- apps/cli/src/domain/services/auth.ts | 324 +- apps/cli/src/domain/services/cli-config.ts | 197 +- apps/cli/src/domain/services/codegen.ts | 71 +- apps/cli/src/domain/services/schema.ts | 436 +-- apps/cli/src/domain/services/source-code.ts | 613 ++-- apps/cli/src/services/auth/get-session.ts | 2 +- apps/cli/src/services/auth/index.ts | 22 +- .../src/services/auth/utils/better-auth.ts | 74 +- apps/cli/src/services/cli-config/index.ts | 22 +- .../organization/create-organization.ts | 2 +- apps/cli/src/services/organization/errors.ts | 2 +- apps/cli/src/services/organization/index.ts | 22 +- .../organization/list-organizations.ts | 2 +- apps/cli/src/services/project/index.ts | 22 +- apps/cli/src/services/repository/index.ts | 22 +- apps/cli/src/utils/api-client.ts | 79 +- apps/cli/src/utils/error-formatter.ts | 63 +- apps/cli/src/utils/fs.ts | 5 +- .../src/utils/js-loading/js-file-loading.ts | 2 +- .../organizations/create-organization.ts | 2 +- .../organizations/select-organization.ts | 4 +- apps/cli/src/utils/projects/create-project.ts | 4 +- apps/cli/src/utils/projects/select-project.ts | 4 +- .../src/utils/schema/local-schema-loader.ts | 3 +- apps/cli/src/utils/source-code-details.ts | 7 +- libraries/react-native/package.json | 1 - libraries/react-native/src/client-effect.ts | 8 +- libraries/react-native/src/client.tsx | 2 +- .../src/core/caching/cache-adapter.ts | 9 +- .../src/core/caching/cache-manager.ts | 167 +- libraries/react-native/src/core/event-bus.ts | 6 +- .../identity/customer-attribute-manager.ts | 108 +- .../core/identity/customer-info-manager.ts | 132 +- .../src/core/identity/identity-manager.ts | 192 +- .../src/core/networking/api-client.ts | 32 +- .../src/core/networking/http-debug-client.ts | 2 +- .../core/payment-adapters/payment-adapter.ts | 9 +- .../src/core/platform/platform-provider.ts | 6 +- .../src/core/sdk-configuration.ts | 11 +- package.json | 20 +- packages/api-spec/package.json | 1 - packages/api-spec/src/api.ts | 400 ++- packages/api-spec/src/auth.ts | 13 +- packages/api-spec/src/changeset.ts | 10 +- packages/api-spec/src/errors/admin.ts | 5 +- packages/api-spec/src/errors/analytics.ts | 13 +- packages/api-spec/src/errors/api-key.ts | 9 +- packages/api-spec/src/errors/billing.ts | 13 +- packages/api-spec/src/errors/changeset.ts | 5 +- packages/api-spec/src/errors/common.ts | 13 +- packages/api-spec/src/errors/customer.ts | 13 +- packages/api-spec/src/errors/organization.ts | 9 +- .../api-spec/src/errors/payment-provider.ts | 33 +- .../api-spec/src/errors/paywall-location.ts | 5 +- packages/api-spec/src/errors/paywall.ts | 17 +- packages/api-spec/src/errors/perk.ts | 13 +- packages/api-spec/src/errors/product-perk.ts | 9 +- packages/api-spec/src/errors/product.ts | 13 +- packages/api-spec/src/errors/project.ts | 9 +- packages/api-spec/src/errors/sdk.ts | 17 +- packages/api-spec/src/errors/user.ts | 5 +- packages/api-spec/src/errors/webhook.ts | 17 +- packages/api-spec/src/middlewares.ts | 7 +- packages/api-spec/src/schema.ts | 66 +- packages/shared/src/auth.ts | 13 +- packages/shared/src/deploy-changeset.ts | 10 +- pnpm-lock.yaml | 2837 ++++++++++++----- 84 files changed, 3829 insertions(+), 2591 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 7ee66f133..3cee200de 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -32,19 +32,17 @@ "test:watch": "vitest -c vitest.unit.mts" }, "dependencies": { - "@effect/cli": "catalog:", - "@effect/platform": "catalog:", - "@effect/platform-node": "catalog:", + "@effect/platform-node": "4.0.0-beta.23", "@voidhash/api-spec": "workspace:*", "@voidhash/shared": "workspace:*", "better-auth": "catalog:", - "effect": "catalog:", + "effect": "4.0.0-beta.23", "esbuild-register": "^3.6.0", "nanoid": "^5.1.5" }, "devDependencies": { "@effect/language-service": "catalog:", - "@effect/vitest": "catalog:", + "@effect/vitest": "4.0.0-beta.23", "@voidhash/react-native": "workspace:*", "@voidhash/tsconfig": "workspace:*", "dotenv-cli": "^10.0.0", diff --git a/apps/cli/src/cli/commands/auth-login.ts b/apps/cli/src/cli/commands/auth-login.ts index 3fdba9b9d..abbbce10d 100644 --- a/apps/cli/src/cli/commands/auth-login.ts +++ b/apps/cli/src/cli/commands/auth-login.ts @@ -1,4 +1,4 @@ -import { Command, Prompt } from "@effect/cli"; +import { Command, Prompt } from "effect/unstable/cli"; import { Console, Effect } from "effect"; import { Auth } from "../../domain/services/auth"; diff --git a/apps/cli/src/cli/commands/auth-logout.ts b/apps/cli/src/cli/commands/auth-logout.ts index fa2d93ba2..61f2eda2f 100644 --- a/apps/cli/src/cli/commands/auth-logout.ts +++ b/apps/cli/src/cli/commands/auth-logout.ts @@ -1,4 +1,4 @@ -import { Command, Prompt } from "@effect/cli"; +import { Command, Prompt } from "effect/unstable/cli"; import { Console, Effect } from "effect"; import { Auth } from "../../domain/services/auth"; diff --git a/apps/cli/src/cli/commands/auth-status.ts b/apps/cli/src/cli/commands/auth-status.ts index 60d0254a4..1d582ea11 100644 --- a/apps/cli/src/cli/commands/auth-status.ts +++ b/apps/cli/src/cli/commands/auth-status.ts @@ -1,4 +1,4 @@ -import { Command } from "@effect/cli"; +import { Command } from "effect/unstable/cli"; import { Console, Effect } from "effect"; import { Auth } from "../../domain/services/auth"; diff --git a/apps/cli/src/cli/commands/auth.ts b/apps/cli/src/cli/commands/auth.ts index 58c28cadd..ed7c688f8 100644 --- a/apps/cli/src/cli/commands/auth.ts +++ b/apps/cli/src/cli/commands/auth.ts @@ -1,4 +1,4 @@ -import { Command } from "@effect/cli"; +import { Command } from "effect/unstable/cli"; import { Effect } from "effect"; import { debugOption } from "../shared-options"; diff --git a/apps/cli/src/cli/commands/config-reset.ts b/apps/cli/src/cli/commands/config-reset.ts index b90037101..aeab15137 100644 --- a/apps/cli/src/cli/commands/config-reset.ts +++ b/apps/cli/src/cli/commands/config-reset.ts @@ -1,7 +1,8 @@ -import { Command, HelpDoc, ValidationError } from "@effect/cli"; +import { Command } from "effect/unstable/cli"; import { Console, Effect } from "effect"; import { CliConfig } from "../../domain/services/cli-config"; +import { userError } from "../../utils/error-formatter"; import { debugOption } from "../shared-options"; export const configResetCommand = Command.make("reset", { debug: debugOption }, () => @@ -11,11 +12,9 @@ export const configResetCommand = Command.make("reset", { debug: debugOption }, yield* cliConfig .resetConfig() .pipe( - Effect.catchTag("ParseError", () => + Effect.catchTag("SchemaError", () => Effect.fail( - ValidationError.invalidValue( - HelpDoc.p("Failed to set configuration") - ) + userError("Failed to set configuration") ) ) ); diff --git a/apps/cli/src/cli/commands/config-set.ts b/apps/cli/src/cli/commands/config-set.ts index 9d0d80a2c..f4a133792 100644 --- a/apps/cli/src/cli/commands/config-set.ts +++ b/apps/cli/src/cli/commands/config-set.ts @@ -1,11 +1,12 @@ -import { Args, Command, HelpDoc, ValidationError } from "@effect/cli"; +import { Argument, Command } from "effect/unstable/cli"; import { Console, Effect } from "effect"; import { CliConfig } from "../../domain/services/cli-config"; +import { userError } from "../../utils/error-formatter"; import { debugOption } from "../shared-options"; -const keyArg = Args.text({ name: "key" }); -const valueArg = Args.text({ name: "value" }); +const keyArg = Argument.string("key"); +const valueArg = Argument.string("value"); export const configSetCommand = Command.make( "set", @@ -20,11 +21,9 @@ export const configSetCommand = Command.make( [key]: value, }) .pipe( - Effect.catchTag("ParseError", () => + Effect.catchTag("SchemaError", () => Effect.fail( - ValidationError.invalidValue( - HelpDoc.p("Failed to set configuration") - ) + userError("Failed to set configuration") ) ) ); diff --git a/apps/cli/src/cli/commands/config.ts b/apps/cli/src/cli/commands/config.ts index cb118017c..7fcec09fc 100644 --- a/apps/cli/src/cli/commands/config.ts +++ b/apps/cli/src/cli/commands/config.ts @@ -1,4 +1,4 @@ -import { Command } from "@effect/cli"; +import { Command } from "effect/unstable/cli"; import { Console, Effect } from "effect"; import { CliConfig } from "../../domain/services/cli-config"; diff --git a/apps/cli/src/cli/commands/init.ts b/apps/cli/src/cli/commands/init.ts index e0963a3be..acae5624d 100644 --- a/apps/cli/src/cli/commands/init.ts +++ b/apps/cli/src/cli/commands/init.ts @@ -1,6 +1,5 @@ -import { Command, Prompt } from "@effect/cli"; -import { Path } from "@effect/platform"; -import { Console, Effect } from "effect"; +import { Command, Prompt } from "effect/unstable/cli"; +import { Console, Effect, Path } from "effect"; import { createInitialNormalizedSchema } from "../../domain/schema/normalized-schema"; import { Auth } from "../../domain/services/auth"; diff --git a/apps/cli/src/cli/commands/schema-check.ts b/apps/cli/src/cli/commands/schema-check.ts index 7eb7d5b00..717da7683 100644 --- a/apps/cli/src/cli/commands/schema-check.ts +++ b/apps/cli/src/cli/commands/schema-check.ts @@ -1,4 +1,4 @@ -import { Command } from "@effect/cli"; +import { Command } from "effect/unstable/cli"; import { Console, Effect } from "effect"; import { diff --git a/apps/cli/src/cli/commands/schema-pull.ts b/apps/cli/src/cli/commands/schema-pull.ts index 29d2179af..2374afefd 100644 --- a/apps/cli/src/cli/commands/schema-pull.ts +++ b/apps/cli/src/cli/commands/schema-pull.ts @@ -1,20 +1,20 @@ -import { Command, HelpDoc, Options, Prompt, ValidationError } from "@effect/cli"; -import { Path } from "@effect/platform"; -import { Console, Effect } from "effect"; +import { Command, Flag, Prompt } from "effect/unstable/cli"; +import { Console, Effect, Path } from "effect"; import { Auth } from "../../domain/services/auth"; import { Codegen } from "../../domain/services/codegen"; import { SchemaService } from "../../domain/services/schema"; import { SourceCode } from "../../domain/services/source-code"; +import { userError } from "../../utils/error-formatter"; import { debugOption } from "../shared-options"; export const schemaPullCommand = Command.make( "pull", { debug: debugOption, - force: Options.boolean("force").pipe( - Options.withDescription("Skip confirmation prompt"), - Options.withDefault(false) + force: Flag.boolean("force").pipe( + Flag.withDescription("Skip confirmation prompt"), + Flag.withDefault(false) ), }, ({ force }) => @@ -29,10 +29,8 @@ export const schemaPullCommand = Command.make( yield* auth.getSignedInSession.pipe( Effect.catchTag("NoSignedInUserError", () => Effect.fail( - ValidationError.invalidValue( - HelpDoc.p( - "You must be logged in to pull schema. Run 'voidhash auth login' first." - ) + userError( + "You must be logged in to pull schema. Run 'voidhash auth login' first." ) ) ) @@ -42,10 +40,8 @@ export const schemaPullCommand = Command.make( const config = yield* sourceCode.loadVoidhashConfig().pipe( Effect.catchTag("VoidhashConfigNotFoundError", () => Effect.fail( - ValidationError.invalidValue( - HelpDoc.p( - "voidhash.config.ts not found. Run 'voidhash init' to create one." - ) + userError( + "voidhash.config.ts not found. Run 'voidhash init' to create one." ) ) ) @@ -56,9 +52,7 @@ export const schemaPullCommand = Command.make( const remoteSchema = yield* schemaService.fetchRemoteSchema().pipe( Effect.catchTag("RemoteSchemaFetchError", (e) => Effect.fail( - ValidationError.invalidValue( - HelpDoc.p(`Failed to fetch remote schema: ${String(e.cause)}`) - ) + userError(`Failed to fetch remote schema: ${String(e.cause)}`) ) ) ); diff --git a/apps/cli/src/cli/commands/schema-push.ts b/apps/cli/src/cli/commands/schema-push.ts index fa8aab3ae..a77414181 100644 --- a/apps/cli/src/cli/commands/schema-push.ts +++ b/apps/cli/src/cli/commands/schema-push.ts @@ -1,4 +1,4 @@ -import { Command, Options, Prompt } from "@effect/cli"; +import { Command, Flag, Prompt } from "effect/unstable/cli"; import { Console, Effect } from "effect"; import { MissingProviderConfigurationError } from "../../domain/errors/schema"; @@ -12,14 +12,14 @@ export const schemaPushCommand = Command.make( "push", { debug: debugOption, - dryRun: Options.boolean("dry-run").pipe( - Options.withDescription("Preview changes without applying"), - Options.withDefault(false) + dryRun: Flag.boolean("dry-run").pipe( + Flag.withDescription("Preview changes without applying"), + Flag.withDefault(false) ), - yes: Options.boolean("yes").pipe( - Options.withAlias("y"), - Options.withDescription("Auto-approve all changes"), - Options.withDefault(false) + yes: Flag.boolean("yes").pipe( + Flag.withAlias("y"), + Flag.withDescription("Auto-approve all changes"), + Flag.withDefault(false) ), }, ({ dryRun, yes }) => diff --git a/apps/cli/src/cli/commands/schema.ts b/apps/cli/src/cli/commands/schema.ts index 6eb1550c1..b2094019c 100644 --- a/apps/cli/src/cli/commands/schema.ts +++ b/apps/cli/src/cli/commands/schema.ts @@ -1,4 +1,4 @@ -import { Command } from "@effect/cli"; +import { Command } from "effect/unstable/cli"; import { Effect } from "effect"; import { debugOption } from "../shared-options"; diff --git a/apps/cli/src/cli/index.ts b/apps/cli/src/cli/index.ts index 65e7f9993..129ca0390 100644 --- a/apps/cli/src/cli/index.ts +++ b/apps/cli/src/cli/index.ts @@ -1,7 +1,7 @@ -import { Command } from "@effect/cli"; -import { FetchHttpClient } from "@effect/platform"; -import { NodeContext, NodeRuntime } from "@effect/platform-node"; -import { Effect, Layer, Logger, LogLevel } from "effect"; +import { Command } from "effect/unstable/cli"; +import { NodeServices, NodeRuntime } from "@effect/platform-node"; +import { Effect, Layer, References } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; import { Auth } from "../domain/services/auth"; import { CliConfig } from "../domain/services/cli-config"; @@ -29,13 +29,12 @@ const command = Command.make("voidhash").pipe( ); const cli = Command.run(command, { - name: "Voidhash CLI", version: "0.0.1-alpha.1", }); // Apply debug log level if --debug flag is present -const cliEffect = Effect.suspend(() => cli(process.argv)).pipe( - isDebugMode() ? Logger.withMinimumLogLevel(LogLevel.Debug) : (x) => x +const cliEffect = cli.pipe( + isDebugMode() ? Effect.provideService(References.MinimumLogLevel, "Debug") : (x) => x ); const ServicesLayer = Layer.mergeAll( @@ -45,7 +44,7 @@ const ServicesLayer = Layer.mergeAll( SchemaService.Default ); -const PlatformLayer = Layer.mergeAll(NodeContext.layer, FetchHttpClient.layer); +const PlatformLayer = Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer); const MainLayer = ServicesLayer.pipe( Layer.provideMerge(ApiClient.Default), diff --git a/apps/cli/src/cli/shared-options.ts b/apps/cli/src/cli/shared-options.ts index bd17204a3..ba647dd19 100644 --- a/apps/cli/src/cli/shared-options.ts +++ b/apps/cli/src/cli/shared-options.ts @@ -1,12 +1,12 @@ -import { Options } from "@effect/cli"; +import { Flag } from "effect/unstable/cli"; /** * Shared debug option for all commands. * The actual debug behavior is handled by isDebugMode() in error-formatter.ts * which checks process.argv directly. This option just tells the parser to accept it. */ -export const debugOption = Options.boolean("debug").pipe( - Options.withAlias("d"), - Options.withDescription("Enable debug logging with full error traces"), - Options.withDefault(false) +export const debugOption = Flag.boolean("debug").pipe( + Flag.withAlias("d"), + Flag.withDescription("Enable debug logging with full error traces"), + Flag.withDefault(false) ); diff --git a/apps/cli/src/domain/schema/normalized-schema.ts b/apps/cli/src/domain/schema/normalized-schema.ts index 0734b2227..8d8bef20a 100644 --- a/apps/cli/src/domain/schema/normalized-schema.ts +++ b/apps/cli/src/domain/schema/normalized-schema.ts @@ -4,7 +4,7 @@ import { Schema } from "effect"; // Provider IDs // ======================================================== -export const ProviderId = Schema.Literal("appleAppStore", "googlePlay"); +export const ProviderId = Schema.Literals(["appleAppStore", "googlePlay"]); export type ProviderId = typeof ProviderId.Type; // ======================================================== @@ -22,7 +22,7 @@ export type NormalizedPerk = typeof NormalizedPerkSchema.Type; // ======================================================== export const ProductProviderConfigSchema = Schema.Struct({ - configuration: Schema.Record({ key: Schema.String, value: Schema.Unknown }), + configuration: Schema.Record(Schema.String, Schema.Unknown), providerId: ProviderId, }); export type ProductProviderConfig = typeof ProductProviderConfigSchema.Type; diff --git a/apps/cli/src/domain/schema/package-json.ts b/apps/cli/src/domain/schema/package-json.ts index 4bd905199..81fdbd822 100644 --- a/apps/cli/src/domain/schema/package-json.ts +++ b/apps/cli/src/domain/schema/package-json.ts @@ -2,20 +2,20 @@ import { Schema } from "effect"; export const PackageJsonSchema = Schema.Struct({ dependencies: Schema.optional( - Schema.Record({ key: Schema.String, value: Schema.String }) + Schema.Record(Schema.String, Schema.String) ), devDependencies: Schema.optional( - Schema.Record({ key: Schema.String, value: Schema.String }) + Schema.Record(Schema.String, Schema.String) ), name: Schema.optional(Schema.String), peerDependencies: Schema.optional( - Schema.Record({ key: Schema.String, value: Schema.String }) + Schema.Record(Schema.String, Schema.String) ), version: Schema.optional(Schema.String), workspaces: Schema.optional( - Schema.Union( + Schema.Union([ Schema.Array(Schema.String), - Schema.Record({ key: Schema.String, value: Schema.Unknown }) - ) + Schema.Record(Schema.String, Schema.Unknown), + ]) ), }); diff --git a/apps/cli/src/domain/services/auth.ts b/apps/cli/src/domain/services/auth.ts index bdfcb268e..c269046d3 100644 --- a/apps/cli/src/domain/services/auth.ts +++ b/apps/cli/src/domain/services/auth.ts @@ -1,10 +1,17 @@ +import { NodeServices, NodeHttpServer } from "@effect/platform-node"; import { - HttpLayerRouter, + Console, + Data, + Effect, + Layer, + PubSub, + ServiceMap, +} from "effect"; +import { + HttpRouter, HttpServerRequest, HttpServerResponse, -} from "@effect/platform"; -import { NodeContext, NodeHttpServer } from "@effect/platform-node"; -import { Console, Data, Effect, Layer, PubSub, Queue } from "effect"; +} from "effect/unstable/http"; import { customAlphabet } from "nanoid"; import { spawn } from "node:child_process"; import { createServer } from "node:http"; @@ -47,7 +54,7 @@ const runCallbackServer = (callbackEvents: PubSub.PubSub) => // Create the callback route layer const CallbackRoute = Layer.effectDiscard( Effect.gen(function* CallbackRoute() { - const router = yield* HttpLayerRouter.HttpRouter; + const router = yield* HttpRouter.HttpRouter; yield* router.add( "GET", "/callback", @@ -57,8 +64,8 @@ const runCallbackServer = (callbackEvents: PubSub.PubSub) => const { query } = parsedUrl; if (query.cancelled) { - yield* callbackEvents.publish({ type: "cancelled" }); - return yield* HttpServerResponse.text("Login cancelled").pipe( + yield* PubSub.publish(callbackEvents, { type: "cancelled" }); + return HttpServerResponse.text("Login cancelled").pipe( HttpServerResponse.setHeader( "Access-Control-Allow-Origin", "*" @@ -70,12 +77,12 @@ const runCallbackServer = (callbackEvents: PubSub.PubSub) => ); } - yield* callbackEvents.publish({ + yield* PubSub.publish(callbackEvents, { code: query.code as string, key: query.key as string, type: "success", }); - return yield* HttpServerResponse.text("Login successful").pipe( + return HttpServerResponse.text("Login successful").pipe( HttpServerResponse.setHeader("Access-Control-Allow-Origin", "*"), HttpServerResponse.setHeader( "Access-Control-Allow-Methods", @@ -97,173 +104,180 @@ const runCallbackServer = (callbackEvents: PubSub.PubSub) => }); // Launch the server with the callback route - return yield* HttpLayerRouter.serve(CallbackRoute).pipe( - Layer.provide(Layer.mergeAll(ServerLive, NodeContext.layer)), + return yield* HttpRouter.serve(CallbackRoute).pipe( + Layer.provide(Layer.mergeAll(ServerLive, NodeServices.layer)), Layer.launch ); }); -export class Auth extends Effect.Service()("voidhash-cli/Auth", { - dependencies: [CliConfig.Default], - effect: Effect.gen(function* effect() { - const client = yield* ApiClient; - const cliConfig = yield* CliConfig; - /** - * Retrieves the currently signed-in user from the local configuration and the BetterAuth service. - * - * Attempts to read the API key from the user's config file. If no API key is found, or if the session - * cannot be retrieved or does not contain a user, a `NoSignedInUserError` is thrown. - * If the session retrieval fails for other reasons, a `FailedToGetSessionError` is thrown. - * - * @returns {Effect.Effect} - * An Effect that yields the signed-in user's information, or fails with an appropriate error. - */ - const getSignedInSession = Effect.gen(function* getSignedInSession() { - yield* Effect.logDebug("Reading CLI config for session check"); - const config = yield* cliConfig - .readConfig() - .pipe( - Effect.catchAll(() => Effect.dieMessage("Failed to read config")) - ); +const make = Effect.gen(function* effect() { + const client = yield* ApiClient; + const cliConfig = yield* CliConfig; + /** + * Retrieves the currently signed-in user from the local configuration and the BetterAuth service. + * + * Attempts to read the API key from the user's config file. If no API key is found, or if the session + * cannot be retrieved or does not contain a user, a `NoSignedInUserError` is thrown. + * If the session retrieval fails for other reasons, a `FailedToGetSessionError` is thrown. + * + * @returns {Effect.Effect} + * An Effect that yields the signed-in user's information, or fails with an appropriate error. + */ + const getSignedInSession = Effect.gen(function* getSignedInSession() { + yield* Effect.logDebug("Reading CLI config for session check"); + const config = (yield* cliConfig + .readConfig() + .pipe( + Effect.catch(() => Effect.die("Failed to read config")) + ))!; - // If the config file is not found or the api key is not set, we consider the user to be signed out - const apiKey = config.api_key; - if (!apiKey) { - yield* Effect.logDebug("No API key found in config"); - return yield* Effect.fail( - new NoSignedInUserError({ message: "No signed in user" }) - ); - } - - yield* Effect.logDebug("Fetching session from API"); - const sessionResponse = yield* client.auth.session().pipe( - Effect.tap((session) => - Effect.logDebug(`Session retrieved for user: ${session.name}`) - ), - Effect.catchTags({ - NotAuthenticatedError: () => - new NoSignedInUserError({ message: "No signed in user" }), - }) + // If the config file is not found or the api key is not set, we consider the user to be signed out + const apiKey = config.api_key; + if (!apiKey) { + yield* Effect.logDebug("No API key found in config"); + return yield* Effect.fail( + new NoSignedInUserError({ message: "No signed in user" }) ); + } - return sessionResponse; - }).pipe( - Effect.withSpan("Auth.getSignedInSession"), - Effect.catchIf( - (e) => e._tag !== "NoSignedInUserError", - (e) => - Effect.fail( - new FailedToGetSessionError({ - cause: e, - message: "Failed to get session", - }) - ) - ) + yield* Effect.logDebug("Fetching session from API"); + const sessionResponse = yield* client.auth.session().pipe( + Effect.tap((session) => + Effect.logDebug(`Session retrieved for user: ${session.name}`) + ), + Effect.catchTags({ + NotAuthenticatedError: () => + Effect.fail(new NoSignedInUserError({ message: "No signed in user" })), + }) ); - const login = Effect.scoped( - Effect.gen(function* login() { - yield* Effect.logDebug("Starting login flow"); - const callbackEventsPubSub = yield* PubSub.unbounded(); - - // Launch the callback server in a separate fiber to avoid blocking - yield* Effect.logDebug( - `Starting callback server on ${host}:${port}` - ); - yield* Effect.fork( - Effect.catchAll(runCallbackServer(callbackEventsPubSub), (error) => { - // biome-ignore lint/suspicious/noConsole: Error logging - console.log(error); - return Effect.die(error); + return sessionResponse; + }).pipe( + Effect.withSpan("Auth.getSignedInSession"), + Effect.catchIf( + (e) => e._tag !== "NoSignedInUserError", + (e) => + Effect.fail( + new FailedToGetSessionError({ + cause: e, + message: "Failed to get session", }) - ); + ) + ) + ); - // Set up the application server with routing - const redirect = `http://${host}:${port}/callback`; + const login = Effect.scoped( + Effect.gen(function* login() { + yield* Effect.logDebug("Starting login flow"); + const callbackEventsPubSub = yield* PubSub.unbounded(); - const code = nanoid(); - const config = yield* cliConfig - .readConfig() - .pipe( - Effect.catchAll(() => Effect.dieMessage("Failed to read config")) - ); - const confirmationUrl = new URL(`${config.web_url}/auth/devices`); - confirmationUrl.searchParams.append("code", code); - confirmationUrl.searchParams.append("redirect", redirect); + // Launch the callback server in a separate fiber to avoid blocking + yield* Effect.logDebug( + `Starting callback server on ${host}:${port}` + ); + yield* Effect.forkChild( + Effect.catch(runCallbackServer(callbackEventsPubSub), (error) => { + // biome-ignore lint/suspicious/noConsole: Error logging + console.log(error); + return Effect.die(error); + }) + ); - yield* Effect.logDebug( - `Opening browser for authentication: ${confirmationUrl.toString()}` - ); - yield* Console.log(`Confirmation code: ${code}\n`); - yield* Console.log( - `If something goes wrong, copy and paste this URL into your browser: ${confirmationUrl.toString()}\n` - ); - spawn("open", [confirmationUrl.toString()]); + // Set up the application server with routing + const redirect = `http://${host}:${port}/callback`; - // Wait for the callback event - yield* Effect.logDebug("Waiting for callback from browser"); - const callbacksQueue = yield* PubSub.subscribe(callbackEventsPubSub); - const callbackEvent = yield* Queue.take(callbacksQueue); + const code = nanoid(); + const config = (yield* cliConfig + .readConfig() + .pipe( + Effect.catch(() => Effect.die("Failed to read config")) + ))!; + const confirmationUrl = new URL(`${config.web_url}/auth/devices`); + confirmationUrl.searchParams.append("code", code); + confirmationUrl.searchParams.append("redirect", redirect); - if (callbackEvent.type === "cancelled") { - yield* Effect.logDebug("Login cancelled by user"); - return yield* Effect.fail( - new LoginCancelledError({ message: "Login cancelled" }) - ); - } + yield* Effect.logDebug( + `Opening browser for authentication: ${confirmationUrl.toString()}` + ); + yield* Console.log(`Confirmation code: ${code}\n`); + yield* Console.log( + `If something goes wrong, copy and paste this URL into your browser: ${confirmationUrl.toString()}\n` + ); + spawn("open", [confirmationUrl.toString()]); - // Store in config - yield* Effect.logDebug("Storing API key in config"); - yield* cliConfig.writeToConfig({ api_key: callbackEvent.key }); + // Wait for the callback event + yield* Effect.logDebug("Waiting for callback from browser"); + const subscription = yield* PubSub.subscribe(callbackEventsPubSub); + const callbackEvent = yield* PubSub.take(subscription); - yield* Console.log( - `Authentication successful! Your key has been stored in your config file. To view it, type 'cat ~/${CONFIG_FILE_NAME}'.\n);` + if (callbackEvent.type === "cancelled") { + yield* Effect.logDebug("Login cancelled by user"); + return yield* Effect.fail( + new LoginCancelledError({ message: "Login cancelled" }) ); - }) - ).pipe( - Effect.withSpan("Auth.login"), - Effect.catchIf( - (e) => e._tag !== "LoginCancelledError", - (e) => - Effect.fail( - new FailedToLoginError({ cause: e, message: "Failed to login" }) - ) - ) - ); - - /** - * Logs out the current user - * - * @returns An Effect that logs out the current user, or fails with a FailedToLogoutError if the logout fails. - */ - const logout = Effect.gen(function* logout() { - yield* Effect.logDebug("Starting logout"); - const config = yield* cliConfig.readConfig(); - if (!config.api_key) { - yield* Effect.logDebug("No API key found, user not logged in"); - yield* Console.log("You are not logged in."); - return; } - yield* Effect.logDebug("Clearing API key from config"); - yield* cliConfig.writeToConfig({ api_key: null }); - yield* Console.log("You have been logged out."); - }).pipe( - Effect.withSpan("Auth.logout"), - Effect.catchAll((e) => + // Store in config + yield* Effect.logDebug("Storing API key in config"); + yield* cliConfig.writeToConfig({ api_key: callbackEvent.key }); + + yield* Console.log( + `Authentication successful! Your key has been stored in your config file. To view it, type 'cat ~/${CONFIG_FILE_NAME}'.\n);` + ); + }) + ).pipe( + Effect.withSpan("Auth.login"), + Effect.catchIf( + (e) => e._tag !== "LoginCancelledError", + (e) => Effect.fail( - new FailedToLogoutError({ - cause: e, - message: "Failed to logout", - }) + new FailedToLoginError({ cause: e, message: "Failed to login" }) ) + ) + ); + + /** + * Logs out the current user + * + * @returns An Effect that logs out the current user, or fails with a FailedToLogoutError if the logout fails. + */ + const logout = Effect.gen(function* logout() { + yield* Effect.logDebug("Starting logout"); + const config = yield* cliConfig.readConfig(); + if (!config.api_key) { + yield* Effect.logDebug("No API key found, user not logged in"); + yield* Console.log("You are not logged in."); + return; + } + + yield* Effect.logDebug("Clearing API key from config"); + yield* cliConfig.writeToConfig({ api_key: null }); + yield* Console.log("You have been logged out."); + }).pipe( + Effect.withSpan("Auth.logout"), + Effect.catch((e) => + Effect.fail( + new FailedToLogoutError({ + cause: e, + message: "Failed to logout", + }) ) - ); + ) + ); + + return { + getSignedInSession, + login, + logout, + } as const; +}); - return { - getSignedInSession, - login, - logout, - } as const; - }), -}) {} +type AuthShape = Effect.Success; + +export class Auth extends ServiceMap.Service()( + "voidhash-cli/Auth" +) { + static Default = Layer.effect(Auth, make).pipe( + Layer.provide(CliConfig.Default) + ) +} diff --git a/apps/cli/src/domain/services/cli-config.ts b/apps/cli/src/domain/services/cli-config.ts index 7e0db8897..92fc3799b 100644 --- a/apps/cli/src/domain/services/cli-config.ts +++ b/apps/cli/src/domain/services/cli-config.ts @@ -1,5 +1,4 @@ -import { FileSystem, Path } from "@effect/platform"; -import { Effect, Schema } from "effect"; +import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"; import os from "node:os"; import { @@ -16,109 +15,103 @@ export const emptyConfig = { web_url: DEFAULT_WEB_URL, } satisfies typeof CliConfigSchema.Type; -export class CliConfig extends Effect.Service()( - "voidhash-cli/CliConfig", - { - dependencies: [], - // Define how to create the service - effect: Effect.gen(function* effect() { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; +const make = Effect.gen(function* effect() { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; - const homeDir = os.homedir(); - const filePath = path.join(homeDir, CONFIG_FILE_NAME); + const homeDir = os.homedir(); + const filePath = path.join(homeDir, CONFIG_FILE_NAME); - /** - * Reads and decodes the user's configuration file from the home directory. - * - * @returns An Effect that yields the parsed configuration object, or fails with a ConfigFileNotFoundError if the config file does not exist, or a Schema.DecodeError if the file contents are invalid. - */ - const readConfig = () => - Effect.gen(function* readConfig() { - yield* Effect.logDebug(`Reading config from ${filePath}`); - if (!fileSystem.exists(filePath)) { - yield* Effect.logDebug("Config file not found, using defaults"); - return yield* Effect.succeed(emptyConfig); - } - const configString = yield* fileSystem.readFileString(filePath); - const configJson = JSON.parse(configString); - yield* Effect.logDebug("Config file loaded successfully"); - return yield* Schema.decodeUnknown(CliConfigSchema)({ - ...emptyConfig, - ...configJson, - }); - }).pipe( - Effect.withSpan("CliConfig.readConfig"), - Effect.catchTags({ - BadArgument: (e) => - Effect.fail( - new FailedToReadCliConfigError({ - cause: e, - message: "Failed to read config", - }) - ), - ParseError: (e) => - Effect.fail( - new FailedToReadCliConfigError({ - cause: e, - message: "Failed to read config", - }) - ), - SystemError: (e) => - Effect.fail( - new FailedToReadCliConfigError({ - cause: e, - message: "Failed to read config", - }) - ), - }) - ); + /** + * Reads and decodes the user's configuration file from the home directory. + * + * @returns An Effect that yields the parsed configuration object, or fails with a ConfigFileNotFoundError if the config file does not exist, or a Schema.DecodeError if the file contents are invalid. + */ + const readConfig = () => + Effect.gen(function* readConfig() { + yield* Effect.logDebug(`Reading config from ${filePath}`); + if (!fileSystem.exists(filePath)) { + yield* Effect.logDebug("Config file not found, using defaults"); + return yield* Effect.succeed(emptyConfig); + } + const configString = yield* fileSystem.readFileString(filePath); + const configJson = JSON.parse(configString); + yield* Effect.logDebug("Config file loaded successfully"); + return yield* Schema.decodeUnknownEffect(CliConfigSchema)({ + ...emptyConfig, + ...configJson, + }); + }).pipe( + Effect.withSpan("CliConfig.readConfig"), + Effect.catchTags({ + PlatformError: (e) => + Effect.fail( + new FailedToReadCliConfigError({ + cause: e, + message: "Failed to read config", + }) + ), + SchemaError: (e) => + Effect.fail( + new FailedToReadCliConfigError({ + cause: e, + message: "Failed to read config", + }) + ), + }) + ); - /** - * Writes the provided partial configuration to the user's config file. - * Merges the new config with any existing config, then saves the result. - * - * @param config - Partial configuration object to write to the config file. - * @returns An Effect that writes the merged configuration to disk. - */ - const writeToConfig = (config: Partial) => - Effect.gen(function* writeToConfig() { - yield* Effect.logDebug(`Writing config to ${filePath}`); - const currentConfig = yield* readConfig().pipe( - Effect.orElse(() => Effect.succeed({})) - ); - const mergedConfig = { ...currentConfig, ...config }; - const validatedConfig = - yield* Schema.decodeUnknown(CliConfigSchema)(mergedConfig); - yield* fileSystem.writeFileString( - filePath, - JSON.stringify(validatedConfig) - ); - yield* Effect.logDebug("Config file written successfully"); - }).pipe(Effect.withSpan("CliConfig.writeToConfig")); + /** + * Writes the provided partial configuration to the user's config file. + * Merges the new config with any existing config, then saves the result. + * + * @param config - Partial configuration object to write to the config file. + * @returns An Effect that writes the merged configuration to disk. + */ + const writeToConfig = (config: Partial) => + Effect.gen(function* writeToConfig() { + yield* Effect.logDebug(`Writing config to ${filePath}`); + const currentConfig = yield* readConfig().pipe( + Effect.catch(() => Effect.succeed({})) + ); + const mergedConfig = { ...currentConfig, ...config }; + const validatedConfig = + yield* Schema.decodeUnknownEffect(CliConfigSchema)(mergedConfig); + yield* fileSystem.writeFileString( + filePath, + JSON.stringify(validatedConfig) + ); + yield* Effect.logDebug("Config file written successfully"); + }).pipe(Effect.withSpan("CliConfig.writeToConfig")); - /** - * Resets the configuration to the default values. If authenticated, persists the authentication state. - * - * @returns An Effect that resets the configuration to the default values. - */ - const resetConfig = () => - Effect.gen(function* resetConfig() { - yield* Effect.logDebug("Resetting config to defaults"); - const config = yield* readConfig(); + /** + * Resets the configuration to the default values. If authenticated, persists the authentication state. + * + * @returns An Effect that resets the configuration to the default values. + */ + const resetConfig = () => + Effect.gen(function* resetConfig() { + yield* Effect.logDebug("Resetting config to defaults"); + const config = yield* readConfig(); - yield* writeToConfig({ - ...emptyConfig, - api_key: config.api_key ?? null, - }); - yield* Effect.logDebug("Config reset complete"); - }).pipe(Effect.withSpan("CliConfig.resetConfig")); + yield* writeToConfig({ + ...emptyConfig, + api_key: config.api_key ?? null, + }); + yield* Effect.logDebug("Config reset complete"); + }).pipe(Effect.withSpan("CliConfig.resetConfig")); - return { - readConfig, - resetConfig, - writeToConfig, - } as const; - }), - } -) {} + return { + readConfig, + resetConfig, + writeToConfig, + } as const; +}); + +type CliConfigShape = Effect.Success; + +export class CliConfig extends ServiceMap.Service()( + "voidhash-cli/CliConfig" +) { + static Default = Layer.effect(CliConfig, make) +} diff --git a/apps/cli/src/domain/services/codegen.ts b/apps/cli/src/domain/services/codegen.ts index acf601761..5c052f6f8 100644 --- a/apps/cli/src/domain/services/codegen.ts +++ b/apps/cli/src/domain/services/codegen.ts @@ -1,5 +1,4 @@ -import { FileSystem } from "@effect/platform"; -import { Effect } from "effect"; +import { Effect, FileSystem, Layer, ServiceMap } from "effect"; import type { Writable } from "../../utils/types"; import type { NormalizedSchema, ProviderId } from "../schema/normalized-schema"; @@ -133,18 +132,15 @@ function generateSchemaCode(schema: NormalizedSchema): string { return lines.join("\n"); } -export class Codegen extends Effect.Service()("voidhash-cli/Codegen", { - dependencies: [], - // Define how to create the service - effect: Effect.gen(function* effect() { - const fileSystem = yield* FileSystem.FileSystem; +const make = Effect.gen(function* effect() { + const fileSystem = yield* FileSystem.FileSystem; - const generateVoidhashConfigFile = ( - filePath: string, - config: Writable - ) => - Effect.gen(function* generateVoidhashConfigFile() { - const content = `import { defineConfig } from 'voidhash-cli'; + const generateVoidhashConfigFile = ( + filePath: string, + config: Writable + ) => + Effect.gen(function* generateVoidhashConfigFile() { + const content = `import { defineConfig } from 'voidhash-cli'; export default defineConfig({ team: '${config.team}', @@ -152,12 +148,12 @@ export default defineConfig({ schema: '${config.schema}' }); `; - yield* fileSystem.writeFileString(filePath, content); - }); + yield* fileSystem.writeFileString(filePath, content); + }); - const generateClientFile = (filePath: string, publishableKey: string) => - Effect.gen(function* generateClientFile() { - const content = `import { createVoidhashClient } from "@voidhash/react-native"; + const generateClientFile = (filePath: string, publishableKey: string) => + Effect.gen(function* generateClientFile() { + const content = `import { createVoidhashClient } from "@voidhash/react-native"; import * as schema from "./schema"; export const voidhash = createVoidhashClient( @@ -165,19 +161,26 @@ export const voidhash = createVoidhashClient( schema ); `; - yield* fileSystem.writeFileString(filePath, content); - }); - - const generateSchemaFile = (filePath: string, schema: NormalizedSchema) => - Effect.gen(function* generateSchemaFile() { - const content = generateSchemaCode(schema); - yield* fileSystem.writeFileString(filePath, content); - }); - - return { - generateClientFile, - generateSchemaFile, - generateVoidhashConfigFile, - } as const; - }), -}) {} + yield* fileSystem.writeFileString(filePath, content); + }); + + const generateSchemaFile = (filePath: string, schema: NormalizedSchema) => + Effect.gen(function* generateSchemaFile() { + const content = generateSchemaCode(schema); + yield* fileSystem.writeFileString(filePath, content); + }); + + return { + generateClientFile, + generateSchemaFile, + generateVoidhashConfigFile, + } as const; +}); + +type CodegenShape = Effect.Success; + +export class Codegen extends ServiceMap.Service()( + "voidhash-cli/Codegen" +) { + static Default = Layer.effect(Codegen, make) +} diff --git a/apps/cli/src/domain/services/schema.ts b/apps/cli/src/domain/services/schema.ts index 2b2587412..846f3da56 100644 --- a/apps/cli/src/domain/services/schema.ts +++ b/apps/cli/src/domain/services/schema.ts @@ -1,5 +1,5 @@ import type { ChangesetSchema } from "@voidhash/shared"; -import { Effect, Schedule } from "effect"; +import { Effect, Layer, Schedule, ServiceMap } from "effect"; import { ApiClient } from "../../utils/api-client"; import { @@ -23,221 +23,225 @@ export { formatChange } from "../../utils/schema/changeset-builder"; type Change = (typeof ChangesetSchema.Type)["changes"][number]; -export class SchemaService extends Effect.Service()( - "voidhash-cli/Schema", - { - dependencies: [ApiClient.Default], - effect: Effect.gen(function* effect() { - const apiClient = yield* ApiClient; - - /** - * Fetch the remote schema from the API - */ - const fetchRemoteSchema = () => - Effect.gen(function* fetchRemoteSchema() { - yield* Effect.logDebug("Fetching remote schema from API"); - const schema = createEmptyNormalizedSchema(); - - // 1. Fetch all perks - const remotePerks = yield* apiClient.perks.listPerks(); - for (const perk of remotePerks) { - schema.perks.set(perk.slug, { - name: perk.name, - slug: perk.slug, - }); - } - - // 1b. Fetch all active paywall locations - const remoteLocations = - yield* apiClient.paywall_locations.listPaywallLocations(); - for (const location of remoteLocations) { - schema.locations.set(location.slug, { - description: location.description, - name: location.name, - slug: location.slug, - }); - } - - // 2. Fetch all products - const remoteProducts = yield* apiClient.products.listProducts(); - - // 3. Fetch payment provider configurations - const providerConfigs = - yield* apiClient.payment_provider_configurations.listPaymentProviderConfigurations(); - for (const config of providerConfigs) { - if ( - config.providerId === "appleAppStore" || - config.providerId === "googlePlay" - ) { - schema.enabledProviders.add(config.providerId); - } - } - - // 4. Fetch all payment provider products - const providerProducts = - yield* apiClient.payment_provider_products.listPaymentProviderProducts(); - - // Build a map of productId -> provider products - const productProviderMap = new Map< - string, - { providerId: ProviderId; configuration: Record }[] - >(); - for (const pp of providerProducts) { - if ( - pp.providerId !== "appleAppStore" && - pp.providerId !== "googlePlay" - ) { - continue; +const make = Effect.gen(function* effect() { + const apiClient = yield* ApiClient; + + /** + * Fetch the remote schema from the API + */ + const fetchRemoteSchema = () => + Effect.gen(function* fetchRemoteSchema() { + yield* Effect.logDebug("Fetching remote schema from API"); + const schema = createEmptyNormalizedSchema(); + + // 1. Fetch all perks + const remotePerks = yield* apiClient.perks.listPerks(); + for (const perk of remotePerks) { + schema.perks.set(perk.slug, { + name: perk.name, + slug: perk.slug, + }); + } + + // 1b. Fetch all active paywall locations + const remoteLocations = + yield* apiClient.paywall_locations.listPaywallLocations(); + for (const location of remoteLocations) { + schema.locations.set(location.slug, { + description: location.description, + name: location.name, + slug: location.slug, + }); + } + + // 2. Fetch all products + const remoteProducts = yield* apiClient.products.listProducts(); + + // 3. Fetch payment provider configurations + const providerConfigs = + yield* apiClient.payment_provider_configurations.listPaymentProviderConfigurations(); + for (const config of providerConfigs) { + if ( + config.providerId === "appleAppStore" || + config.providerId === "googlePlay" + ) { + schema.enabledProviders.add(config.providerId); + } + } + + // 4. Fetch all payment provider products + const providerProducts = + yield* apiClient.payment_provider_products.listPaymentProviderProducts(); + + // Build a map of productId -> provider products + const productProviderMap = new Map< + string, + { providerId: ProviderId; configuration: Record }[] + >(); + for (const pp of providerProducts) { + if ( + pp.providerId !== "appleAppStore" && + pp.providerId !== "googlePlay" + ) { + continue; + } + const existing = productProviderMap.get(pp.productId) || []; + existing.push({ + configuration: pp.configuration as Record, + providerId: pp.providerId, + }); + productProviderMap.set(pp.productId, existing); + } + + // 5. For each product, fetch its perks + + yield* Effect.all( + remoteProducts.map((product) => + Effect.gen(function* () { + const productPerks = yield* apiClient.product_perks + .listProductPerksByProductId({ + params: { productId: product.id }, + }) + .pipe( + Effect.retry({ + schedule: Schedule.exponential(1000), + times: 3, + }), + ); + + // Map perkIds to slugs + const perkSlugs: string[] = []; + for (const pp of productPerks) { + const perk = remotePerks.find((p) => p.id === pp.perkId); + if (perk) { + perkSlugs.push(perk.slug); + } } - const existing = productProviderMap.get(pp.productId) || []; - existing.push({ - configuration: pp.configuration as Record, - providerId: pp.providerId, + + schema.products.set(product.slug, { + name: product.name, + perks: perkSlugs, + providers: productProviderMap.get(product.id) || [], + slug: product.slug, + type: "subscription", // TODO: map from product.type }); - productProviderMap.set(pp.productId, existing); - } - - // 5. For each product, fetch its perks - - yield* Effect.all( - remoteProducts.map((product) => - Effect.gen(function* () { - const productPerks = yield* apiClient.product_perks - .listProductPerksByProductId({ - path: { productId: product.id }, - }) - .pipe( - Effect.retry({ - schedule: Schedule.exponential(1000), - times: 3, - }), - ); - - // Map perkIds to slugs - const perkSlugs: string[] = []; - for (const pp of productPerks) { - const perk = remotePerks.find((p) => p.id === pp.perkId); - if (perk) { - perkSlugs.push(perk.slug); - } - } - - schema.products.set(product.slug, { - name: product.name, - perks: perkSlugs, - providers: productProviderMap.get(product.id) || [], - slug: product.slug, - type: "subscription", // TODO: map from product.type - }); - }), - ), - { - concurrency: 8, - }, - ); - - yield* Effect.logDebug( - `Fetched ${schema.locations.size} locations, ${schema.perks.size} perks, ${schema.products.size} products` - ); - return schema; - }).pipe( - Effect.withSpan("SchemaService.fetchRemoteSchema"), - Effect.catchAll( - (e) => - new RemoteSchemaFetchError({ - cause: e, - }), - ), - ); - - /** - * Fetch payment provider configurations - */ - const fetchProviderConfigurations = () => - apiClient.payment_provider_configurations - .listPaymentProviderConfigurations() - .pipe( - Effect.tap((configs) => - Effect.logDebug( - `Fetched ${configs.length} provider configurations` - ) - ), - Effect.withSpan("SchemaService.fetchProviderConfigurations"), - Effect.catchAll( - (e) => - new RemoteSchemaFetchError({ - cause: e, - }), - ), - ); - /** - * Check which providers are missing configurations - */ - const checkProviderConfigurations = ( - localProviders: Set, - remoteConfigs: readonly { providerId: string }[], - ): ProviderId[] => { - const remoteProviderIds = new Set( - remoteConfigs.map((c) => c.providerId), - ); - return [...localProviders].filter( - (p) => !remoteProviderIds.has(p), - ) as ProviderId[]; - }; - - /** - * Deploy a single change to the server - */ - const deployChange = (change: Change) => - Effect.logDebug(`Deploying change: ${formatChange(change)}`).pipe( - Effect.andThen( - apiClient.changesets.deployChangeset({ - payload: { changeset: { changes: [change] } }, - }) - ), - Effect.withSpan("SchemaService.deployChange"), - Effect.catchAll( - (e) => - new ChangeDeploymentError({ - cause: e, - change: formatChange(change), - }) - ) - ); - - /** - * Deploy an entire changeset to the server - */ - const deployChangeset = (changeset: typeof ChangesetSchema.Type) => - Effect.logDebug( - `Deploying changeset with ${changeset.changes.length} changes` - ).pipe( - Effect.andThen( - apiClient.changesets.deployChangeset({ - payload: { changeset }, - }) - ), - Effect.withSpan("SchemaService.deployChangeset"), - Effect.catchAll( - (e) => - new ChangeDeploymentError({ - cause: e, - change: "Full changeset deployment", - }) + }), + ), + { + concurrency: 8, + }, + ); + + yield* Effect.logDebug( + `Fetched ${schema.locations.size} locations, ${schema.perks.size} perks, ${schema.products.size} products` + ); + return schema; + }).pipe( + Effect.withSpan("SchemaService.fetchRemoteSchema"), + Effect.catch( + (e) => + Effect.fail(new RemoteSchemaFetchError({ + cause: e, + })), + ), + ); + + /** + * Fetch payment provider configurations + */ + const fetchProviderConfigurations = () => + apiClient.payment_provider_configurations + .listPaymentProviderConfigurations() + .pipe( + Effect.tap((configs) => + Effect.logDebug( + `Fetched ${configs.length} provider configurations` ) - ); - - return { - buildChangeset, - checkProviderConfigurations, - computeDiff, - deployChange, - deployChangeset, - fetchProviderConfigurations, - fetchRemoteSchema, - loadLocalSchema, - summarizeDiff, - } as const; - }), - }, -) {} + ), + Effect.withSpan("SchemaService.fetchProviderConfigurations"), + Effect.catch( + (e) => + Effect.fail(new RemoteSchemaFetchError({ + cause: e, + })), + ), + ); + /** + * Check which providers are missing configurations + */ + const checkProviderConfigurations = ( + localProviders: Set, + remoteConfigs: readonly { providerId: string }[], + ): ProviderId[] => { + const remoteProviderIds = new Set( + remoteConfigs.map((c) => c.providerId), + ); + return [...localProviders].filter( + (p) => !remoteProviderIds.has(p), + ) as ProviderId[]; + }; + + /** + * Deploy a single change to the server + */ + const deployChange = (change: Change) => + Effect.logDebug(`Deploying change: ${formatChange(change)}`).pipe( + Effect.andThen( + apiClient.changesets.deployChangeset({ + payload: { changeset: { changes: [change] } }, + }) + ), + Effect.withSpan("SchemaService.deployChange"), + Effect.catch( + (e) => + Effect.fail(new ChangeDeploymentError({ + cause: e, + change: formatChange(change), + })) + ) + ); + + /** + * Deploy an entire changeset to the server + */ + const deployChangeset = (changeset: typeof ChangesetSchema.Type) => + Effect.logDebug( + `Deploying changeset with ${changeset.changes.length} changes` + ).pipe( + Effect.andThen( + apiClient.changesets.deployChangeset({ + payload: { changeset }, + }) + ), + Effect.withSpan("SchemaService.deployChangeset"), + Effect.catch( + (e) => + Effect.fail(new ChangeDeploymentError({ + cause: e, + change: "Full changeset deployment", + })) + ) + ); + + return { + buildChangeset, + checkProviderConfigurations, + computeDiff, + deployChange, + deployChangeset, + fetchProviderConfigurations, + fetchRemoteSchema, + loadLocalSchema, + summarizeDiff, + } as const; +}); + +type SchemaServiceShape = Effect.Success; + +export class SchemaService extends ServiceMap.Service()( + "voidhash-cli/Schema" +) { + static Default = Layer.effect(SchemaService, make).pipe( + Layer.provide(ApiClient.Default) + ) +} diff --git a/apps/cli/src/domain/services/source-code.ts b/apps/cli/src/domain/services/source-code.ts index c5a378bb0..efb083d01 100644 --- a/apps/cli/src/domain/services/source-code.ts +++ b/apps/cli/src/domain/services/source-code.ts @@ -1,5 +1,4 @@ -import { FileSystem, Path } from "@effect/platform"; -import { Effect, Schema } from "effect"; +import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"; import { safeRegister } from "../../utils/js-loading/js-file-loading"; import { relativePathPrefixFromDepth } from "../../utils/source-code"; @@ -16,338 +15,300 @@ import { import { PackageJsonSchema } from "../schema/package-json"; import { VoidhashConfigSchema } from "../schema/voidhash-config"; -export class SourceCode extends Effect.Service()( - "voidhash-cli/SourceCode", - { - dependencies: [], - // Define how to create the service - effect: Effect.gen(function* effect() { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - // =================================== - // Langauge - // =================================== - - /** - * Detects the source code language of the project. - * @returns The source code language. - */ - const detectSrcLanguage = () => - Effect.gen(function* detectSrcLanguage() { - // Check if tsconfig.json exists - const tsconfigPath = path.resolve("./tsconfig.json"); - const tsconfigExists = yield* fs.exists(tsconfigPath); - if (tsconfigExists) { - return "ts"; - } - return "js"; - }); - - // =================================== - // Monorepo - // =================================== - - /** - * Detects the root path of the monorepo. - * @param maxDepth - The maximum depth to check for a monorepo root. - * @returns The root path of the monorepo. - */ - const detectMonorepoRootPath = (maxDepth = 10) => - Effect.gen(function* detectMonorepoRootPath() { - const checkIsMonorepoRoot = (depth: number) => - Effect.gen(function* checkIsMonorepoRoot() { - const pathPrefix = relativePathPrefixFromDepth(depth); - - // Check for monorepo indicators - const isPnpmWorkspace = yield* fs.exists( - path.resolve(pathPrefix, "pnpm-workspace.yaml") - ); - const isYarnWorkspaces = yield* fs.exists( - path.resolve(pathPrefix, "yarn.workspaces.json") - ); - const isTurboRoot = yield* fs.exists( - path.resolve(pathPrefix, "turbo.json") - ); - - // Check for package.json with workspaces field - const packageJsonPath = path.resolve(pathPrefix, "package.json"); - const packageJsonExists = yield* fs.exists(packageJsonPath); - let hasWorkspacesField = false; - - if (packageJsonExists) { - const packageJson = yield* loadPackageJson(pathPrefix); - hasWorkspacesField = - (packageJson.workspaces !== undefined && - Array.isArray(packageJson.workspaces)) || - (packageJson.workspaces !== undefined && - typeof packageJson.workspaces === "object"); - } - - return ( - isPnpmWorkspace || - isYarnWorkspaces || - isTurboRoot || - hasWorkspacesField - ); - }); - - // Check current directory and traverse up - for (let depth = 0; depth <= maxDepth; depth++) { - const isMonorepoRoot = yield* checkIsMonorepoRoot(depth); - if (isMonorepoRoot) { - const pathPrefix = relativePathPrefixFromDepth(depth); - return path.resolve(pathPrefix); - } - } - - return null; // No monorepo root found - }); +const make = Effect.gen(function* effect() { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + // =================================== + // Langauge + // =================================== + + /** + * Detects the source code language of the project. + * @returns The source code language. + */ + const detectSrcLanguage = () => + Effect.gen(function* detectSrcLanguage() { + // Check if tsconfig.json exists + const tsconfigPath = path.resolve("./tsconfig.json"); + const tsconfigExists = yield* fs.exists(tsconfigPath); + if (tsconfigExists) { + return "ts"; + } + return "js"; + }); + + // =================================== + // Monorepo + // =================================== + + /** + * Detects the root path of the monorepo. + * @param maxDepth - The maximum depth to check for a monorepo root. + * @returns The root path of the monorepo. + */ + const detectMonorepoRootPath = (maxDepth = 10) => + Effect.gen(function* detectMonorepoRootPath() { + const checkIsMonorepoRoot = (depth: number) => + Effect.gen(function* checkIsMonorepoRoot() { + const pathPrefix = relativePathPrefixFromDepth(depth); + + // Check for monorepo indicators + const isPnpmWorkspace = yield* fs.exists( + path.resolve(pathPrefix, "pnpm-workspace.yaml") + ); + const isYarnWorkspaces = yield* fs.exists( + path.resolve(pathPrefix, "yarn.workspaces.json") + ); + const isTurboRoot = yield* fs.exists( + path.resolve(pathPrefix, "turbo.json") + ); - // =================================== - // Package JSON - // =================================== - - /** - * Loads the package.json file from the given base path. - * @param basePath - The base path to load the package.json file from. - * @returns The package.json file. - */ - const loadPackageJson = (basePath = "./") => - Effect.gen(function* loadPackageJson() { - const packageJsonPath = path.resolve(basePath, "package.json"); + // Check for package.json with workspaces field + const packageJsonPath = path.resolve(pathPrefix, "package.json"); const packageJsonExists = yield* fs.exists(packageJsonPath); - if (!packageJsonExists) { - return yield* Effect.fail( - new PackageJsonNotFoundError({ - message: "Package JSON not found in this directory.", - }) - ); - - /** - * ValidationError.invalidValue( - HelpDoc.p( - 'React Native project not found in this directory. Please re-run the command in the root of the React Native project.' - ) - ) - */ + let hasWorkspacesField = false; + + if (packageJsonExists) { + const packageJson = yield* loadPackageJson(pathPrefix); + hasWorkspacesField = + (packageJson.workspaces !== undefined && + Array.isArray(packageJson.workspaces)) || + (packageJson.workspaces !== undefined && + typeof packageJson.workspaces === "object"); } - const packageJson = yield* fs.readFileString(packageJsonPath); - return yield* Schema.decodeUnknown(PackageJsonSchema)( - JSON.parse(packageJson) - ).pipe( - Effect.catchTag("ParseError", (e) => - Effect.fail( - new InvalidPackageJsonError({ - cause: e, - message: "Invalid package JSON", - }) - ) - ) + return ( + isPnpmWorkspace || + isYarnWorkspaces || + isTurboRoot || + hasWorkspacesField ); - }).pipe( - Effect.catchTags({ - BadArgument: (e) => - Effect.fail( - new FailedToLoadPackageJsonError({ - cause: e, - message: "Failed to load package JSON", - }) - ), - SystemError: (e) => - Effect.fail( - new FailedToLoadPackageJsonError({ - cause: e, - message: "Failed to load package JSON", - }) - ), + }); + + // Check current directory and traverse up + for (let depth = 0; depth <= maxDepth; depth++) { + const isMonorepoRoot = yield* checkIsMonorepoRoot(depth); + if (isMonorepoRoot) { + const pathPrefix = relativePathPrefixFromDepth(depth); + return path.resolve(pathPrefix); + } + } + + return null; // No monorepo root found + }); + + // =================================== + // Package JSON + // =================================== + + /** + * Loads the package.json file from the given base path. + * @param basePath - The base path to load the package.json file from. + * @returns The package.json file. + */ + const loadPackageJson = (basePath = "./") => + Effect.gen(function* loadPackageJson() { + const packageJsonPath = path.resolve(basePath, "package.json"); + const packageJsonExists = yield* fs.exists(packageJsonPath); + if (!packageJsonExists) { + return yield* Effect.fail( + new PackageJsonNotFoundError({ + message: "Package JSON not found in this directory.", }) ); - - // =================================== - // Package Manager - // =================================== - - /** - * Detects the package manager of the project. - * @param pathPrefix - The path prefix to check for the package manager. - * @returns The package manager. - */ - const detectPackageManager = (pathPrefix = "./") => - Effect.gen(function* detectPackageManager() { - // npm - const packageLockPath = path.resolve(pathPrefix, "package-lock.json"); - const packageLockExists = yield* fs.exists(packageLockPath); - if (packageLockExists) { - return "npm"; - } - - // yarn - const yarnLockPath = path.resolve(pathPrefix, "yarn.lock"); - const yarnLockExists = yield* fs.exists(yarnLockPath); - if (yarnLockExists) { - return "yarn"; - } - - // pnpm - const pnpmLockPath = path.resolve(pathPrefix, "pnpm-lock.yaml"); - const pnpmLockExists = yield* fs.exists(pnpmLockPath); - if (pnpmLockExists) { - return "pnpm"; - } - - // bun - const bunLockPath = path.resolve(pathPrefix, "bun.lockb"); - const bunLockExists = yield* fs.exists(bunLockPath); - if (bunLockExists) { - return "bun"; - } - - return yield* Effect.fail( - new NoPackageManagerFoundError({ - message: "No package manager found in this directory.", + } + + const packageJson = yield* fs.readFileString(packageJsonPath); + return yield* Schema.decodeUnknownEffect(PackageJsonSchema)( + JSON.parse(packageJson) + ).pipe( + Effect.catchTag("SchemaError", (e) => + Effect.fail( + new InvalidPackageJsonError({ + cause: e, + message: "Invalid package JSON", }) - ); - }).pipe( - Effect.catchTags({ - BadArgument: (e) => - Effect.fail( - new FailedToDetectPackageManagerError({ - cause: e, - message: "Failed to detect package manager", - }) - ), - SystemError: (e) => - Effect.fail( - new FailedToDetectPackageManagerError({ - cause: e, - message: "Failed to detect package manager", - }) - ), + ) + ) + ); + }).pipe( + Effect.catchTag("PlatformError", (e) => + Effect.fail( + new FailedToLoadPackageJsonError({ + cause: e, + message: "Failed to load package JSON", }) - ); - - // =================================== - // Source Directory - // =================================== - - /** - * Determines the source directory path for the project. - * - * Checks if a './src' directory exists in the current working directory. - * If it exists, returns the absolute path to './src'. - * Otherwise, returns the absolute path to the current directory ('./'). - * - * @returns {Effect.Effect} An Effect that yields the resolved source directory path. - */ - const retrieveSrcDir = () => - Effect.gen(function* retrieveSrcDir() { - const srcDir = path.resolve("./src"); - const srcDirExists = yield* fs.exists(srcDir); - if (srcDirExists) { - return srcDir; - } - return path.resolve("./"); - }); - - // =================================== - // Voidhash Config - // =================================== - - const loadVoidhashConfig = () => - Effect.gen(function* loadVoidhashConfig() { - const possibleVoidhashConfigPaths = [ - path.resolve("./voidhash.config.ts"), - path.resolve("./voidhash.config.js"), - path.resolve("./voidhash.config.cjs"), - path.resolve("./voidhash.config.mjs"), - ]; - - const existingPaths = yield* Effect.all( - possibleVoidhashConfigPaths.map((path) => - Effect.gen(function* existingPaths() { - const exists = yield* fs.exists(path); - return { exists, path }; - }) - ), - { - concurrency: "unbounded", - } - ); - - const existingPath = existingPaths.find((path) => path.exists)?.path; - if (!existingPath) { - return yield* Effect.fail( - new VoidhashConfigNotFoundError({ - message: "Voidhash config not found", - }) - ); - } - - const absolutePath = path.resolve(existingPath); - const { unregister } = yield* safeRegister(); - const required = require(absolutePath); - unregister(); - const content = required.default ?? required; - return yield* Schema.decodeUnknown(VoidhashConfigSchema)(content); - }).pipe( - Effect.catchTags({ - BadArgument: (e) => - Effect.fail( - new FailedToLoadVoidhashConfigError({ - cause: e, - message: "Failed to load voidhash config", - }) - ), - FailedToLoadJsFileError: (e) => - Effect.fail( - new FailedToLoadVoidhashConfigError({ - cause: e, - message: - "There has been an error while trying to load the voidhash config.", - }) - ), - ParseError: () => - Effect.fail( - new InvalidVoidhashConfigError({ - message: - "Could not parse voidhash config. Please check your voidhash.config.(ts|js|cjs|mjs) file is valid.", - }) - ), - SystemError: (e) => - Effect.fail( - new FailedToLoadVoidhashConfigError({ - cause: e, - message: "Failed to load voidhash config", - }) - ), - - // Effect.fail( - // ValidationError.invalidValue( - // HelpDoc.p( - // 'Could not parse voidhash config. Please check your voidhash.config.(ts|js|cjs|mjs) file is valid.' - // ) - // ) - // ) + ) + ) + ); + + // =================================== + // Package Manager + // =================================== + + /** + * Detects the package manager of the project. + * @param pathPrefix - The path prefix to check for the package manager. + * @returns The package manager. + */ + const detectPackageManager = (pathPrefix = "./") => + Effect.gen(function* detectPackageManager() { + // npm + const packageLockPath = path.resolve(pathPrefix, "package-lock.json"); + const packageLockExists = yield* fs.exists(packageLockPath); + if (packageLockExists) { + return "npm"; + } + + // yarn + const yarnLockPath = path.resolve(pathPrefix, "yarn.lock"); + const yarnLockExists = yield* fs.exists(yarnLockPath); + if (yarnLockExists) { + return "yarn"; + } + + // pnpm + const pnpmLockPath = path.resolve(pathPrefix, "pnpm-lock.yaml"); + const pnpmLockExists = yield* fs.exists(pnpmLockPath); + if (pnpmLockExists) { + return "pnpm"; + } + + // bun + const bunLockPath = path.resolve(pathPrefix, "bun.lockb"); + const bunLockExists = yield* fs.exists(bunLockPath); + if (bunLockExists) { + return "bun"; + } + + return yield* Effect.fail( + new NoPackageManagerFoundError({ + message: "No package manager found in this directory.", + }) + ); + }).pipe( + Effect.catchTag("PlatformError", (e) => + Effect.fail( + new FailedToDetectPackageManagerError({ + cause: e, + message: "Failed to detect package manager", + }) + ) + ) + ); + + // =================================== + // Source Directory + // =================================== + + /** + * Determines the source directory path for the project. + * + * Checks if a './src' directory exists in the current working directory. + * If it exists, returns the absolute path to './src'. + * Otherwise, returns the absolute path to the current directory ('./'). + * + * @returns {Effect.Effect} An Effect that yields the resolved source directory path. + */ + const retrieveSrcDir = () => + Effect.gen(function* retrieveSrcDir() { + const srcDir = path.resolve("./src"); + const srcDirExists = yield* fs.exists(srcDir); + if (srcDirExists) { + return srcDir; + } + return path.resolve("./"); + }); + + // =================================== + // Voidhash Config + // =================================== + + const loadVoidhashConfig = () => + Effect.gen(function* loadVoidhashConfig() { + const possibleVoidhashConfigPaths = [ + path.resolve("./voidhash.config.ts"), + path.resolve("./voidhash.config.js"), + path.resolve("./voidhash.config.cjs"), + path.resolve("./voidhash.config.mjs"), + ]; + + const existingPaths = yield* Effect.all( + possibleVoidhashConfigPaths.map((path) => + Effect.gen(function* existingPaths() { + const exists = yield* fs.exists(path); + return { exists, path }; + }) + ), + { + concurrency: "unbounded", + } + ); + + const existingPath = existingPaths.find((path) => path.exists)?.path; + if (!existingPath) { + return yield* Effect.fail( + new VoidhashConfigNotFoundError({ + message: "Voidhash config not found", }) ); - - const deleteVoidhashConfig = () => - Effect.gen(function* deleteVoidhashConfig() { - const voidhashConfigPath = path.resolve("./voidhash.config.ts"); - yield* fs.remove(voidhashConfigPath); - }); - - return { - deleteVoidhashConfig, - detectMonorepoRootPath, - detectPackageManager, - detectSrcLanguage, - loadPackageJson, - loadVoidhashConfig, - retrieveSrcDir, - } as const; - }), - } -) {} + } + + const absolutePath = path.resolve(existingPath); + const { unregister } = yield* safeRegister(); + const required = require(absolutePath); + unregister(); + const content = required.default ?? required; + return yield* Schema.decodeUnknownEffect(VoidhashConfigSchema)(content); + }).pipe( + Effect.catchTags({ + FailedToLoadJsFileError: (e) => + Effect.fail( + new FailedToLoadVoidhashConfigError({ + cause: e, + message: + "There has been an error while trying to load the voidhash config.", + }) + ), + PlatformError: (e) => + Effect.fail( + new FailedToLoadVoidhashConfigError({ + cause: e, + message: "Failed to load voidhash config", + }) + ), + SchemaError: () => + Effect.fail( + new InvalidVoidhashConfigError({ + message: + "Could not parse voidhash config. Please check your voidhash.config.(ts|js|cjs|mjs) file is valid.", + }) + ), + }) + ); + + const deleteVoidhashConfig = () => + Effect.gen(function* deleteVoidhashConfig() { + const voidhashConfigPath = path.resolve("./voidhash.config.ts"); + yield* fs.remove(voidhashConfigPath); + }); + + return { + deleteVoidhashConfig, + detectMonorepoRootPath, + detectPackageManager, + detectSrcLanguage, + loadPackageJson, + loadVoidhashConfig, + retrieveSrcDir, + } as const; +}); + +type SourceCodeShape = Effect.Success; + +export class SourceCode extends ServiceMap.Service()( + "voidhash-cli/SourceCode" +) { + static Default = Layer.effect(SourceCode, make) +} diff --git a/apps/cli/src/services/auth/get-session.ts b/apps/cli/src/services/auth/get-session.ts index 698fac6c4..03943b09f 100644 --- a/apps/cli/src/services/auth/get-session.ts +++ b/apps/cli/src/services/auth/get-session.ts @@ -17,7 +17,7 @@ export const getSession = Effect.gen(function* getSession() { }, (effect) => effect.pipe( - Effect.catchAll((error) => { + Effect.catch((error) => { if (error._tag === "NotAuthenticatedError") { return Effect.fail( new OrganizationServiceError({ diff --git a/apps/cli/src/services/auth/index.ts b/apps/cli/src/services/auth/index.ts index 6a05e10fe..492774624 100644 --- a/apps/cli/src/services/auth/index.ts +++ b/apps/cli/src/services/auth/index.ts @@ -1,11 +1,13 @@ -import { Effect } from "effect"; +import { Effect, Layer, ServiceMap } from "effect"; -export class AuthService extends Effect.Service()( - "voidhash-cli/services/AuthService", - { - dependencies: [], - scoped: Effect.gen(function* scoped() { - return {} as const; - }), - } -) {} +const make = Effect.gen(function* scoped() { + return {} as const; +}); + +type AuthServiceShape = Effect.Success; + +export class AuthService extends ServiceMap.Service()( + "voidhash-cli/services/AuthService" +) { + static Default = Layer.effect(AuthService, make) +} diff --git a/apps/cli/src/services/auth/utils/better-auth.ts b/apps/cli/src/services/auth/utils/better-auth.ts index c39235e17..a1e63da87 100644 --- a/apps/cli/src/services/auth/utils/better-auth.ts +++ b/apps/cli/src/services/auth/utils/better-auth.ts @@ -1,6 +1,6 @@ import { createAuthClient } from "better-auth/client"; import { apiKeyClient } from "better-auth/client/plugins"; -import { Data, Effect } from "effect"; +import { Data, Effect, Layer, ServiceMap } from "effect"; import { CliConfig } from "../../../domain/services/cli-config"; @@ -11,40 +11,42 @@ export class BetterAuthClientError extends Data.TaggedError( readonly message: string; }> {} -export class BetterAuthClient extends Effect.Service()( - "app/BetterAuthClient", - { - dependencies: [], - effect: Effect.gen(function* effect() { - const cliConfig = yield* CliConfig; - const config = yield* cliConfig.readConfig(); - const authClient: ReturnType = createAuthClient({ - basePath: "/auth/api/auth", - baseURL: config.web_url ?? "https://voidhash.com", - plugins: [apiKeyClient()], - }); +const make = Effect.gen(function* effect() { + const cliConfig = yield* CliConfig; + const config = yield* cliConfig.readConfig(); + const authClient: ReturnType = createAuthClient({ + basePath: "/auth/api/auth", + baseURL: config.web_url ?? "https://voidhash.com", + plugins: [apiKeyClient()], + }); - return { - use: ( - fn: ( - client: typeof authClient - ) => Promise<{ error: E; data?: null } | { error?: null; data: D }> - ) => - Effect.tryPromise({ - catch: (error) => - new BetterAuthClientError({ - cause: error, - message: "Failed to use better-auth client", - }), - try: async () => { - const res = await fn(authClient); - if (res.error) { - throw res.error; - } - return res.data; - }, + return { + use: ( + fn: ( + client: typeof authClient + ) => Promise<{ error: E; data?: null } | { error?: null; data: D }> + ) => + Effect.tryPromise({ + catch: (error) => + new BetterAuthClientError({ + cause: error, + message: "Failed to use better-auth client", }), - }; - }), - } -) {} + try: async () => { + const res = await fn(authClient); + if (res.error) { + throw res.error; + } + return res.data; + }, + }), + }; +}); + +type BetterAuthClientShape = Effect.Success; + +export class BetterAuthClient extends ServiceMap.Service()( + "app/BetterAuthClient" +) { + static Default = Layer.effect(BetterAuthClient, make) +} diff --git a/apps/cli/src/services/cli-config/index.ts b/apps/cli/src/services/cli-config/index.ts index d5befac8b..b07018236 100644 --- a/apps/cli/src/services/cli-config/index.ts +++ b/apps/cli/src/services/cli-config/index.ts @@ -1,11 +1,13 @@ -import { Effect } from "effect"; +import { Effect, Layer, ServiceMap } from "effect"; -export class CliConfigService extends Effect.Service()( - "voidhash-cli/services/CliConfigService", - { - dependencies: [], - scoped: Effect.gen(function* scoped() { - return {} as const; - }), - } -) {} +const make = Effect.gen(function* scoped() { + return {} as const; +}); + +type CliConfigServiceShape = Effect.Success; + +export class CliConfigService extends ServiceMap.Service()( + "voidhash-cli/services/CliConfigService" +) { + static Default = Layer.effect(CliConfigService, make) +} diff --git a/apps/cli/src/services/organization/create-organization.ts b/apps/cli/src/services/organization/create-organization.ts index acac1d67c..7bf2ed355 100644 --- a/apps/cli/src/services/organization/create-organization.ts +++ b/apps/cli/src/services/organization/create-organization.ts @@ -17,7 +17,7 @@ export const createOrganization = Effect.gen(function* createOrganization() { }, (effect) => effect.pipe( - Effect.catchAll((error) => { + Effect.catch((error) => { if (error._tag === "NotAuthenticatedError") { return Effect.fail( new OrganizationServiceError({ diff --git a/apps/cli/src/services/organization/errors.ts b/apps/cli/src/services/organization/errors.ts index f14707d14..ad49c43c8 100644 --- a/apps/cli/src/services/organization/errors.ts +++ b/apps/cli/src/services/organization/errors.ts @@ -1,6 +1,6 @@ import { Schema } from "effect"; -export class OrganizationServiceError extends Schema.TaggedError()( +export class OrganizationServiceError extends Schema.TaggedErrorClass()( "OrganizationServiceError", { message: Schema.String, diff --git a/apps/cli/src/services/organization/index.ts b/apps/cli/src/services/organization/index.ts index a1aa2616a..341bb0221 100644 --- a/apps/cli/src/services/organization/index.ts +++ b/apps/cli/src/services/organization/index.ts @@ -1,11 +1,13 @@ -import { Effect } from "effect"; +import { Effect, Layer, ServiceMap } from "effect"; -export class OrganizationService extends Effect.Service()( - "voidhash-cli/services/OrganizationService", - { - dependencies: [], - scoped: Effect.gen(function* scoped() { - return {} as const; - }), - } -) {} +const make = Effect.gen(function* scoped() { + return {} as const; +}); + +type OrganizationServiceShape = Effect.Success; + +export class OrganizationService extends ServiceMap.Service()( + "voidhash-cli/services/OrganizationService" +) { + static Default = Layer.effect(OrganizationService, make) +} diff --git a/apps/cli/src/services/organization/list-organizations.ts b/apps/cli/src/services/organization/list-organizations.ts index 17889b531..76be481c5 100644 --- a/apps/cli/src/services/organization/list-organizations.ts +++ b/apps/cli/src/services/organization/list-organizations.ts @@ -17,7 +17,7 @@ export const listOrganizations = Effect.gen(function* listOrganizations() { }, (effect) => effect.pipe( - Effect.catchAll((error) => { + Effect.catch((error) => { if (error._tag === "NotAuthenticatedError") { return Effect.fail( new OrganizationServiceError({ diff --git a/apps/cli/src/services/project/index.ts b/apps/cli/src/services/project/index.ts index 0ffce0a93..5891ab42a 100644 --- a/apps/cli/src/services/project/index.ts +++ b/apps/cli/src/services/project/index.ts @@ -1,11 +1,13 @@ -import { Effect } from "effect"; +import { Effect, Layer, ServiceMap } from "effect"; -export class ProjectService extends Effect.Service()( - "voidhash-cli/services/ProjectService", - { - dependencies: [], - scoped: Effect.gen(function* scoped() { - return {} as const; - }), - } -) {} +const make = Effect.gen(function* scoped() { + return {} as const; +}); + +type ProjectServiceShape = Effect.Success; + +export class ProjectService extends ServiceMap.Service()( + "voidhash-cli/services/ProjectService" +) { + static Default = Layer.effect(ProjectService, make) +} diff --git a/apps/cli/src/services/repository/index.ts b/apps/cli/src/services/repository/index.ts index 703c7e414..dad5c0fcf 100644 --- a/apps/cli/src/services/repository/index.ts +++ b/apps/cli/src/services/repository/index.ts @@ -1,11 +1,13 @@ -import { Effect } from "effect"; +import { Effect, Layer, ServiceMap } from "effect"; -export class RepositoryService extends Effect.Service()( - "voidhash-cli/services/RepositoryService", - { - dependencies: [], - scoped: Effect.gen(function* scoped() { - return {} as const; - }), - } -) {} +const make = Effect.gen(function* scoped() { + return {} as const; +}); + +type RepositoryServiceShape = Effect.Success; + +export class RepositoryService extends ServiceMap.Service()( + "voidhash-cli/services/RepositoryService" +) { + static Default = Layer.effect(RepositoryService, make) +} diff --git a/apps/cli/src/utils/api-client.ts b/apps/cli/src/utils/api-client.ts index ede8914be..e5a4b5f73 100644 --- a/apps/cli/src/utils/api-client.ts +++ b/apps/cli/src/utils/api-client.ts @@ -1,43 +1,48 @@ -import { FetchHttpClient, HttpApiClient, HttpClient } from "@effect/platform"; import { VoidhashV1Api } from "@voidhash/api-spec"; -import { Effect } from "effect"; +import { Effect, Layer, ServiceMap } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { HttpApiClient } from "effect/unstable/httpapi"; import { CliConfig } from "../domain/services/cli-config"; -export class ApiClient extends Effect.Service()( - "voidhash-cli/ApiClient", - { - dependencies: [FetchHttpClient.layer, CliConfig.Default], - effect: Effect.gen(function* effect() { - yield* Effect.logDebug("Initializing API client"); - const cliConfig = yield* CliConfig; - return yield* HttpApiClient.make(VoidhashV1Api, { - baseUrl: "http://localhost:5001", - transformClient: (client) => - client.pipe( - HttpClient.mapRequestEffect((request) => - Effect.gen(function* transformClient() { - const config = yield* cliConfig.readConfig().pipe( - Effect.catchAll(() => - Effect.dieMessage("Failed to read config") - ) - ); +const make = Effect.gen(function* effect() { + yield* Effect.logDebug("Initializing API client"); + const cliConfig = yield* CliConfig; + return yield* HttpApiClient.make(VoidhashV1Api, { + baseUrl: "http://localhost:5001", + transformClient: (client) => + client.pipe( + HttpClient.mapRequestEffect((request) => + Effect.gen(function* transformClient() { + const config = yield* cliConfig.readConfig().pipe( + Effect.catch(() => + Effect.die("Failed to read config") + ) + ); - yield* Effect.logDebug( - `API Request: ${request.method} ${request.url}` - ); + yield* Effect.logDebug( + `API Request: ${request.method} ${request.url}` + ); - return { - ...request, - headers: { - ...request.headers, - ...(config.api_key ? { "x-api-key": config.api_key } : {}), - }, - }; - }).pipe(Effect.withSpan("ApiClient.transformRequest")) - ) - ), - }); - }).pipe(Effect.withSpan("ApiClient.make")), - } -) {} + return { + ...request, + headers: { + ...request.headers, + ...(config.api_key ? { "x-api-key": config.api_key } : {}), + }, + }; + }).pipe(Effect.withSpan("ApiClient.transformRequest")) + ) + ), + }); +}).pipe(Effect.withSpan("ApiClient.make")); + +type ApiClientShape = Effect.Success; + +export class ApiClient extends ServiceMap.Service()( + "voidhash-cli/ApiClient" +) { + static Default = Layer.effect(ApiClient, make).pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, CliConfig.Default)) + ) +} diff --git a/apps/cli/src/utils/error-formatter.ts b/apps/cli/src/utils/error-formatter.ts index d7eb3bb8c..10b754089 100644 --- a/apps/cli/src/utils/error-formatter.ts +++ b/apps/cli/src/utils/error-formatter.ts @@ -1,8 +1,7 @@ -import { HelpDoc, ValidationError } from "@effect/cli"; -import * as Span from "@effect/cli/HelpDoc/Span"; -import { Cause, Chunk, Console, Effect } from "effect"; +import { CliError } from "effect/unstable/cli"; +import { Cause, Console, Effect } from "effect"; -const ValidationErrorTypeId = Symbol.for("@effect/cli/ValidationError"); +const CliErrorTypeId = Symbol.for("~effect/cli/CliError"); /** * Check if debug mode is enabled via --debug flag @@ -11,13 +10,7 @@ export const isDebugMode = (): boolean => process.argv.includes("--debug") || process.argv.includes("-d"); /** - * Creates a nicely formatted error HelpDoc with red styling. - */ -export const errorDoc = (message: string): HelpDoc.HelpDoc => - HelpDoc.p(Span.error(message)); - -/** - * Creates a ValidationError with a nicely formatted red error message. + * Creates a CliError.UserError with a message. * Use this in command error handlers to show user-friendly errors. * * @example @@ -27,21 +20,21 @@ export const errorDoc = (message: string): HelpDoc.HelpDoc => * ) * ``` */ -export const userError = (message: string): ValidationError.ValidationError => - ValidationError.invalidValue(errorDoc(message)); +export const userError = (message: string): CliError.UserError => + new CliError.UserError({ cause: new Error(message) }); /** - * Check if an error is a ValidationError from @effect/cli + * Check if an error is a CliError from @effect/cli */ -const isValidationError = ( +const isCliError = ( error: unknown -): error is ValidationError.ValidationError => - typeof error === "object" && error !== null && ValidationErrorTypeId in error; +): error is CliError.CliError => + typeof error === "object" && error !== null && CliErrorTypeId in error; /** - * Wraps a CLI effect to properly render ValidationErrors. + * Wraps a CLI effect to properly render CliErrors. * - * @effect/cli's Command.run doesn't render ValidationErrors that bubble up + * @effect/cli's Command.run doesn't render CliErrors that bubble up * from command handlers - it only handles them during parsing. * This wrapper catches those errors and prints them nicely, then exits with code 1. * @@ -49,54 +42,56 @@ const isValidationError = ( */ export const withValidationErrorHandler = ( effect: Effect.Effect -): Effect.Effect, R> => +): Effect.Effect, R> => effect.pipe( - Effect.catchAllCause((cause) => { - const failures = Cause.failures(cause); + Effect.catchCause((cause) => { + const failures = cause.reasons + .filter((r): r is Extract => r._tag === "Fail") + .map((r) => r.error); for (const failure of failures) { - if (isValidationError(failure)) { + if (isCliError(failure)) { // In debug mode, show the full cause chain if (isDebugMode()) { return Console.error("\n--- Debug Trace ---").pipe( Effect.andThen( - Console.error(Cause.pretty(cause, { renderErrorCause: true })) + Console.error(Cause.pretty(cause)) ), Effect.andThen(Console.error("--- End Debug Trace ---\n")), - Effect.andThen(Console.error(HelpDoc.toAnsiText(failure.error))), + Effect.andThen(Console.error(failure.message)), Effect.andThen(Effect.sync(() => process.exit(1))) ); } // Normal mode: just show the user-friendly message - return Console.error(HelpDoc.toAnsiText(failure.error)).pipe( + return Console.error(failure.message).pipe( Effect.andThen(Effect.sync(() => process.exit(1))) ); } } - // For non-ValidationError failures, show full cause in debug mode - if (isDebugMode() && !Cause.isEmpty(cause)) { + // For non-CliError failures, show full cause in debug mode + if (isDebugMode() && cause.reasons.length > 0) { return Console.error("\n--- Debug Trace ---").pipe( Effect.andThen( - Console.error(Cause.pretty(cause, { renderErrorCause: true })) + Console.error(Cause.pretty(cause)) ), Effect.andThen(Console.error("--- End Debug Trace ---\n")), Effect.andThen(Effect.sync(() => process.exit(1))) ); } - // Re-fail with non-ValidationError - const firstFailure = Chunk.head(failures); - if (firstFailure._tag === "Some") { + // Re-fail with non-CliError + const firstFailure = failures[0]; + if (firstFailure !== undefined) { return Effect.fail( - firstFailure.value as Exclude + firstFailure as Exclude ); } // Handle defects return Effect.failCause( - cause as Cause.Cause> + cause as Cause.Cause> ); }) ); diff --git a/apps/cli/src/utils/fs.ts b/apps/cli/src/utils/fs.ts index e5aa2671c..19ba47b27 100644 --- a/apps/cli/src/utils/fs.ts +++ b/apps/cli/src/utils/fs.ts @@ -1,6 +1,5 @@ -import { Prompt } from "@effect/cli"; -import { FileSystem } from "@effect/platform"; -import { Data, Effect } from "effect"; +import { Prompt } from "effect/unstable/cli"; +import { Data, Effect, FileSystem } from "effect"; export class FileExistsError extends Data.TaggedError("FileExistsError")<{ readonly message: string; diff --git a/apps/cli/src/utils/js-loading/js-file-loading.ts b/apps/cli/src/utils/js-loading/js-file-loading.ts index 8f7b3f7f3..92ef1d028 100644 --- a/apps/cli/src/utils/js-loading/js-file-loading.ts +++ b/apps/cli/src/utils/js-loading/js-file-loading.ts @@ -57,7 +57,7 @@ export const safeRegister = () => loader: "ts", }), }).pipe( - Effect.orElse(() => + Effect.catch(() => Effect.succeed({ // biome-ignore lint/suspicious/noEmptyBlockStatements: it is on purpose an empty function. It is here instead of try-catch due to tsx. unregister(): void {}, diff --git a/apps/cli/src/utils/organizations/create-organization.ts b/apps/cli/src/utils/organizations/create-organization.ts index 78a3f0d61..548d812d6 100644 --- a/apps/cli/src/utils/organizations/create-organization.ts +++ b/apps/cli/src/utils/organizations/create-organization.ts @@ -1,4 +1,4 @@ -import { Prompt } from "@effect/cli"; +import { Prompt } from "effect/unstable/cli"; import { Console, Effect } from "effect"; import { NoSignedInUserError } from "../../domain/errors/auth"; diff --git a/apps/cli/src/utils/organizations/select-organization.ts b/apps/cli/src/utils/organizations/select-organization.ts index 8ff72fcc8..ffe4c4be5 100644 --- a/apps/cli/src/utils/organizations/select-organization.ts +++ b/apps/cli/src/utils/organizations/select-organization.ts @@ -1,4 +1,4 @@ -import { Prompt } from "@effect/cli"; +import { Prompt } from "effect/unstable/cli"; import { Effect } from "effect"; import { createOrganization } from "./create-organization"; @@ -30,7 +30,7 @@ export const selectOrganization = ( } const organization = organizations.find((t) => t.slug === organizationSlug); if (!organization) { - return yield* Effect.dieMessage( + return yield* Effect.die( "Organization not found even though it was selected and should exist." ); } diff --git a/apps/cli/src/utils/projects/create-project.ts b/apps/cli/src/utils/projects/create-project.ts index c711b3369..78d4cce19 100644 --- a/apps/cli/src/utils/projects/create-project.ts +++ b/apps/cli/src/utils/projects/create-project.ts @@ -1,4 +1,4 @@ -import { Prompt } from "@effect/cli"; +import { Prompt } from "effect/unstable/cli"; import { Console, Effect } from "effect"; import { NoSignedInUserError } from "../../domain/errors/auth"; @@ -22,7 +22,7 @@ export const createProject = (input: { organizationId: string }) => const config = yield* cliConfig .readConfig() - .pipe(Effect.catchAll(() => Effect.dieMessage("Failed to read config"))); + .pipe(Effect.catch(() => Effect.die("Failed to read config"))); // If the config file is not found or the api key is not set, we consider the user to be signed out const apiKey = config.api_key; diff --git a/apps/cli/src/utils/projects/select-project.ts b/apps/cli/src/utils/projects/select-project.ts index 9892cf936..3c6150b01 100644 --- a/apps/cli/src/utils/projects/select-project.ts +++ b/apps/cli/src/utils/projects/select-project.ts @@ -1,4 +1,4 @@ -import { Prompt } from "@effect/cli"; +import { Prompt } from "effect/unstable/cli"; import { Effect } from "effect"; import { createProject } from "./create-project"; @@ -29,7 +29,7 @@ export const selectProject = ( const project = projects.find((p) => p.slug === projectSlug); if (!project) { - return yield* Effect.dieMessage( + return yield* Effect.die( "Project not found even though it was selected and should exist." ); } diff --git a/apps/cli/src/utils/schema/local-schema-loader.ts b/apps/cli/src/utils/schema/local-schema-loader.ts index 058fa2d3f..da5c6a803 100644 --- a/apps/cli/src/utils/schema/local-schema-loader.ts +++ b/apps/cli/src/utils/schema/local-schema-loader.ts @@ -1,6 +1,5 @@ -import { FileSystem, Path } from "@effect/platform"; import Module from "node:module"; -import { Effect } from "effect"; +import { Effect, FileSystem, Path } from "effect"; import { LocalSchemaNotFoundError, diff --git a/apps/cli/src/utils/source-code-details.ts b/apps/cli/src/utils/source-code-details.ts index ecca6ae9d..d4b19e195 100644 --- a/apps/cli/src/utils/source-code-details.ts +++ b/apps/cli/src/utils/source-code-details.ts @@ -1,4 +1,4 @@ -import { Context, Effect } from "effect"; +import { Effect, ServiceMap } from "effect"; import type { PackageJsonSchema } from "../domain/schema/package-json"; import { SourceCode } from "../domain/services/source-code"; @@ -14,10 +14,7 @@ export interface SourceCodeDetailsType { packageJson: typeof PackageJsonSchema.Type; } -export class SourceCodeDetails extends Context.Tag("app/SourceCodeDetails")< - SourceCodeDetails, - SourceCodeDetailsType ->() { +export class SourceCodeDetails extends ServiceMap.Service()("app/SourceCodeDetails") { static readonly provide = (details: SourceCodeDetailsType) => (effect: Effect.Effect) => diff --git a/libraries/react-native/package.json b/libraries/react-native/package.json index 61a014bec..aabc40301 100644 --- a/libraries/react-native/package.json +++ b/libraries/react-native/package.json @@ -84,7 +84,6 @@ "ultracite": "5.0.39" }, "peerDependencies": { - "@effect/platform": "catalog:", "@react-native-async-storage/async-storage": "^1.24.0 || ^2.0.0", "effect": "catalog:", "expo": "*", diff --git a/libraries/react-native/src/client-effect.ts b/libraries/react-native/src/client-effect.ts index 419b41715..40de0ed71 100644 --- a/libraries/react-native/src/client-effect.ts +++ b/libraries/react-native/src/client-effect.ts @@ -365,7 +365,7 @@ const makeInitializedClient = (options: { for (const transaction of observedTransactionsByKey.values()) { yield* processObservedTransaction(transaction).pipe( - Effect.catchAll((error) => + Effect.catch((error) => Effect.logWarning("Failed to process observed transaction", { error, transactionId: transaction.transactionId, @@ -564,10 +564,10 @@ const makeInitializedClient = (options: { mapQueuedAnalyticsEventToIngestEvent(event, standardizedProperties, analyticsSessionId) ); - const sendResult = yield* Effect.either(sendAnalyticsEventsImpl(ingestBatch)); - if (sendResult._tag === "Left") { + const sendResult = yield* Effect.exit(sendAnalyticsEventsImpl(ingestBatch)); + if (sendResult._tag === "Failure") { analyticsQueue.unshift(...queuedBatch); - yield* Effect.fail(sendResult.left); + yield* Effect.failCause(sendResult.cause); } } }), diff --git a/libraries/react-native/src/client.tsx b/libraries/react-native/src/client.tsx index db1293d0d..a3f9c6672 100644 --- a/libraries/react-native/src/client.tsx +++ b/libraries/react-native/src/client.tsx @@ -1,5 +1,5 @@ -import { FetchHttpClient } from "@effect/platform"; import { Cause, Effect, Exit, Layer, ManagedRuntime, pipe } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; import { VoidhashEffectClient } from "./client-effect"; import { AsyncStorageCacheAdapter } from "./core/caching/async-storage-cache"; diff --git a/libraries/react-native/src/core/caching/cache-adapter.ts b/libraries/react-native/src/core/caching/cache-adapter.ts index 64f6502a7..3d4fafde5 100644 --- a/libraries/react-native/src/core/caching/cache-adapter.ts +++ b/libraries/react-native/src/core/caching/cache-adapter.ts @@ -1,10 +1,7 @@ -import { Context, type Effect } from "effect"; +import { ServiceMap, type Effect } from "effect"; -export class CacheAdapter extends Context.Tag("rn-voidhash/CacheAdapter")< - CacheAdapter, - { +export class CacheAdapter extends ServiceMap.Service Effect.Effect; readonly set: (key: string, value: string) => Effect.Effect; readonly delete: (key: string) => Effect.Effect; - } ->() {} + }>()("rn-voidhash/CacheAdapter") {} diff --git a/libraries/react-native/src/core/caching/cache-manager.ts b/libraries/react-native/src/core/caching/cache-manager.ts index d917eb398..66e6a2073 100644 --- a/libraries/react-native/src/core/caching/cache-manager.ts +++ b/libraries/react-native/src/core/caching/cache-manager.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Layer, ServiceMap } from "effect"; import { CacheAdapter } from "./cache-adapter"; @@ -15,96 +15,95 @@ type CacheHit = CacheEnvelope & { isStale: boolean; isExpired: boolean; }; -export class CacheManager extends Effect.Service()( - "rn-voidhash/CacheManager", - { - dependencies: [], - effect: Effect.gen(function* effect() { - const cache = yield* CacheAdapter; - const get = (key: string) => - Effect.gen(function* get() { - const cachedValue = yield* cache.get(key); - if (cachedValue) { - const cacheHit = JSON.parse(cachedValue) as CacheEnvelope; - const isExpired = cacheHit.expiresAt - ? cacheHit.expiresAt < Date.now() - : false; - const isStale = cacheHit.staleAt - ? cacheHit.staleAt < Date.now() - : false; +const make = Effect.gen(function* effect() { + const cache = yield* CacheAdapter; + const get = (key: string) => + Effect.gen(function* get() { + const cachedValue = yield* cache.get(key); + if (cachedValue) { + const cacheHit = JSON.parse(cachedValue) as CacheEnvelope; - if (isExpired) { - yield* deleteValue(key); - return null; - } + const isExpired = cacheHit.expiresAt + ? cacheHit.expiresAt < Date.now() + : false; + const isStale = cacheHit.staleAt + ? cacheHit.staleAt < Date.now() + : false; - return { - ...cacheHit, - isExpired, - isStale, - } satisfies CacheHit; - } + if (isExpired) { + yield* deleteValue(key); return null; - }); + } - const setValue = ( - key: string, - value: T, - options?: { ttl?: number; staleTime?: number } - ) => - Effect.all([ - cache.set( - key, - JSON.stringify({ - createdAt: Date.now(), - expiresAt: options?.ttl ? Date.now() + options.ttl : null, - staleAt: options?.staleTime - ? Date.now() + options.staleTime - : null, - value, - } satisfies CacheEnvelope) - ), - storeCacheKey(key), - ]); + return { + ...cacheHit, + isExpired, + isStale, + } satisfies CacheHit; + } + return null; + }); - const deleteValue = (key: string) => cache.delete(key); + const setValue = ( + key: string, + value: T, + options?: { ttl?: number; staleTime?: number } + ) => + Effect.all([ + cache.set( + key, + JSON.stringify({ + createdAt: Date.now(), + expiresAt: options?.ttl ? Date.now() + options.ttl : null, + staleAt: options?.staleTime + ? Date.now() + options.staleTime + : null, + value, + } satisfies CacheEnvelope) + ), + storeCacheKey(key), + ]); - const clear = () => - Effect.gen(function* clear() { - const cacheKeys = yield* getCacheKeys(); - yield* Effect.all(cacheKeys.map((key) => cache.delete(key))); - yield* cache.delete(CACHE_KEYS_KEY); - }); + const deleteValue = (key: string) => cache.delete(key); - const getCacheKeys = () => - Effect.gen(function* getCacheKeys() { - const cacheKeys = yield* cache.get(CACHE_KEYS_KEY); - if (cacheKeys) { - return JSON.parse(cacheKeys) as string[]; - } - return []; - }); + const clear = () => + Effect.gen(function* clear() { + const cacheKeys = yield* getCacheKeys(); + yield* Effect.all(cacheKeys.map((key) => cache.delete(key))); + yield* cache.delete(CACHE_KEYS_KEY); + }); - const storeCacheKey = (key: string) => - Effect.gen(function* storeCacheKey() { - const cacheKeys = yield* cache.get(CACHE_KEYS_KEY); - if (cacheKeys) { - const cacheKeysArray = JSON.parse(cacheKeys) as string[]; - cacheKeysArray.push(key); - yield* cache.set(CACHE_KEYS_KEY, JSON.stringify(cacheKeysArray)); - } else { - yield* cache.set(CACHE_KEYS_KEY, JSON.stringify([key])); - } - }); + const getCacheKeys = () => + Effect.gen(function* getCacheKeys() { + const cacheKeys = yield* cache.get(CACHE_KEYS_KEY); + if (cacheKeys) { + return JSON.parse(cacheKeys) as string[]; + } + return []; + }); - return { - clear, - delete: deleteValue, - get, - getCacheKeys, - set: setValue, - } as const; - }), - } -) {} + const storeCacheKey = (key: string) => + Effect.gen(function* storeCacheKey() { + const cacheKeys = yield* cache.get(CACHE_KEYS_KEY); + if (cacheKeys) { + const cacheKeysArray = JSON.parse(cacheKeys) as string[]; + cacheKeysArray.push(key); + yield* cache.set(CACHE_KEYS_KEY, JSON.stringify(cacheKeysArray)); + } else { + yield* cache.set(CACHE_KEYS_KEY, JSON.stringify([key])); + } + }); + + return { + clear, + delete: deleteValue, + get, + getCacheKeys, + set: setValue, + } as const; +}); + +export class CacheManager extends ServiceMap.Service>()("rn-voidhash/CacheManager") { + static Default = Layer.effect(CacheManager, make) +} diff --git a/libraries/react-native/src/core/event-bus.ts b/libraries/react-native/src/core/event-bus.ts index ea19b3b69..0e4f12686 100644 --- a/libraries/react-native/src/core/event-bus.ts +++ b/libraries/react-native/src/core/event-bus.ts @@ -1,5 +1,5 @@ import type { SdkCustomer } from "@voidhash/api-spec"; -import { Context } from "effect"; +import { ServiceMap } from "effect"; export interface CustomerFetchedEvent { type: "customer-fetched"; @@ -68,6 +68,4 @@ export class EventBus { } } -export class EventBusProvider extends Context.Tag( - "rn-voidhash/EventBusProvider" -)() {} +export class EventBusProvider extends ServiceMap.Service()("rn-voidhash/EventBusProvider") {} diff --git a/libraries/react-native/src/core/identity/customer-attribute-manager.ts b/libraries/react-native/src/core/identity/customer-attribute-manager.ts index 08f22dde9..2f90b73c4 100644 --- a/libraries/react-native/src/core/identity/customer-attribute-manager.ts +++ b/libraries/react-native/src/core/identity/customer-attribute-manager.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Layer, ServiceMap } from "effect"; import { CacheManager } from "../caching/cache-manager"; import { ApiClient } from "../networking/api-client"; @@ -9,61 +9,61 @@ interface CustomerAttributes { name?: string; } -export class CustomerAttributeManager extends Effect.Service()( - "rn-voidhash/CustomerAttributeManager", - { - dependencies: [CacheManager.Default], - effect: Effect.gen(function* effect() { - const cacheManager = yield* CacheManager; - const apiClient = yield* ApiClient; +const make = Effect.gen(function* effect() { + const cacheManager = yield* CacheManager; + const apiClient = yield* ApiClient; - const getCustomerAttributes = (appUserId: string) => - cacheManager - .get( - generateCustomerAttributesCacheKey(appUserId) - ) - .pipe(Effect.map((attributes) => attributes?.value ?? null)); + const getCustomerAttributes = (appUserId: string) => + cacheManager + .get( + generateCustomerAttributesCacheKey(appUserId) + ) + .pipe(Effect.map((attributes) => attributes?.value ?? null)); - const setCustomerAttributes = ( - appUserId: string, - attributes: CustomerAttributes - ) => - cacheManager.set( - generateCustomerAttributesCacheKey(appUserId), - attributes - ); + const setCustomerAttributes = ( + appUserId: string, + attributes: CustomerAttributes + ) => + cacheManager.set( + generateCustomerAttributesCacheKey(appUserId), + attributes + ); - const syncCustomerAttributes = (appUserId: string) => - Effect.gen(function* syncCustomerAttributes() { - let attributes = yield* getCustomerAttributes(appUserId); - if (!attributes) { - attributes = { - email: undefined, - name: undefined, - }; - yield* setCustomerAttributes(appUserId, attributes); - } - const commonHeaders = yield* getCommonSdkHeaders(); - yield* apiClient.sdk.syncCustomerAttributes({ - headers: { - ...commonHeaders, - "x-app-user-id": appUserId, - }, - payload: { - email: attributes.email, - name: attributes.name, - }, - }); - }); + const syncCustomerAttributes = (appUserId: string) => + Effect.gen(function* syncCustomerAttributes() { + let attributes = yield* getCustomerAttributes(appUserId); + if (!attributes) { + attributes = { + email: undefined, + name: undefined, + }; + yield* setCustomerAttributes(appUserId, attributes); + } + const commonHeaders = yield* getCommonSdkHeaders(); + yield* apiClient.sdk.syncCustomerAttributes({ + headers: { + ...commonHeaders, + "x-app-user-id": appUserId, + }, + payload: { + email: attributes.email, + name: attributes.name, + }, + }); + }); - const generateCustomerAttributesCacheKey = (appUserId: string) => - `customer-attributes:${appUserId}`; + const generateCustomerAttributesCacheKey = (appUserId: string) => + `customer-attributes:${appUserId}`; - return { - getCustomerAttributes, - setCustomerAttributes, - syncCustomerAttributes, - } as const; - }), - } -) {} + return { + getCustomerAttributes, + setCustomerAttributes, + syncCustomerAttributes, + } as const; +}); + +export class CustomerAttributeManager extends ServiceMap.Service>()("rn-voidhash/CustomerAttributeManager") { + static Default = Layer.effect(CustomerAttributeManager, make).pipe( + Layer.provide(CacheManager.Default) + ) +} diff --git a/libraries/react-native/src/core/identity/customer-info-manager.ts b/libraries/react-native/src/core/identity/customer-info-manager.ts index b9d9a1ba2..4549db98a 100644 --- a/libraries/react-native/src/core/identity/customer-info-manager.ts +++ b/libraries/react-native/src/core/identity/customer-info-manager.ts @@ -1,83 +1,83 @@ import type { SdkCustomer } from "@voidhash/api-spec"; -import { Effect } from "effect"; +import { Effect, Layer, ServiceMap } from "effect"; import { CacheManager } from "../caching/cache-manager"; import { EventBusProvider } from "../event-bus"; import { ApiClient } from "../networking/api-client"; import { getCommonSdkHeaders } from "../utils/get-common-sdk-headers"; -export class CustomerInfoManager extends Effect.Service()( - "rn-voidhash/CustomerInfoManager", - { - dependencies: [CacheManager.Default], - effect: Effect.gen(function* effect() { - const cacheManager = yield* CacheManager; - const apiClient = yield* ApiClient; - const eventBus = yield* EventBusProvider; +const make = Effect.gen(function* effect() { + const cacheManager = yield* CacheManager; + const apiClient = yield* ApiClient; + const eventBus = yield* EventBusProvider; - const generateCustomerCacheKey = (appUserId: string) => - `customer:${appUserId}`; + const generateCustomerCacheKey = (appUserId: string) => + `customer:${appUserId}`; - const getCustomerFromCache = (appUserId: string) => - cacheManager.get(generateCustomerCacheKey(appUserId)); + const getCustomerFromCache = (appUserId: string) => + cacheManager.get(generateCustomerCacheKey(appUserId)); - const cache = (appUserId: string, customer: SdkCustomer) => - cacheManager.set(generateCustomerCacheKey(appUserId), customer, { - ttl: 1000 * 60 * 60 * 24 * 2, // 2 days - staleTime: 1000 * 60 * 5, // 5 minutes - }); + const cache = (appUserId: string, customer: SdkCustomer) => + cacheManager.set(generateCustomerCacheKey(appUserId), customer, { + ttl: 1000 * 60 * 60 * 24 * 2, // 2 days + staleTime: 1000 * 60 * 5, // 5 minutes + }); - const resetCache = (appUserId: string) => - cacheManager.delete(generateCustomerCacheKey(appUserId)); + const resetCache = (appUserId: string) => + cacheManager.delete(generateCustomerCacheKey(appUserId)); - const getCustomerFromServerAndCache = (appUserId: string) => - Effect.gen(function* getCustomerFromServerAndCache() { - const commonHeaders = yield* getCommonSdkHeaders(); - const result = yield* apiClient.sdk.getCustomer({ - headers: { - ...commonHeaders, - "x-app-user-id": appUserId, - }, - }); - eventBus.emit("customer-fetched", result); - yield* cache(appUserId, result); - return result; - }); + const getCustomerFromServerAndCache = (appUserId: string) => + Effect.gen(function* getCustomerFromServerAndCache() { + const commonHeaders = yield* getCommonSdkHeaders(); + const result = yield* apiClient.sdk.getCustomer({ + headers: { + ...commonHeaders, + "x-app-user-id": appUserId, + }, + }); + eventBus.emit("customer-fetched", result); + yield* cache(appUserId, result); + return result; + }); - const getCustomer = ( - appUserId: string, - cachePolicy: "cache" | "fetch" | "fetch-while-stale" - ) => - Effect.gen(function* getCustomer() { - if (cachePolicy === "cache") { - const customerFromCache = yield* getCustomerFromCache(appUserId); - return customerFromCache?.value ?? null; - } + const getCustomer = ( + appUserId: string, + cachePolicy: "cache" | "fetch" | "fetch-while-stale" + ) => + Effect.gen(function* getCustomer() { + if (cachePolicy === "cache") { + const customerFromCache = yield* getCustomerFromCache(appUserId); + return customerFromCache?.value ?? null; + } - if (cachePolicy === "fetch") { - return yield* getCustomerFromServerAndCache(appUserId); - } + if (cachePolicy === "fetch") { + return yield* getCustomerFromServerAndCache(appUserId); + } - // fetch-while-stale policy - const customerFromCache = yield* getCustomerFromCache(appUserId); - if ( - customerFromCache && - !customerFromCache.isStale && - !customerFromCache.isExpired && - customerFromCache.value - ) { - return customerFromCache.value; - } + // fetch-while-stale policy + const customerFromCache = yield* getCustomerFromCache(appUserId); + if ( + customerFromCache && + !customerFromCache.isStale && + !customerFromCache.isExpired && + customerFromCache.value + ) { + return customerFromCache.value; + } - return yield* getCustomerFromServerAndCache(appUserId); - }); + return yield* getCustomerFromServerAndCache(appUserId); + }); - return { - cache, - getCustomer, - getCustomerFromCache, - resetCache, - } as const; - }), - } -) {} + return { + cache, + getCustomer, + getCustomerFromCache, + resetCache, + } as const; +}); + +export class CustomerInfoManager extends ServiceMap.Service>()("rn-voidhash/CustomerInfoManager") { + static Default = Layer.effect(CustomerInfoManager, make).pipe( + Layer.provide(CacheManager.Default) + ) +} diff --git a/libraries/react-native/src/core/identity/identity-manager.ts b/libraries/react-native/src/core/identity/identity-manager.ts index 59e42bb8c..f5e35c77b 100644 --- a/libraries/react-native/src/core/identity/identity-manager.ts +++ b/libraries/react-native/src/core/identity/identity-manager.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Layer, ServiceMap } from "effect"; import { ANONYMOUS_USER_ID_PREFIX } from "../../constants"; import { CacheManager } from "../caching/cache-manager"; @@ -10,106 +10,106 @@ import { CustomerInfoManager } from "./customer-info-manager"; const CACHE_KEY = "appUserId"; -export class IdentityManager extends Effect.Service()( - "rn-voidhash/IdentityManager", - { - dependencies: [ - CacheManager.Default, - CustomerAttributeManager.Default, - CustomerInfoManager.Default, - ], - effect: Effect.gen(function* effect() { - const cacheManager = yield* CacheManager; - const customerAttributeManager = yield* CustomerAttributeManager; - const customerInfoManager = yield* CustomerInfoManager; - const eventBus = yield* EventBusProvider; - const apiClient = yield* ApiClient; +const make = Effect.gen(function* effect() { + const cacheManager = yield* CacheManager; + const customerAttributeManager = yield* CustomerAttributeManager; + const customerInfoManager = yield* CustomerInfoManager; + const eventBus = yield* EventBusProvider; + const apiClient = yield* ApiClient; + + /** + * Returns the app user id. If no app user id is cached, a new anonymous user id is generated and cached. + * @returns The app user id. + */ + const getAppUserId = () => + Effect.gen(function* getAppUserId() { + const appUserId = yield* getAppUserIdFromCache(); + if (appUserId) { + yield* Effect.logDebug(`Using cached app user id: ${appUserId}`); + return appUserId; + } - /** - * Returns the app user id. If no app user id is cached, a new anonymous user id is generated and cached. - * @returns The app user id. - */ - const getAppUserId = () => - Effect.gen(function* getAppUserId() { - const appUserId = yield* getAppUserIdFromCache(); - if (appUserId) { - yield* Effect.logDebug(`Using cached app user id: ${appUserId}`); - return appUserId; - } + const anonymousUserId = generateAnonymousUserId(); + yield* setAppUserIdInCache(anonymousUserId); + return anonymousUserId; + }); - const anonymousUserId = generateAnonymousUserId(); - yield* setAppUserIdInCache(anonymousUserId); - return anonymousUserId; - }); + /** + * Identifies the customer. It makes a request to the server to identify the customer and caches the app user id. + * @param appUserId - The app user id. + * @param options - The options. + */ + const identify = ( + appUserId: string, + options: { + email?: string; + name?: string; + } + ) => + Effect.gen(function* identify() { + const currentAppUserId = yield* getAppUserId(); + yield* customerAttributeManager.syncCustomerAttributes( + currentAppUserId + ); + const commonHeaders = yield* getCommonSdkHeaders(); + const identifyRequest = yield* apiClient.sdk.identify({ + headers: { + ...commonHeaders, + "x-app-user-id": currentAppUserId, + }, + payload: { + appUserId, + email: options.email, + name: options.name, + }, + }); - /** - * Identifies the customer. It makes a request to the server to identify the customer and caches the app user id. - * @param appUserId - The app user id. - * @param options - The options. - */ - const identify = ( - appUserId: string, - options: { - email?: string; - name?: string; - } - ) => - Effect.gen(function* identify() { - const currentAppUserId = yield* getAppUserId(); - yield* customerAttributeManager.syncCustomerAttributes( - currentAppUserId - ); - const commonHeaders = yield* getCommonSdkHeaders(); - const identifyRequest = yield* apiClient.sdk.identify({ - headers: { - ...commonHeaders, - "x-app-user-id": currentAppUserId, - }, - payload: { - appUserId, - email: options.email, - name: options.name, - }, - }); + yield* Effect.all([ + setAppUserIdInCache(appUserId), + customerInfoManager.cache(appUserId, identifyRequest), + ]); - yield* Effect.all([ - setAppUserIdInCache(appUserId), - customerInfoManager.cache(appUserId, identifyRequest), - ]); + eventBus.emit("customer-identified"); + eventBus.emit("customer-fetched", { + ...identifyRequest, + appUserId, + }); + }); - eventBus.emit("customer-identified"); - eventBus.emit("customer-fetched", { - ...identifyRequest, - appUserId, - }); - }); + const signOut = () => + Effect.gen(function* signOut() { + const currentAppUserId = yield* getAppUserId(); + yield* customerAttributeManager.syncCustomerAttributes( + currentAppUserId + ); + yield* cacheManager.clear(); + eventBus.emit("customer-signed-out"); + }); - const signOut = () => - Effect.gen(function* signOut() { - const currentAppUserId = yield* getAppUserId(); - yield* customerAttributeManager.syncCustomerAttributes( - currentAppUserId - ); - yield* cacheManager.clear(); - eventBus.emit("customer-signed-out"); - }); + // Helpers + const generateAnonymousUserId = () => + `${ANONYMOUS_USER_ID_PREFIX}${Math.random().toString(36).slice(2, 15)}`; + const getAppUserIdFromCache = () => + cacheManager + .get(CACHE_KEY) + .pipe(Effect.map((appUserId) => appUserId?.value ?? null)); + const setAppUserIdInCache = (appUserId: string) => + cacheManager.set(CACHE_KEY, appUserId); - // Helpers - const generateAnonymousUserId = () => - `${ANONYMOUS_USER_ID_PREFIX}${Math.random().toString(36).slice(2, 15)}`; - const getAppUserIdFromCache = () => - cacheManager - .get(CACHE_KEY) - .pipe(Effect.map((appUserId) => appUserId?.value ?? null)); - const setAppUserIdInCache = (appUserId: string) => - cacheManager.set(CACHE_KEY, appUserId); + return { + getAppUserId, + getAppUserIdFromCache, + identify, + signOut, + } as const; +}); - return { - getAppUserId, - getAppUserIdFromCache, - identify, - signOut, - } as const; - }), - } -) {} +export class IdentityManager extends ServiceMap.Service>()("rn-voidhash/IdentityManager") { + static Default = Layer.effect(IdentityManager, make).pipe( + Layer.provide(Layer.mergeAll( + CacheManager.Default, + CustomerAttributeManager.Default, + CustomerInfoManager.Default, + )) + ) +} diff --git a/libraries/react-native/src/core/networking/api-client.ts b/libraries/react-native/src/core/networking/api-client.ts index 201cecbd0..d5e2e64d0 100644 --- a/libraries/react-native/src/core/networking/api-client.ts +++ b/libraries/react-native/src/core/networking/api-client.ts @@ -1,22 +1,20 @@ -import { HttpApiClient } from "@effect/platform"; import { VoidhashV1Api } from "@voidhash/api-spec"; -import { Effect } from "effect"; +import { Effect, Layer, ServiceMap } from "effect"; +import { HttpApiClient } from "effect/unstable/httpapi"; import { SdkConfiguration } from "../sdk-configuration"; import { withHttpDebugLogging } from "./http-debug-client"; -export class ApiClient extends Effect.Service()( - "rn-voidhash/ApiClient", - { - dependencies: [], - effect: Effect.gen(function* effect() { - const sdkConfiguration = yield* SdkConfiguration; - return yield* HttpApiClient.make(VoidhashV1Api, { - baseUrl: sdkConfiguration.baseUrl, - transformClient: sdkConfiguration.debug - ? withHttpDebugLogging - : undefined, - }); - }), - } -) {} +const make = Effect.gen(function* effect() { + const sdkConfiguration = yield* SdkConfiguration; + return yield* HttpApiClient.make(VoidhashV1Api, { + baseUrl: sdkConfiguration.baseUrl, + transformClient: sdkConfiguration.debug + ? withHttpDebugLogging + : undefined, + }); +}); + +export class ApiClient extends ServiceMap.Service>()("rn-voidhash/ApiClient") { + static Default = Layer.effect(ApiClient, make) +} diff --git a/libraries/react-native/src/core/networking/http-debug-client.ts b/libraries/react-native/src/core/networking/http-debug-client.ts index 80327b7d8..725521af9 100644 --- a/libraries/react-native/src/core/networking/http-debug-client.ts +++ b/libraries/react-native/src/core/networking/http-debug-client.ts @@ -1,5 +1,5 @@ -import { HttpClient } from "@effect/platform"; import { Console, Effect } from "effect"; +import { HttpClient } from "effect/unstable/http"; const MAX_BODY_PREVIEW_BYTES = 2_048; const MAX_VALUE_PREVIEW_CHARS = 1_024; diff --git a/libraries/react-native/src/core/payment-adapters/payment-adapter.ts b/libraries/react-native/src/core/payment-adapters/payment-adapter.ts index 91060be37..b93d4987d 100644 --- a/libraries/react-native/src/core/payment-adapters/payment-adapter.ts +++ b/libraries/react-native/src/core/payment-adapters/payment-adapter.ts @@ -1,4 +1,4 @@ -import { Context, type Effect } from "effect"; +import { ServiceMap, type Effect } from "effect"; import type { Product, SubscriptionProduct } from "../entities/product"; import type { Transaction } from "../entities/transaction"; @@ -22,9 +22,7 @@ import type { UserCancelledError, } from "./errors"; -export class PaymentAdapter extends Context.Tag("rn-voidhash/PaymentAdapter")< - PaymentAdapter, - { +export class PaymentAdapter extends ServiceMap.Service void ): Effect.Effect; @@ -82,8 +80,7 @@ export class PaymentAdapter extends Context.Tag("rn-voidhash/PaymentAdapter")< FailedToShowManageSubscriptionsError, never >; - } ->() {} + }>()("rn-voidhash/PaymentAdapter") {} // export interface PaymentAdapter { // initConnection( diff --git a/libraries/react-native/src/core/platform/platform-provider.ts b/libraries/react-native/src/core/platform/platform-provider.ts index 02764f139..270b83081 100644 --- a/libraries/react-native/src/core/platform/platform-provider.ts +++ b/libraries/react-native/src/core/platform/platform-provider.ts @@ -1,4 +1,4 @@ -import { Context } from "effect"; +import { ServiceMap } from "effect"; export interface PlatformInfo { appBuild: string | undefined; @@ -12,6 +12,4 @@ export interface PlatformInfo { isDebugBuild: boolean; platform: "ios" | "android" | "unknown"; } -export class PlatformProvider extends Context.Tag( - "rn-voidhash/PlatformProvider" -)() {} +export class PlatformProvider extends ServiceMap.Service()("rn-voidhash/PlatformProvider") {} diff --git a/libraries/react-native/src/core/sdk-configuration.ts b/libraries/react-native/src/core/sdk-configuration.ts index dd33a4eaf..7a5b6c591 100644 --- a/libraries/react-native/src/core/sdk-configuration.ts +++ b/libraries/react-native/src/core/sdk-configuration.ts @@ -1,14 +1,9 @@ -import { Context } from "effect"; +import { ServiceMap } from "effect"; -export class SdkConfiguration extends Context.Tag( - "rn-voidhash/SdkConfiguration" -)< - SdkConfiguration, - { +export class SdkConfiguration extends ServiceMap.Service() {} + }>()("rn-voidhash/SdkConfiguration") {} diff --git a/package.json b/package.json index daab11308..9bc9c8ff0 100644 --- a/package.json +++ b/package.json @@ -23,15 +23,11 @@ "@trpc/server": "^11.1.0", "@tanstack/react-query": "^5.90.21", "@trpc/tanstack-react-query": "^11.1.0", - "effect": "^3.19.14", - "@effect/platform-node": "^0.104.0", - "@effect/platform-bun": "^0.87.0", - "@effect/platform": "^0.94.1", - "@effect/cluster": "^0.56.1", - "@effect/cli": "^0.73.0", - "@effect/rpc": "^0.73.0", - "@effect/language-service": "^0.64.1", - "@effect/vitest": "^0.27.0", + "effect": "4.0.0-beta.23", + "@effect/platform-node": "4.0.0-beta.23", + "@effect/platform-bun": "4.0.0-beta.23", + "@effect/language-service": "^0.77.0", + "@effect/vitest": "4.0.0-beta.23", "better-auth": "^1.4.18" } }, @@ -63,6 +59,7 @@ }, "devDependencies": { "@biomejs/biome": "2.3.11", + "@typescript/native-preview": "7.0.0-dev.20260302.1", "dotenv-cli": "^8.0.0", "ultracite": "5.0.39" }, @@ -70,5 +67,10 @@ "react": "19.1.0", "react-dom": "19.1.0" }, + "pnpm": { + "overrides": { + "effect": "4.0.0-beta.23" + } + }, "packageManager": "pnpm@10.19.0" } diff --git a/packages/api-spec/package.json b/packages/api-spec/package.json index e2df3b4b9..10d685a93 100644 --- a/packages/api-spec/package.json +++ b/packages/api-spec/package.json @@ -26,7 +26,6 @@ "typescript": "5.6.3" }, "peerDependencies": { - "@effect/platform": "catalog:", "effect": "catalog:" } } diff --git a/packages/api-spec/src/api.ts b/packages/api-spec/src/api.ts index 48b4d95c0..446a7d7a8 100644 --- a/packages/api-spec/src/api.ts +++ b/packages/api-spec/src/api.ts @@ -1,5 +1,5 @@ -import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "@effect/platform"; import { Schema } from "effect"; +import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { ActionForbiddenError, @@ -32,26 +32,21 @@ import { import { AuthMiddleware } from "./middlewares"; import { ApiKey, - ApiKeyIdParam, ApiKeyWithRawKey, - AppUserIdParam, CreateCustomerBody, CreateOrganizationBody, CreateProjectBody, CreateSecretKeyBody, CreateWebhookEndpointBody, Customer, - CustomerIdParam, DeployChangesetBody, DeployChangesetResponse, Organization, - OrganizationIdParam, PaymentProviderConfiguration, PaymentProviderProduct, PaywallLocation, Perk, Product, - ProductIdParam, ProductPerk, Project, EvaluateFeatureFlagsBody, @@ -68,57 +63,55 @@ import { UpdateWebhookEndpointBody, User, WebhookDelivery, - WebhookDeliveryIdParam, WebhookDeliveryWithAttempts, WebhookEndpoint, - WebhookEndpointIdParam, } from "./schema"; export const VoidhashV1Api = HttpApi.make("VoidhashV1Api") .add( HttpApiGroup.make("auth") .add( - HttpApiEndpoint.get("session")`/session` - .addSuccess(Session) - .addError(ActionForbiddenError) - .middleware(AuthMiddleware) + HttpApiEndpoint.get("session", "/session", { + success: Session, + error: [ActionForbiddenError], + }).middleware(AuthMiddleware) ) .prefix("/auth") ) .add( HttpApiGroup.make("api_keys") .add( - HttpApiEndpoint.post("createSecretKey")`/` - .addSuccess(ApiKeyWithRawKey) - .setPayload(CreateSecretKeyBody) - .addError(ApiKeyServiceError) - .addError(ActionForbiddenError) + HttpApiEndpoint.post("createSecretKey", "/", { + success: ApiKeyWithRawKey, + payload: CreateSecretKeyBody, + error: [ApiKeyServiceError, ActionForbiddenError], + }) ) .add( - HttpApiEndpoint.get("listApiKeys")`/` - .addSuccess(Schema.Array(ApiKey)) - .addError(ApiKeyServiceError) - .addError(ActionForbiddenError) + HttpApiEndpoint.get("listApiKeys", "/", { + success: Schema.Array(ApiKey), + error: [ApiKeyServiceError, ActionForbiddenError], + }) ) .add( - HttpApiEndpoint.get("getApiKeyById")`/${ApiKeyIdParam}` - .addSuccess(ApiKey) - .addError(ApiKeyServiceError) - .addError(ApiKeyNotFoundError) - .addError(ActionForbiddenError) + HttpApiEndpoint.get("getApiKeyById", "/:apiKeyId", { + params: { apiKeyId: Schema.String }, + success: ApiKey, + error: [ApiKeyServiceError, ApiKeyNotFoundError, ActionForbiddenError], + }) ) .add( - HttpApiEndpoint.post("rotateSecretKey")`/${ApiKeyIdParam}/rotate` - .addSuccess(ApiKeyWithRawKey) - .addError(ApiKeyServiceError) - .addError(ApiKeyNotFoundError) - .addError(ActionForbiddenError) + HttpApiEndpoint.post("rotateSecretKey", "/:apiKeyId/rotate", { + params: { apiKeyId: Schema.String }, + success: ApiKeyWithRawKey, + error: [ApiKeyServiceError, ApiKeyNotFoundError, ActionForbiddenError], + }) ) .add( - HttpApiEndpoint.del("deleteApiKey")`/${ApiKeyIdParam}` - .addError(ApiKeyServiceError) - .addError(ApiKeyNotFoundError) - .addError(ActionForbiddenError) + HttpApiEndpoint.delete("deleteApiKey", "/:apiKeyId", { + params: { apiKeyId: Schema.String }, + error: [ApiKeyServiceError, ApiKeyNotFoundError, ActionForbiddenError], + }) ) .middleware(AuthMiddleware) .prefix("/api-keys") @@ -126,32 +119,31 @@ export const VoidhashV1Api = HttpApi.make("VoidhashV1Api") .add( HttpApiGroup.make("customers") .add( - HttpApiEndpoint.post("createCustomer")`/` - .setPayload(CreateCustomerBody) - .addSuccess(Customer) - .addError(ActionForbiddenError) - .addError(CustomerInvalidAnonymousIdError) - .addError(CustomerServiceError) + HttpApiEndpoint.post("createCustomer", "/", { + payload: CreateCustomerBody, + success: Customer, + error: [ActionForbiddenError, CustomerInvalidAnonymousIdError, CustomerServiceError], + }) ) .add( - HttpApiEndpoint.get("listCustomers")`/` - .addSuccess(Schema.Array(Customer)) - .addError(ActionForbiddenError) - .addError(CustomerServiceError) + HttpApiEndpoint.get("listCustomers", "/", { + success: Schema.Array(Customer), + error: [ActionForbiddenError, CustomerServiceError], + }) ) .add( - HttpApiEndpoint.get("getCustomerById")`/${CustomerIdParam}` - .addSuccess(Customer) - .addError(ActionForbiddenError) - .addError(CustomerNotFoundError) - .addError(CustomerServiceError) + HttpApiEndpoint.get("getCustomerById", "/:customerId", { + params: { customerId: Schema.String }, + success: Customer, + error: [ActionForbiddenError, CustomerNotFoundError, CustomerServiceError], + }) ) .add( - HttpApiEndpoint.get("byAppUserId")`/by-app-user-id/${AppUserIdParam}` - .addSuccess(Customer) - .addError(ActionForbiddenError) - .addError(CustomerNotFoundError) - .addError(CustomerServiceError) + HttpApiEndpoint.get("byAppUserId", "/by-app-user-id/:appUserId", { + params: { appUserId: Schema.String }, + success: Customer, + error: [ActionForbiddenError, CustomerNotFoundError, CustomerServiceError], + }) ) .middleware(AuthMiddleware) .prefix("/customers") @@ -159,10 +151,11 @@ export const VoidhashV1Api = HttpApi.make("VoidhashV1Api") .add( HttpApiGroup.make("organizations") .add( - HttpApiEndpoint.post("createOrganization")`/` - .setPayload(CreateOrganizationBody) - .addSuccess(Organization) - .addError(OrganizationServiceError) + HttpApiEndpoint.post("createOrganization", "/", { + payload: CreateOrganizationBody, + success: Organization, + error: [OrganizationServiceError], + }) ) .middleware(AuthMiddleware) .prefix("/organizations") @@ -170,10 +163,10 @@ export const VoidhashV1Api = HttpApi.make("VoidhashV1Api") .add( HttpApiGroup.make("perks") .add( - HttpApiEndpoint.get("listPerks")`/` - .addSuccess(Schema.Array(Perk)) - .addError(ActionForbiddenError) - .addError(PerkServiceError) + HttpApiEndpoint.get("listPerks", "/", { + success: Schema.Array(Perk), + error: [ActionForbiddenError, PerkServiceError], + }) ) .middleware(AuthMiddleware) .prefix("/perks") @@ -181,10 +174,10 @@ export const VoidhashV1Api = HttpApi.make("VoidhashV1Api") .add( HttpApiGroup.make("paywall_locations") .add( - HttpApiEndpoint.get("listPaywallLocations")`/` - .addSuccess(Schema.Array(PaywallLocation)) - .addError(ActionForbiddenError) - .addError(PaywallLocationServiceError) + HttpApiEndpoint.get("listPaywallLocations", "/", { + success: Schema.Array(PaywallLocation), + error: [ActionForbiddenError, PaywallLocationServiceError], + }) ) .middleware(AuthMiddleware) .prefix("/paywall-locations") @@ -192,18 +185,18 @@ export const VoidhashV1Api = HttpApi.make("VoidhashV1Api") .add( HttpApiGroup.make("projects") .add( - HttpApiEndpoint.post("createProject")`/` - .setPayload(CreateProjectBody) - .addSuccess(Project) - .addError(ActionForbiddenError) - .addError(AuthenticationError) - .addError(ProjectServiceError) + HttpApiEndpoint.post("createProject", "/", { + payload: CreateProjectBody, + success: Project, + error: [ActionForbiddenError, AuthenticationError, ProjectServiceError], + }) ) .add( - HttpApiEndpoint.get("listProjects")`/${OrganizationIdParam}` - .addSuccess(Schema.Array(Project)) - .addError(ActionForbiddenError) - .addError(ProjectServiceError) + HttpApiEndpoint.get("listProjects", "/:organizationId", { + params: { organizationId: Schema.String }, + success: Schema.Array(Project), + error: [ActionForbiddenError, ProjectServiceError], + }) ) .middleware(AuthMiddleware) .prefix("/projects") @@ -211,10 +204,10 @@ export const VoidhashV1Api = HttpApi.make("VoidhashV1Api") .add( HttpApiGroup.make("products") .add( - HttpApiEndpoint.get("listProducts")`/` - .addSuccess(Schema.Array(Product)) - .addError(ActionForbiddenError) - .addError(ProductServiceError) + HttpApiEndpoint.get("listProducts", "/", { + success: Schema.Array(Product), + error: [ActionForbiddenError, ProductServiceError], + }) ) .middleware(AuthMiddleware) .prefix("/products") @@ -222,13 +215,11 @@ export const VoidhashV1Api = HttpApi.make("VoidhashV1Api") .add( HttpApiGroup.make("product_perks") .add( - HttpApiEndpoint.get( - "listProductPerksByProductId" - )`/by-product-id/${ProductIdParam}` - .addSuccess(Schema.Array(ProductPerk)) - .addError(ActionForbiddenError) - .addError(ProductPerkServiceError) - .addError(ProductPerkValidationError) + HttpApiEndpoint.get("listProductPerksByProductId", "/by-product-id/:productId", { + params: { productId: Schema.String }, + success: Schema.Array(ProductPerk), + error: [ActionForbiddenError, ProductPerkServiceError, ProductPerkValidationError], + }) ) .middleware(AuthMiddleware) .prefix("/product-perks") @@ -236,60 +227,51 @@ export const VoidhashV1Api = HttpApi.make("VoidhashV1Api") .add( HttpApiGroup.make("sdk") .add( - HttpApiEndpoint.get("getCustomer")`/get-customer` - .addSuccess(SdkCustomer) - .setHeaders(SdkHeaders) - .addError(AuthenticationError) - .addError(SdkServiceError) - .addError(SdkCustomerNotFoundError) - .addError(SdkValidationError) - ) - .add( - HttpApiEndpoint.post("identify")`/identify` - .setPayload(SdkIdentifyBody) - .addSuccess(SdkCustomer) - .setHeaders(SdkHeaders) - .addError(AuthenticationError) - .addError(SdkServiceError) - .addError(SdkValidationError) - .addError(SdkCustomerAlreadyIdentifiedError) - ) - .add( - HttpApiEndpoint.post( - "syncCustomerAttributes" - )`/sync-customer-attributes` - .setPayload(SdkSyncCustomerAttributesBody) - .addSuccess(SdkCustomer) - .setHeaders(SdkHeaders) - .addError(AuthenticationError) - .addError(SdkServiceError) - .addError(SdkValidationError) - ) - .add( - HttpApiEndpoint.post("syncTransaction")`/sync-transaction` - .setPayload(SdkSyncTransactionBody) - .addSuccess(SdkSyncTransactionResponse) - .setHeaders(SdkHeaders) - .addError(AuthenticationError) - .addError(SdkServiceError) - .addError(SdkValidationError) - ) - .add( - HttpApiEndpoint.post("evaluateFeatureFlags")`/evaluate-flags` - .setPayload(EvaluateFeatureFlagsBody) - .addSuccess(SdkFeatureFlagsResponse) - .setHeaders(SdkHeaders) - .addError(AuthenticationError) - .addError(SdkServiceError) - ) - .add( - HttpApiEndpoint.post("resolvePaywall")`/resolve-paywall` - .setPayload(SdkResolvePaywallBody) - .addSuccess(Schema.NullOr(SdkResolvedPaywall)) - .setHeaders(SdkHeaders) - .addError(AuthenticationError) - .addError(SdkServiceError) - .addError(SdkValidationError) + HttpApiEndpoint.get("getCustomer", "/get-customer", { + success: SdkCustomer, + headers: SdkHeaders, + error: [AuthenticationError, SdkServiceError, SdkCustomerNotFoundError, SdkValidationError], + }) + ) + .add( + HttpApiEndpoint.post("identify", "/identify", { + payload: SdkIdentifyBody, + success: SdkCustomer, + headers: SdkHeaders, + error: [AuthenticationError, SdkServiceError, SdkValidationError, SdkCustomerAlreadyIdentifiedError], + }) + ) + .add( + HttpApiEndpoint.post("syncCustomerAttributes", "/sync-customer-attributes", { + payload: SdkSyncCustomerAttributesBody, + success: SdkCustomer, + headers: SdkHeaders, + error: [AuthenticationError, SdkServiceError, SdkValidationError], + }) + ) + .add( + HttpApiEndpoint.post("syncTransaction", "/sync-transaction", { + payload: SdkSyncTransactionBody, + success: SdkSyncTransactionResponse, + headers: SdkHeaders, + error: [AuthenticationError, SdkServiceError, SdkValidationError], + }) + ) + .add( + HttpApiEndpoint.post("evaluateFeatureFlags", "/evaluate-flags", { + payload: EvaluateFeatureFlagsBody, + success: SdkFeatureFlagsResponse, + headers: SdkHeaders, + error: [AuthenticationError, SdkServiceError], + }) + ) + .add( + HttpApiEndpoint.post("resolvePaywall", "/resolve-paywall", { + payload: SdkResolvePaywallBody, + success: Schema.NullOr(SdkResolvedPaywall), + headers: SdkHeaders, + error: [AuthenticationError, SdkServiceError, SdkValidationError], + }) ) .middleware(AuthMiddleware) .prefix("/sdk") @@ -297,10 +279,10 @@ export const VoidhashV1Api = HttpApi.make("VoidhashV1Api") .add( HttpApiGroup.make("users") .add( - HttpApiEndpoint.get("getUser")`/current` - .addSuccess(User) - .addError(AuthenticationError) - .addError(UserServiceError) + HttpApiEndpoint.get("getUser", "/current", { + success: User, + error: [AuthenticationError, UserServiceError], + }) ) .middleware(AuthMiddleware) .prefix("/users") @@ -308,10 +290,10 @@ export const VoidhashV1Api = HttpApi.make("VoidhashV1Api") .add( HttpApiGroup.make("payment_provider_configurations") .add( - HttpApiEndpoint.get("listPaymentProviderConfigurations")`/` - .addSuccess(Schema.Array(PaymentProviderConfiguration)) - .addError(ActionForbiddenError) - .addError(PaymentProviderConfigurationServiceError) + HttpApiEndpoint.get("listPaymentProviderConfigurations", "/", { + success: Schema.Array(PaymentProviderConfiguration), + error: [ActionForbiddenError, PaymentProviderConfigurationServiceError], + }) ) .middleware(AuthMiddleware) .prefix("/payment-provider-configurations") @@ -319,10 +301,10 @@ export const VoidhashV1Api = HttpApi.make("VoidhashV1Api") .add( HttpApiGroup.make("payment_provider_products") .add( - HttpApiEndpoint.get("listPaymentProviderProducts")`/` - .addSuccess(Schema.Array(PaymentProviderProduct)) - .addError(ActionForbiddenError) - .addError(PaymentProviderProductServiceError) + HttpApiEndpoint.get("listPaymentProviderProducts", "/", { + success: Schema.Array(PaymentProviderProduct), + error: [ActionForbiddenError, PaymentProviderProductServiceError], + }) ) .middleware(AuthMiddleware) .prefix("/payment-provider-products") @@ -330,12 +312,11 @@ export const VoidhashV1Api = HttpApi.make("VoidhashV1Api") .add( HttpApiGroup.make("changesets") .add( - HttpApiEndpoint.post("deployChangeset")`/deploy` - .setPayload(DeployChangesetBody) - .addSuccess(DeployChangesetResponse) - .addError(AuthenticationError) - .addError(ActionForbiddenError) - .addError(ChangesetDeploymentServiceError) + HttpApiEndpoint.post("deployChangeset", "/deploy", { + payload: DeployChangesetBody, + success: DeployChangesetResponse, + error: [AuthenticationError, ActionForbiddenError, ChangesetDeploymentServiceError], + }) ) .middleware(AuthMiddleware) .prefix("/changesets") @@ -343,75 +324,72 @@ export const VoidhashV1Api = HttpApi.make("VoidhashV1Api") .add( HttpApiGroup.make("webhooks") .add( - HttpApiEndpoint.post("createWebhookEndpoint")`/endpoints` - .setPayload(CreateWebhookEndpointBody) - .addSuccess(WebhookEndpoint) - .addError(ActionForbiddenError) - .addError(WebhookValidationError) - .addError(WebhookServiceError) + HttpApiEndpoint.post("createWebhookEndpoint", "/endpoints", { + payload: CreateWebhookEndpointBody, + success: WebhookEndpoint, + error: [ActionForbiddenError, WebhookValidationError, WebhookServiceError], + }) ) .add( - HttpApiEndpoint.get("listWebhookEndpoints")`/endpoints` - .addSuccess(Schema.Array(WebhookEndpoint)) - .addError(ActionForbiddenError) - .addError(WebhookServiceError) + HttpApiEndpoint.get("listWebhookEndpoints", "/endpoints", { + success: Schema.Array(WebhookEndpoint), + error: [ActionForbiddenError, WebhookServiceError], + }) ) .add( - HttpApiEndpoint.get("getWebhookEndpoint")`/endpoints/${WebhookEndpointIdParam}` - .addSuccess(WebhookEndpoint) - .addError(ActionForbiddenError) - .addError(WebhookEndpointNotFoundError) - .addError(WebhookServiceError) + HttpApiEndpoint.get("getWebhookEndpoint", "/endpoints/:endpointId", { + params: { endpointId: Schema.String }, + success: WebhookEndpoint, + error: [ActionForbiddenError, WebhookEndpointNotFoundError, WebhookServiceError], + }) ) .add( - HttpApiEndpoint.patch("updateWebhookEndpoint")`/endpoints/${WebhookEndpointIdParam}` - .setPayload(UpdateWebhookEndpointBody) - .addSuccess(WebhookEndpoint) - .addError(ActionForbiddenError) - .addError(WebhookEndpointNotFoundError) - .addError(WebhookValidationError) - .addError(WebhookServiceError) + HttpApiEndpoint.patch("updateWebhookEndpoint", "/endpoints/:endpointId", { + params: { endpointId: Schema.String }, + payload: UpdateWebhookEndpointBody, + success: WebhookEndpoint, + error: [ActionForbiddenError, WebhookEndpointNotFoundError, WebhookValidationError, WebhookServiceError], + }) ) .add( - HttpApiEndpoint.del("deleteWebhookEndpoint")`/endpoints/${WebhookEndpointIdParam}` - .addError(ActionForbiddenError) - .addError(WebhookEndpointNotFoundError) - .addError(WebhookServiceError) + HttpApiEndpoint.delete("deleteWebhookEndpoint", "/endpoints/:endpointId", { + params: { endpointId: Schema.String }, + error: [ActionForbiddenError, WebhookEndpointNotFoundError, WebhookServiceError], + }) ) .add( - HttpApiEndpoint.post("rotateWebhookSecret")`/endpoints/${WebhookEndpointIdParam}/rotate-secret` - .addSuccess(WebhookEndpoint) - .addError(ActionForbiddenError) - .addError(WebhookEndpointNotFoundError) - .addError(WebhookServiceError) + HttpApiEndpoint.post("rotateWebhookSecret", "/endpoints/:endpointId/rotate-secret", { + params: { endpointId: Schema.String }, + success: WebhookEndpoint, + error: [ActionForbiddenError, WebhookEndpointNotFoundError, WebhookServiceError], + }) ) .add( - HttpApiEndpoint.post("testWebhookEndpoint")`/endpoints/${WebhookEndpointIdParam}/test` - .addSuccess(WebhookDelivery) - .addError(ActionForbiddenError) - .addError(WebhookEndpointNotFoundError) - .addError(WebhookServiceError) + HttpApiEndpoint.post("testWebhookEndpoint", "/endpoints/:endpointId/test", { + params: { endpointId: Schema.String }, + success: WebhookDelivery, + error: [ActionForbiddenError, WebhookEndpointNotFoundError, WebhookServiceError], + }) ) .add( - HttpApiEndpoint.get("listWebhookDeliveries")`/deliveries` - .addSuccess(Schema.Array(WebhookDelivery)) - .addError(ActionForbiddenError) - .addError(WebhookServiceError) + HttpApiEndpoint.get("listWebhookDeliveries", "/deliveries", { + success: Schema.Array(WebhookDelivery), + error: [ActionForbiddenError, WebhookServiceError], + }) ) .add( - HttpApiEndpoint.get("getWebhookDelivery")`/deliveries/${WebhookDeliveryIdParam}` - .addSuccess(WebhookDeliveryWithAttempts) - .addError(ActionForbiddenError) - .addError(WebhookDeliveryNotFoundError) - .addError(WebhookServiceError) + HttpApiEndpoint.get("getWebhookDelivery", "/deliveries/:deliveryId", { + params: { deliveryId: Schema.String }, + success: WebhookDeliveryWithAttempts, + error: [ActionForbiddenError, WebhookDeliveryNotFoundError, WebhookServiceError], + }) ) .add( - HttpApiEndpoint.post("retryWebhookDelivery")`/deliveries/${WebhookDeliveryIdParam}/retry` - .addSuccess(WebhookDelivery) - .addError(ActionForbiddenError) - .addError(WebhookDeliveryNotFoundError) - .addError(WebhookValidationError) - .addError(WebhookServiceError) + HttpApiEndpoint.post("retryWebhookDelivery", "/deliveries/:deliveryId/retry", { + params: { deliveryId: Schema.String }, + success: WebhookDelivery, + error: [ActionForbiddenError, WebhookDeliveryNotFoundError, WebhookValidationError, WebhookServiceError], + }) ) .middleware(AuthMiddleware) .prefix("/webhooks") diff --git a/packages/api-spec/src/auth.ts b/packages/api-spec/src/auth.ts index 85fe8433e..8ad068bc0 100644 --- a/packages/api-spec/src/auth.ts +++ b/packages/api-spec/src/auth.ts @@ -1,4 +1,4 @@ -import { Context, Schema } from "effect"; +import { Schema, ServiceMap } from "effect"; // ============================================================================ // Session Sub-Schemas @@ -74,11 +74,11 @@ export const ApiPublishableKeySessionSchema = Schema.Struct({ // Combined Auth Session Schema // ============================================================================ -export const ApiAuthSessionSchema = Schema.Union( +export const ApiAuthSessionSchema = Schema.Union([ ApiUserSessionSchema, ApiSecretKeySessionSchema, - ApiPublishableKeySessionSchema -); + ApiPublishableKeySessionSchema, +]); // ============================================================================ // Type Exports @@ -96,7 +96,4 @@ export type AnyApiAuthSession = // Context Tag // ============================================================================ -export class ApiAuthSession extends Context.Tag("api-spec/auth/ApiAuthSession")< - ApiAuthSession, - ApiUserSession | ApiSecretKeySession | ApiPublishableKeySession ->() {} +export class ApiAuthSession extends ServiceMap.Service()("api-spec/auth/ApiAuthSession") {} diff --git a/packages/api-spec/src/changeset.ts b/packages/api-spec/src/changeset.ts index 15bec1273..28972b21e 100644 --- a/packages/api-spec/src/changeset.ts +++ b/packages/api-spec/src/changeset.ts @@ -122,7 +122,7 @@ export const PaymentProviderProductCreateChangeSchema = Schema.Struct({ changeType: Schema.Literal("create-payment-provider-product"), key: Schema.String, payload: Schema.Struct({ - configuration: Schema.Record({ key: Schema.String, value: Schema.Unknown }), + configuration: Schema.Record(Schema.String, Schema.Unknown), productSlug: Schema.String, providerId: Schema.String, }), @@ -132,7 +132,7 @@ export const PaymentProviderProductUpdateChangeSchema = Schema.Struct({ changeType: Schema.Literal("update-payment-provider-product"), key: Schema.String, payload: Schema.Struct({ - configuration: Schema.Record({ key: Schema.String, value: Schema.Unknown }), + configuration: Schema.Record(Schema.String, Schema.Unknown), productSlug: Schema.String, providerId: Schema.String, }), @@ -151,7 +151,7 @@ export const PaymentProviderProductDeleteChangeSchema = Schema.Struct({ // Combined Schemas // ============================================================================ -export const ChangeSchema = Schema.Union( +export const ChangeSchema = Schema.Union([ PaywallLocationCreateChangeSchema, PaywallLocationUpdateChangeSchema, PaywallLocationArchiveChangeSchema, @@ -165,8 +165,8 @@ export const ChangeSchema = Schema.Union( ProductPerkDeleteChangeSchema, PaymentProviderProductCreateChangeSchema, PaymentProviderProductUpdateChangeSchema, - PaymentProviderProductDeleteChangeSchema -); + PaymentProviderProductDeleteChangeSchema, +]); export const ChangesetSchema = Schema.Struct({ changes: Schema.Array(ChangeSchema), diff --git a/packages/api-spec/src/errors/admin.ts b/packages/api-spec/src/errors/admin.ts index 57afb04a2..8f266a42a 100644 --- a/packages/api-spec/src/errors/admin.ts +++ b/packages/api-spec/src/errors/admin.ts @@ -1,11 +1,10 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Generic admin service error */ -export class AdminServiceError extends Schema.TaggedError()( +export class AdminServiceError extends Schema.TaggedErrorClass()( "AdminServiceError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} diff --git a/packages/api-spec/src/errors/analytics.ts b/packages/api-spec/src/errors/analytics.ts index d0832464c..c860289b7 100644 --- a/packages/api-spec/src/errors/analytics.ts +++ b/packages/api-spec/src/errors/analytics.ts @@ -1,29 +1,28 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Generic analytics service error */ -export class AnalyticsServiceError extends Schema.TaggedError()( +export class AnalyticsServiceError extends Schema.TaggedErrorClass()( "AnalyticsServiceError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} /** Invalid time range error */ -export class InvalidTimeRangeError extends Schema.TaggedError()( +export class InvalidTimeRangeError extends Schema.TaggedErrorClass()( "InvalidTimeRangeError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 400 }) + { httpApiStatus: 400 } ) {} /** Invalid metric error */ -export class InvalidMetricError extends Schema.TaggedError()( +export class InvalidMetricError extends Schema.TaggedErrorClass()( "InvalidMetricError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 400 }) + { httpApiStatus: 400 } ) {} diff --git a/packages/api-spec/src/errors/api-key.ts b/packages/api-spec/src/errors/api-key.ts index c4b6d9ccb..1782b4200 100644 --- a/packages/api-spec/src/errors/api-key.ts +++ b/packages/api-spec/src/errors/api-key.ts @@ -1,20 +1,19 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Generic API key service error */ -export class ApiKeyServiceError extends Schema.TaggedError()( +export class ApiKeyServiceError extends Schema.TaggedErrorClass()( "ApiKeyServiceError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} /** API key not found */ -export class ApiKeyNotFoundError extends Schema.TaggedError()( +export class ApiKeyNotFoundError extends Schema.TaggedErrorClass()( "ApiKeyNotFoundError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 404 }) + { httpApiStatus: 404 } ) {} diff --git a/packages/api-spec/src/errors/billing.ts b/packages/api-spec/src/errors/billing.ts index 036fb741e..71de5cbda 100644 --- a/packages/api-spec/src/errors/billing.ts +++ b/packages/api-spec/src/errors/billing.ts @@ -1,29 +1,28 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Generic billing service error */ -export class BillingServiceError extends Schema.TaggedError()( +export class BillingServiceError extends Schema.TaggedErrorClass()( "BillingServiceError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} /** Organization billing not found */ -export class OrganizationBillingNotFoundError extends Schema.TaggedError()( +export class OrganizationBillingNotFoundError extends Schema.TaggedErrorClass()( "OrganizationBillingNotFoundError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 404 }) + { httpApiStatus: 404 } ) {} /** Invalid billing tier error */ -export class InvalidBillingTierError extends Schema.TaggedError()( +export class InvalidBillingTierError extends Schema.TaggedErrorClass()( "InvalidBillingTierError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 400 }) + { httpApiStatus: 400 } ) {} diff --git a/packages/api-spec/src/errors/changeset.ts b/packages/api-spec/src/errors/changeset.ts index 8fc1903dd..ec7e8a101 100644 --- a/packages/api-spec/src/errors/changeset.ts +++ b/packages/api-spec/src/errors/changeset.ts @@ -1,11 +1,10 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Generic changeset deployment service error */ -export class ChangesetDeploymentServiceError extends Schema.TaggedError()( +export class ChangesetDeploymentServiceError extends Schema.TaggedErrorClass()( "ChangesetDeploymentServiceError", { cause: Schema.Unknown, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} diff --git a/packages/api-spec/src/errors/common.ts b/packages/api-spec/src/errors/common.ts index fbff86995..0de329ce6 100644 --- a/packages/api-spec/src/errors/common.ts +++ b/packages/api-spec/src/errors/common.ts @@ -1,30 +1,29 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Action is forbidden due to insufficient permissions */ -export class ActionForbiddenError extends Schema.TaggedError()( +export class ActionForbiddenError extends Schema.TaggedErrorClass()( "ActionForbiddenError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 403 }) + { httpApiStatus: 403 } ) {} /** Authentication failed */ -export class AuthenticationError extends Schema.TaggedError()( +export class AuthenticationError extends Schema.TaggedErrorClass()( "AuthenticationError", { cause: Schema.String, message: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} /** User is not authenticated */ -export class NotAuthenticatedError extends Schema.TaggedError()( +export class NotAuthenticatedError extends Schema.TaggedErrorClass()( "NotAuthenticatedError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 401 }) + { httpApiStatus: 401 } ) {} diff --git a/packages/api-spec/src/errors/customer.ts b/packages/api-spec/src/errors/customer.ts index cf25fedea..113f31f8c 100644 --- a/packages/api-spec/src/errors/customer.ts +++ b/packages/api-spec/src/errors/customer.ts @@ -1,22 +1,21 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Generic customer service error */ -export class CustomerServiceError extends Schema.TaggedError()( +export class CustomerServiceError extends Schema.TaggedErrorClass()( "CustomerServiceError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} /** Customer not found */ -export class CustomerNotFoundError extends Schema.TaggedError()( +export class CustomerNotFoundError extends Schema.TaggedErrorClass()( "CustomerNotFoundError", { id: Schema.NonEmptyString, }, - HttpApiSchema.annotations({ status: 404 }) + { httpApiStatus: 404 } ) { toString(): string { return `The following customer not found: ${this.id}`; @@ -24,12 +23,12 @@ export class CustomerNotFoundError extends Schema.TaggedError()( +export class CustomerInvalidAnonymousIdError extends Schema.TaggedErrorClass()( "CustomerInvalidAnonymousIdError", { id: Schema.NonEmptyString, }, - HttpApiSchema.annotations({ status: 400 }) + { httpApiStatus: 400 } ) { toString(): string { return `The following anonymous ID is invalid: ${this.id}`; diff --git a/packages/api-spec/src/errors/organization.ts b/packages/api-spec/src/errors/organization.ts index e67c6559f..4ae8760f5 100644 --- a/packages/api-spec/src/errors/organization.ts +++ b/packages/api-spec/src/errors/organization.ts @@ -1,20 +1,19 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Generic organization service error */ -export class OrganizationServiceError extends Schema.TaggedError()( +export class OrganizationServiceError extends Schema.TaggedErrorClass()( "OrganizationServiceError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} /** Organization not found */ -export class OrganizationNotFoundError extends Schema.TaggedError()( +export class OrganizationNotFoundError extends Schema.TaggedErrorClass()( "OrganizationNotFoundError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 404 }) + { httpApiStatus: 404 } ) {} diff --git a/packages/api-spec/src/errors/payment-provider.ts b/packages/api-spec/src/errors/payment-provider.ts index a1979df3f..cbf0c597b 100644 --- a/packages/api-spec/src/errors/payment-provider.ts +++ b/packages/api-spec/src/errors/payment-provider.ts @@ -1,74 +1,73 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Generic payment provider configuration service error */ -export class PaymentProviderConfigurationServiceError extends Schema.TaggedError()( +export class PaymentProviderConfigurationServiceError extends Schema.TaggedErrorClass()( "PaymentProviderConfigurationServiceError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} /** Payment provider configuration not found */ -export class PaymentProviderConfigurationNotFoundError extends Schema.TaggedError()( +export class PaymentProviderConfigurationNotFoundError extends Schema.TaggedErrorClass()( "PaymentProviderConfigurationNotFoundError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 404 }) + { httpApiStatus: 404 } ) {} /** Payment provider configuration validation error */ -export class PaymentProviderConfigurationValidationError extends Schema.TaggedError()( +export class PaymentProviderConfigurationValidationError extends Schema.TaggedErrorClass()( "PaymentProviderConfigurationValidationError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 400 }) + { httpApiStatus: 400 } ) {} /** Payment provider configuration key unavailable */ -export class PaymentProviderConfigurationKeyUnavailableError extends Schema.TaggedError()( +export class PaymentProviderConfigurationKeyUnavailableError extends Schema.TaggedErrorClass()( "PaymentProviderConfigurationKeyUnavailableError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 400 }) + { httpApiStatus: 400 } ) {} /** Payment provider already exists */ -export class PaymentProviderAlreadyExistsError extends Schema.TaggedError()( +export class PaymentProviderAlreadyExistsError extends Schema.TaggedErrorClass()( "PaymentProviderAlreadyExistsError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 409 }) + { httpApiStatus: 409 } ) {} /** Generic payment provider product service error */ -export class PaymentProviderProductServiceError extends Schema.TaggedError()( +export class PaymentProviderProductServiceError extends Schema.TaggedErrorClass()( "PaymentProviderProductServiceError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} /** Payment provider product validation error */ -export class PaymentProviderProductValidationError extends Schema.TaggedError()( +export class PaymentProviderProductValidationError extends Schema.TaggedErrorClass()( "PaymentProviderProductValidationError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 400 }) + { httpApiStatus: 400 } ) {} /** Payment provider product not found */ -export class PaymentProviderProductNotFoundError extends Schema.TaggedError()( +export class PaymentProviderProductNotFoundError extends Schema.TaggedErrorClass()( "PaymentProviderProductNotFoundError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 404 }) + { httpApiStatus: 404 } ) {} diff --git a/packages/api-spec/src/errors/paywall-location.ts b/packages/api-spec/src/errors/paywall-location.ts index 81716203c..2fa8573ac 100644 --- a/packages/api-spec/src/errors/paywall-location.ts +++ b/packages/api-spec/src/errors/paywall-location.ts @@ -1,11 +1,10 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Generic paywall location service error */ -export class PaywallLocationServiceError extends Schema.TaggedError()( +export class PaywallLocationServiceError extends Schema.TaggedErrorClass()( "PaywallLocationServiceError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} diff --git a/packages/api-spec/src/errors/paywall.ts b/packages/api-spec/src/errors/paywall.ts index d9220623b..cabac2a39 100644 --- a/packages/api-spec/src/errors/paywall.ts +++ b/packages/api-spec/src/errors/paywall.ts @@ -1,38 +1,37 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Generic paywall service error */ -export class PaywallServiceError extends Schema.TaggedError()( +export class PaywallServiceError extends Schema.TaggedErrorClass()( "PaywallServiceError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} /** Paywall not found */ -export class PaywallNotFoundError extends Schema.TaggedError()( +export class PaywallNotFoundError extends Schema.TaggedErrorClass()( "PaywallNotFoundError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 404 }) + { httpApiStatus: 404 } ) {} /** Paywall slug already exists */ -export class PaywallSlugAlreadyExistsError extends Schema.TaggedError()( +export class PaywallSlugAlreadyExistsError extends Schema.TaggedErrorClass()( "PaywallSlugAlreadyExistsError", { slug: Schema.String, }, - HttpApiSchema.annotations({ status: 409 }) + { httpApiStatus: 409 } ) {} /** Paywall publish error */ -export class PaywallPublishError extends Schema.TaggedError()( +export class PaywallPublishError extends Schema.TaggedErrorClass()( "PaywallPublishError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} diff --git a/packages/api-spec/src/errors/perk.ts b/packages/api-spec/src/errors/perk.ts index 5fe2623b7..6d143cc3e 100644 --- a/packages/api-spec/src/errors/perk.ts +++ b/packages/api-spec/src/errors/perk.ts @@ -1,31 +1,30 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Generic perk service error */ -export class PerkServiceError extends Schema.TaggedError()( +export class PerkServiceError extends Schema.TaggedErrorClass()( "PerkServiceError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} /** Perk not found */ -export class PerkNotFoundError extends Schema.TaggedError()( +export class PerkNotFoundError extends Schema.TaggedErrorClass()( "PerkNotFoundError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 404 }) + { httpApiStatus: 404 } ) {} /** Perk slug already exists */ -export class PerkSlugAlreadyExistsError extends Schema.TaggedError()( +export class PerkSlugAlreadyExistsError extends Schema.TaggedErrorClass()( "PerkSlugAlreadyExistsError", { slug: Schema.String, }, - HttpApiSchema.annotations({ status: 409 }) + { httpApiStatus: 409 } ) { toString(): string { return `The following perk slug already exists: ${this.slug}`; diff --git a/packages/api-spec/src/errors/product-perk.ts b/packages/api-spec/src/errors/product-perk.ts index 404278cff..f96c14e3d 100644 --- a/packages/api-spec/src/errors/product-perk.ts +++ b/packages/api-spec/src/errors/product-perk.ts @@ -1,20 +1,19 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Generic product perk service error */ -export class ProductPerkServiceError extends Schema.TaggedError()( +export class ProductPerkServiceError extends Schema.TaggedErrorClass()( "ProductPerkServiceError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} /** Product perk validation error */ -export class ProductPerkValidationError extends Schema.TaggedError()( +export class ProductPerkValidationError extends Schema.TaggedErrorClass()( "ProductPerkValidationError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 400 }) + { httpApiStatus: 400 } ) {} diff --git a/packages/api-spec/src/errors/product.ts b/packages/api-spec/src/errors/product.ts index eda8d2699..1425355e3 100644 --- a/packages/api-spec/src/errors/product.ts +++ b/packages/api-spec/src/errors/product.ts @@ -1,31 +1,30 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Generic product service error */ -export class ProductServiceError extends Schema.TaggedError()( +export class ProductServiceError extends Schema.TaggedErrorClass()( "ProductServiceError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} /** Product not found */ -export class ProductNotFoundError extends Schema.TaggedError()( +export class ProductNotFoundError extends Schema.TaggedErrorClass()( "ProductNotFoundError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 404 }) + { httpApiStatus: 404 } ) {} /** Product slug already exists */ -export class ProductSlugAlreadyExistsError extends Schema.TaggedError()( +export class ProductSlugAlreadyExistsError extends Schema.TaggedErrorClass()( "ProductSlugAlreadyExistsError", { slug: Schema.String, }, - HttpApiSchema.annotations({ status: 409 }) + { httpApiStatus: 409 } ) { toString(): string { return `The following product slug already exists: ${this.slug}`; diff --git a/packages/api-spec/src/errors/project.ts b/packages/api-spec/src/errors/project.ts index 51675b982..804b87e37 100644 --- a/packages/api-spec/src/errors/project.ts +++ b/packages/api-spec/src/errors/project.ts @@ -1,22 +1,21 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Generic project service error */ -export class ProjectServiceError extends Schema.TaggedError()( +export class ProjectServiceError extends Schema.TaggedErrorClass()( "ProjectServiceError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} /** Project not found */ -export class ProjectNotFoundError extends Schema.TaggedError()( +export class ProjectNotFoundError extends Schema.TaggedErrorClass()( "ProjectNotFoundError", { projectId: Schema.String, }, - HttpApiSchema.annotations({ status: 404 }) + { httpApiStatus: 404 } ) { toString(): string { return `The following project not found: ${this.projectId}`; diff --git a/packages/api-spec/src/errors/sdk.ts b/packages/api-spec/src/errors/sdk.ts index 72949f841..78987c117 100644 --- a/packages/api-spec/src/errors/sdk.ts +++ b/packages/api-spec/src/errors/sdk.ts @@ -1,31 +1,30 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Generic SDK service error */ -export class SdkServiceError extends Schema.TaggedError()( +export class SdkServiceError extends Schema.TaggedErrorClass()( "SdkServiceError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} /** SDK customer not found */ -export class SdkCustomerNotFoundError extends Schema.TaggedError()( +export class SdkCustomerNotFoundError extends Schema.TaggedErrorClass()( "SdkCustomerNotFoundError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 404 }) + { httpApiStatus: 404 } ) {} /** SDK customer already identified */ -export class SdkCustomerAlreadyIdentifiedError extends Schema.TaggedError()( +export class SdkCustomerAlreadyIdentifiedError extends Schema.TaggedErrorClass()( "SdkCustomerAlreadyIdentifiedError", { appUserId: Schema.String, }, - HttpApiSchema.annotations({ status: 409 }) + { httpApiStatus: 409 } ) { toString(): string { return `The following customer was already identified: ${this.appUserId}`; @@ -33,10 +32,10 @@ export class SdkCustomerAlreadyIdentifiedError extends Schema.TaggedError()( +export class SdkValidationError extends Schema.TaggedErrorClass()( "SdkValidationError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 400 }) + { httpApiStatus: 400 } ) {} diff --git a/packages/api-spec/src/errors/user.ts b/packages/api-spec/src/errors/user.ts index 85e2cb025..a46727294 100644 --- a/packages/api-spec/src/errors/user.ts +++ b/packages/api-spec/src/errors/user.ts @@ -1,11 +1,10 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Generic user service error */ -export class UserServiceError extends Schema.TaggedError()( +export class UserServiceError extends Schema.TaggedErrorClass()( "UserServiceError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} diff --git a/packages/api-spec/src/errors/webhook.ts b/packages/api-spec/src/errors/webhook.ts index 0afe7ef43..87110dc04 100644 --- a/packages/api-spec/src/errors/webhook.ts +++ b/packages/api-spec/src/errors/webhook.ts @@ -1,38 +1,37 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; /** Generic webhook service error */ -export class WebhookServiceError extends Schema.TaggedError()( +export class WebhookServiceError extends Schema.TaggedErrorClass()( "WebhookServiceError", { cause: Schema.String, }, - HttpApiSchema.annotations({ status: 500 }) + { httpApiStatus: 500 } ) {} /** Webhook endpoint not found */ -export class WebhookEndpointNotFoundError extends Schema.TaggedError()( +export class WebhookEndpointNotFoundError extends Schema.TaggedErrorClass()( "WebhookEndpointNotFoundError", { endpointId: Schema.String, }, - HttpApiSchema.annotations({ status: 404 }) + { httpApiStatus: 404 } ) {} /** Webhook delivery not found */ -export class WebhookDeliveryNotFoundError extends Schema.TaggedError()( +export class WebhookDeliveryNotFoundError extends Schema.TaggedErrorClass()( "WebhookDeliveryNotFoundError", { deliveryId: Schema.String, }, - HttpApiSchema.annotations({ status: 404 }) + { httpApiStatus: 404 } ) {} /** Webhook validation error */ -export class WebhookValidationError extends Schema.TaggedError()( +export class WebhookValidationError extends Schema.TaggedErrorClass()( "WebhookValidationError", { message: Schema.String, }, - HttpApiSchema.annotations({ status: 400 }) + { httpApiStatus: 400 } ) {} diff --git a/packages/api-spec/src/middlewares.ts b/packages/api-spec/src/middlewares.ts index 6dd9f5920..a3ffdc7ef 100644 --- a/packages/api-spec/src/middlewares.ts +++ b/packages/api-spec/src/middlewares.ts @@ -1,15 +1,14 @@ -import { HttpApiMiddleware, HttpApiSecurity } from "@effect/platform"; import { Schema } from "effect"; +import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"; import { ApiAuthSession } from "./auth"; import { AuthenticationError, NotAuthenticatedError } from "./errors"; -export class AuthMiddleware extends HttpApiMiddleware.Tag()( +export class AuthMiddleware extends HttpApiMiddleware.Service()( "Http/AuthenticationMiddleware", { // Optionally define the error schema for the middleware - failure: Schema.Union(AuthenticationError, NotAuthenticatedError), - provides: ApiAuthSession, + error: Schema.Union([AuthenticationError, NotAuthenticatedError]), security: { apiKey: HttpApiSecurity.apiKey({ in: "header", diff --git a/packages/api-spec/src/schema.ts b/packages/api-spec/src/schema.ts index e62de0a74..17cc9cc67 100644 --- a/packages/api-spec/src/schema.ts +++ b/packages/api-spec/src/schema.ts @@ -1,4 +1,3 @@ -import { HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; import { ChangesetSchema } from "./changeset"; @@ -20,11 +19,11 @@ export const SecretKeyAuthHeaders = Schema.Struct({ // Auth // ======================================================== -const SessionAuthMethods = Schema.Union( +const SessionAuthMethods = Schema.Union([ Schema.Literal("api-key"), Schema.Literal("publishable-key"), - Schema.Literal("secret-key") -); + Schema.Literal("secret-key"), +]); export const Session = Schema.Struct({ method: SessionAuthMethods, @@ -79,7 +78,7 @@ export class CreateSecretKeyBody extends Schema.Class( projectId: Schema.String, }) {} -export const ApiKeyIdParam = HttpApiSchema.param("apiKeyId", Schema.String); +export const ApiKeyIdParam = Schema.String; // ======================================================== // Customers @@ -100,9 +99,9 @@ export class CreateCustomerBody extends Schema.Class( name: Schema.optional(Schema.String), }) {} -export const CustomerIdParam = HttpApiSchema.param("customerId", Schema.String); +export const CustomerIdParam = Schema.String; -export const AppUserIdParam = HttpApiSchema.param("appUserId", Schema.String); +export const AppUserIdParam = Schema.String; // ======================================================== // Organizations @@ -147,11 +146,11 @@ export class PaywallLocation extends Schema.Class("PaywallLocat // Products // ======================================================== -export const ProductType = Schema.Literal( +export const ProductType = Schema.Literals([ "subscription", "one-time", - "one-time-consumable" -); + "one-time-consumable", +]); export class Product extends Schema.Class("Product")({ id: Schema.String, @@ -165,7 +164,7 @@ export class Product extends Schema.Class("Product")({ // Product Perks // ======================================================== -export const ProductIdParam = HttpApiSchema.param("productId", Schema.String); +export const ProductIdParam = Schema.String; export class ProductPerk extends Schema.Class("ProductPerk")({ id: Schema.String, @@ -194,7 +193,7 @@ export class PaymentProviderConfiguration extends Schema.Class( "PaymentProviderProduct" )({ - configuration: Schema.Record({ key: Schema.String, value: Schema.Unknown }), + configuration: Schema.Record(Schema.String, Schema.Unknown), id: Schema.String, paymentProviderConfigurationId: Schema.String, productId: Schema.String, @@ -234,10 +233,7 @@ export class Project extends Schema.Class("Project")({ slug: Schema.String, }) {} -export const OrganizationIdParam = HttpApiSchema.param( - "organizationId", - Schema.String -); +export const OrganizationIdParam = Schema.String; // ======================================================== // SDK @@ -248,9 +244,9 @@ const CommonSdkHeaders = Schema.Struct({ "x-client-locale": Schema.optional(Schema.String), "x-client-version": Schema.optional(Schema.String), "x-is-backgrounded": Schema.Literal("false"), - "x-is-debug-build": Schema.Literal("true", "false"), + "x-is-debug-build": Schema.Literals(["true", "false"]), "x-nonce": Schema.optional(Schema.String), - "x-observer-mode": Schema.Literal("true", "false"), + "x-observer-mode": Schema.Literals(["true", "false"]), "x-platform": Schema.String, "x-platform-brand": Schema.optional(Schema.String), "x-platform-device": Schema.optional(Schema.String), @@ -286,7 +282,7 @@ export class SdkSyncCustomerAttributesBody extends Schema.Class("User")({ // Webhooks // ======================================================== -export const WebhookEventType = Schema.Literal( +export const WebhookEventType = Schema.Literals([ "customer.created", "customer.updated", "customer.deleted", @@ -352,18 +348,18 @@ export const WebhookEventType = Schema.Literal( "subscription.cancelled", "subscription.expired", "purchase.completed", - "purchase.refunded" -); + "purchase.refunded", +]); -export const WebhookEndpointStatus = Schema.Literal("active", "disabled", "failed"); +export const WebhookEndpointStatus = Schema.Literals(["active", "disabled", "failed"]); -export const WebhookDeliveryStatus = Schema.Literal( +export const WebhookDeliveryStatus = Schema.Literals([ "pending", "in_progress", "succeeded", "failed", - "exhausted" -); + "exhausted", +]); export class WebhookEndpoint extends Schema.Class( "WebhookEndpoint" @@ -396,14 +392,11 @@ export class UpdateWebhookEndpointBody extends Schema.Class( "WebhookDelivery" @@ -453,10 +446,7 @@ export class WebhookDeliveryWithAttempts extends Schema.Class( locationSlug: Schema.String, }) {} -const SdkResolvedPaywallShowingType = Schema.Literal( +const SdkResolvedPaywallShowingType = Schema.Literals([ "paywall_release", - "feature_flag" -); + "feature_flag", +]); const SdkResolvedPaywallShowingPaywall = Schema.Struct({ id: Schema.String, diff --git a/packages/shared/src/auth.ts b/packages/shared/src/auth.ts index eaee1a6c1..84a3e6349 100644 --- a/packages/shared/src/auth.ts +++ b/packages/shared/src/auth.ts @@ -1,4 +1,4 @@ -import { Context, Schema } from "effect"; +import { Schema, ServiceMap } from "effect"; export const SessionOrganizationSchema = Schema.Struct({ id: Schema.String, @@ -62,11 +62,11 @@ export const PublishableKeySessionSchema = Schema.Struct({ user: Schema.Null, }); -export const AuthSessionSchema = Schema.Union( +export const AuthSessionSchema = Schema.Union([ UserSessionSchema, SecretKeySessionSchema, - PublishableKeySessionSchema -); + PublishableKeySessionSchema, +]); // type User = { // name: string; @@ -129,7 +129,4 @@ export type AnyAuthSession = | SecretKeySession | PublishableKeySession; -export class AuthSession extends Context.Tag("shared/auth/AuthSession")< - AuthSession, - UserSession | SecretKeySession | PublishableKeySession ->() {} +export class AuthSession extends ServiceMap.Service()("shared/auth/AuthSession") {} diff --git a/packages/shared/src/deploy-changeset.ts b/packages/shared/src/deploy-changeset.ts index 2a7a7b42e..ccce9e9e1 100644 --- a/packages/shared/src/deploy-changeset.ts +++ b/packages/shared/src/deploy-changeset.ts @@ -111,7 +111,7 @@ export const PaymentProviderProductCreateChangeSchema = Schema.Struct({ changeType: Schema.Literal("create-payment-provider-product"), key: Schema.String, payload: Schema.Struct({ - configuration: Schema.Record({ key: Schema.String, value: Schema.Unknown }), + configuration: Schema.Record(Schema.String, Schema.Unknown), productSlug: Schema.String, providerId: Schema.String, }), @@ -121,7 +121,7 @@ export const PaymentProviderProductUpdateChangeSchema = Schema.Struct({ changeType: Schema.Literal("update-payment-provider-product"), key: Schema.String, payload: Schema.Struct({ - configuration: Schema.Record({ key: Schema.String, value: Schema.Unknown }), + configuration: Schema.Record(Schema.String, Schema.Unknown), productSlug: Schema.String, providerId: Schema.String, }), @@ -136,7 +136,7 @@ export const PaymentProviderProductDeleteChangeSchema = Schema.Struct({ }), }); -export const ChangeSchema = Schema.Union( +export const ChangeSchema = Schema.Union([ PaywallLocationCreateChangeSchema, PaywallLocationUpdateChangeSchema, PaywallLocationArchiveChangeSchema, @@ -150,8 +150,8 @@ export const ChangeSchema = Schema.Union( ProductPerkDeleteChangeSchema, PaymentProviderProductCreateChangeSchema, PaymentProviderProductUpdateChangeSchema, - PaymentProviderProductDeleteChangeSchema -); + PaymentProviderProductDeleteChangeSchema, +]); export const ChangesetSchema = Schema.Struct({ changes: Schema.Array(ChangeSchema), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b085b86fe..43d89e241 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,31 +6,17 @@ settings: catalogs: default: - '@effect/cli': - specifier: ^0.73.0 - version: 0.73.0 '@effect/language-service': specifier: ^0.64.1 version: 0.64.1 - '@effect/platform': - specifier: ^0.94.1 - version: 0.94.1 - '@effect/platform-node': - specifier: ^0.104.0 - version: 0.104.0 - '@effect/vitest': - specifier: ^0.27.0 - version: 0.27.0 better-auth: specifier: ^1.4.18 version: 1.4.18 - effect: - specifier: ^3.19.14 - version: 3.19.14 overrides: react: 19.1.0 react-dom: 19.1.0 + effect: 4.0.0-beta.23 importers: @@ -82,15 +68,9 @@ importers: apps/cli: dependencies: - '@effect/cli': - specifier: 'catalog:' - version: 0.73.0(@effect/platform@0.94.1(effect@3.19.14))(@effect/printer-ansi@0.46.0(@effect/typeclass@0.37.0(effect@3.19.14))(effect@3.19.14))(@effect/printer@0.46.0(@effect/typeclass@0.37.0(effect@3.19.14))(effect@3.19.14))(effect@3.19.14) - '@effect/platform': - specifier: 'catalog:' - version: 0.94.1(effect@3.19.14) '@effect/platform-node': - specifier: 'catalog:' - version: 0.104.0(@effect/cluster@0.56.1(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.46.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.46.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14) + specifier: 4.0.0-beta.23 + version: 4.0.0-beta.23(effect@4.0.0-beta.23)(ioredis@5.9.1) '@voidhash/api-spec': specifier: workspace:* version: link:../../packages/api-spec @@ -99,10 +79,10 @@ importers: version: link:../../packages/shared better-auth: specifier: 'catalog:' - version: 1.4.18(@tanstack/react-start@1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(kysely@0.28.9)(mysql2@3.16.0))(mysql2@3.16.0)(next@16.0.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.0))(react@19.1.0))(react-dom@19.1.1(react@19.1.0))(react@19.1.0)(vitest@3.2.4) + version: 1.4.18(@tanstack/react-start@1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(kysely@0.28.9)(mysql2@3.16.0))(mysql2@3.16.0)(next@16.0.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.0))(react@19.1.0))(react-dom@19.1.1(react@19.1.0))(react@19.1.0)(vitest@3.2.4) effect: - specifier: 'catalog:' - version: 3.19.14 + specifier: 4.0.0-beta.23 + version: 4.0.0-beta.23 esbuild-register: specifier: ^3.6.0 version: 3.6.0(esbuild@0.25.12) @@ -114,8 +94,8 @@ importers: specifier: 'catalog:' version: 0.64.1 '@effect/vitest': - specifier: 'catalog:' - version: 0.27.0(effect@3.19.14)(vitest@3.2.4) + specifier: 4.0.0-beta.23 + version: 4.0.0-beta.23(effect@4.0.0-beta.23)(vitest@3.2.4) '@voidhash/react-native': specifier: workspace:* version: link:../../libraries/react-native @@ -139,10 +119,10 @@ importers: version: 5.6.3 vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.6.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.1.4(typescript@5.6.3)(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^3.0.9 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) examples/react-native-example: dependencies: @@ -172,7 +152,7 @@ importers: version: 17.0.8(expo@54.0.33)(react@19.1.0) expo-router: specifier: ~6.0.23 - version: 6.0.23(9471de8f7829ecb4f3c776787ee824a6) + version: 6.0.23(a9179ff0b15fe10f2a71d6f189aa330e) expo-splash-screen: specifier: ~31.0.11 version: 31.0.13(expo@54.0.33) @@ -234,15 +214,12 @@ importers: libraries/react-native: dependencies: - '@effect/platform': - specifier: 'catalog:' - version: 0.94.1(effect@3.19.14) '@react-native-async-storage/async-storage': specifier: ^1.24.0 || ^2.0.0 - version: 2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) + version: 2.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) effect: - specifier: 'catalog:' - version: 3.19.14 + specifier: 4.0.0-beta.23 + version: 4.0.0-beta.23 neverthrow: specifier: ^8.2.0 version: 8.2.0 @@ -264,37 +241,37 @@ importers: version: link:../../packages/shared expo: specifier: 54.0.33 - version: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-constants: specifier: ~18.0.13 - version: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) + version: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) expo-linking: specifier: ~8.0.9 - version: 8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-module-scripts: specifier: ^5.0.8 - version: 5.0.8(@babel/core@7.28.5)(@babel/runtime@7.28.4)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(eslint@9.39.2(jiti@2.6.1))(expo@54.0.33)(jest@29.7.0(@types/node@24.10.4))(prettier@3.7.4)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react-refresh@0.17.0)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) + version: 5.0.8(@babel/core@7.29.0)(@babel/runtime@7.28.4)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.25.12)(eslint@9.39.2(jiti@2.6.1))(expo@54.0.33)(jest@29.7.0(@types/node@24.10.4))(prettier@3.8.1)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react-refresh@0.17.0)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) jest: specifier: ~29.7.0 version: 29.7.0(@types/node@24.10.4) jest-expo: specifier: ~54.0.17 - version: 54.0.17(@babel/core@7.28.5)(expo@54.0.33)(jest@29.7.0(@types/node@24.10.4))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 54.0.17(@babel/core@7.29.0)(expo@54.0.33)(jest@29.7.0(@types/node@24.10.4))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) jest-fixed-jsdom: specifier: ^0.0.9 version: 0.0.9(jest-environment-jsdom@29.7.0) nitro-codegen: specifier: 0.26.4 - version: 0.26.4(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 0.26.4(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react: specifier: 19.1.0 version: 19.1.0 react-native: specifier: 0.81.5 - version: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + version: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) react-native-nitro-modules: specifier: 0.26.4 - version: 0.26.4(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 0.26.4(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) typescript: specifier: 5.9.3 version: 5.9.3 @@ -304,12 +281,9 @@ importers: packages/api-spec: dependencies: - '@effect/platform': - specifier: 'catalog:' - version: 0.94.1(effect@3.19.14) effect: - specifier: 'catalog:' - version: 3.19.14 + specifier: 4.0.0-beta.23 + version: 4.0.0-beta.23 devDependencies: '@voidhash/tsconfig': specifier: workspace:* @@ -321,8 +295,8 @@ importers: packages/shared: dependencies: effect: - specifier: 'catalog:' - version: 3.19.14 + specifier: 4.0.0-beta.23 + version: 4.0.0-beta.23 devDependencies: '@voidhash/tsconfig': specifier: workspace:* @@ -368,18 +342,34 @@ packages: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.28.5': resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + '@babel/core@7.28.5': resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} engines: {node: '>=6.9.0'} + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + '@babel/generator@7.28.5': resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} @@ -388,12 +378,22 @@ packages: resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + '@babel/helper-create-class-features-plugin@7.28.5': resolution: {integrity: sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-create-regexp-features-plugin@7.28.5': resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==} engines: {node: '>=6.9.0'} @@ -417,12 +417,22 @@ packages: resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@7.28.3': resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-optimise-call-expression@7.27.1': resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} engines: {node: '>=6.9.0'} @@ -431,6 +441,10 @@ packages: resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + '@babel/helper-remap-async-to-generator@7.27.1': resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==} engines: {node: '>=6.9.0'} @@ -443,6 +457,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} engines: {node: '>=6.9.0'} @@ -467,6 +487,10 @@ packages: resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} engines: {node: '>=6.9.0'} + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + '@babel/highlight@7.25.9': resolution: {integrity: sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==} engines: {node: '>=6.9.0'} @@ -476,6 +500,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5': resolution: {integrity: sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==} engines: {node: '>=6.9.0'} @@ -596,6 +625,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} peerDependencies: @@ -644,6 +679,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} engines: {node: '>=6.9.0'} @@ -806,6 +847,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-modules-systemjs@7.28.5': resolution: {integrity: sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==} engines: {node: '>=6.9.0'} @@ -992,6 +1039,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-unicode-escapes@7.27.1': resolution: {integrity: sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==} engines: {node: '>=6.9.0'} @@ -1053,14 +1106,26 @@ packages: resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.28.5': resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -1147,106 +1212,28 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} - '@effect/cli@0.73.0': - resolution: {integrity: sha512-KkRtFjfyG52kQ6Z3ZEjwytfKZQACsLzn/RI2jKKxMJDebY9BQganOiE3VGCT4slU3Zisur6SP//ghM0vCLQs4g==} - peerDependencies: - '@effect/platform': ^0.94.0 - '@effect/printer': ^0.47.0 - '@effect/printer-ansi': ^0.47.0 - effect: ^3.19.13 - - '@effect/cluster@0.56.1': - resolution: {integrity: sha512-gnrsH6kfrUjn+82j/bw1IR4yFqJqV8tc7xZvrbJPRgzANycc6K1hu3LMg548uYbUkTzD8YYyqrSatMO1mkQpzw==} - peerDependencies: - '@effect/platform': ^0.94.1 - '@effect/rpc': ^0.73.0 - '@effect/sql': ^0.49.0 - '@effect/workflow': ^0.16.0 - effect: ^3.19.14 - - '@effect/experimental@0.58.0': - resolution: {integrity: sha512-IEP9sapjF6rFy5TkoqDPc86st/fnqUfjT7Xa3pWJrFGr1hzaMXHo+mWsYOZS9LAOVKnpHuVziDK97EP5qsCHVA==} - peerDependencies: - '@effect/platform': ^0.94.0 - effect: ^3.19.13 - ioredis: ^5 - lmdb: ^3 - peerDependenciesMeta: - ioredis: - optional: true - lmdb: - optional: true - '@effect/language-service@0.64.1': resolution: {integrity: sha512-1S7Xr2t9mygR6QyiIdCQUvPwSvcf5EBpnWmKa/8rC4P0btFfw/RiWRI8bvFI2AW+U4KStMUHYxPu4QEsk4y8bg==} hasBin: true - '@effect/platform-node-shared@0.57.0': - resolution: {integrity: sha512-QXuvmLNlABCQLcTl+lN1YPhKosR6KqArPYjC2reU0fb5lroCo3YRb/aGpXIgLthHzQL8cLU5XMGA3Cu5hKY2Tw==} - peerDependencies: - '@effect/cluster': ^0.56.0 - '@effect/platform': ^0.94.0 - '@effect/rpc': ^0.73.0 - '@effect/sql': ^0.49.0 - effect: ^3.19.13 - - '@effect/platform-node@0.104.0': - resolution: {integrity: sha512-2ZkUDDTxLD95ARdYIKBx4tdIIgqA3cwb3jlnVVBxmHUf0Pg5N2HdMuD0Q+CXQ7Q94FDwnLW3ZvaSfxDh6FvrNw==} - peerDependencies: - '@effect/cluster': ^0.56.0 - '@effect/platform': ^0.94.0 - '@effect/rpc': ^0.73.0 - '@effect/sql': ^0.49.0 - effect: ^3.19.13 - - '@effect/platform@0.94.1': - resolution: {integrity: sha512-SlL8OMTogHmMNnFLnPAHHo3ua1yrB1LNQOVQMiZsqYu9g3216xjr0gn5WoDgCxUyOdZcseegMjWJ7dhm/2vnfg==} - peerDependencies: - effect: ^3.19.14 - - '@effect/printer-ansi@0.46.0': - resolution: {integrity: sha512-b+wCoCtmEF/Duobsid2N1JoGvZLiA9Fkcz1G1x/dhXVtsZqaGPa6jNYctopmzBhyuS53kK9XpqnFhEbF6+/5Ug==} - peerDependencies: - '@effect/typeclass': ^0.37.0 - effect: ^3.18.0 - - '@effect/printer@0.46.0': - resolution: {integrity: sha512-8RbuXDY9ND3M7klt2wCrx4gCDlfecH3bFJl8Co4UenRDiAMa0WoI8GKPqlNA3FonryNYgrMN0TDnk39Yp5GUbQ==} - peerDependencies: - '@effect/typeclass': ^0.37.0 - effect: ^3.18.0 - - '@effect/rpc@0.73.0': - resolution: {integrity: sha512-iMPf6tTriz8sK0l5x4koFId8Hz5nFptHYg8WqyjHGIIVLTpZxuiSqhmXZG7FnAs5N2n6uCEws4wWGcIgXNUrFg==} - peerDependencies: - '@effect/platform': ^0.94.0 - effect: ^3.19.13 - - '@effect/sql@0.46.0': - resolution: {integrity: sha512-nm9TuTTG7gLmJlIPkf71wA5lXArSvkpm1oYoIF+rhf01wef+1ujz9Mv1SfuzYbzsk7W9+OXUIRMxz/nSlKkiGQ==} - peerDependencies: - '@effect/experimental': ^0.56.0 - '@effect/platform': ^0.92.0 - effect: ^3.18.0 - - '@effect/typeclass@0.37.0': - resolution: {integrity: sha512-8PkVWx82gkavYYMDlRV80pM1Ss0dL84wabkho6JjaAuDr+gJKx87N0T6Z+UGroySslsfw1FW1dMibUPH1BXX1w==} + '@effect/platform-node-shared@4.0.0-beta.23': + resolution: {integrity: sha512-iYy0oPMAKnpWEum1u8v42BcDAkUEK+AuiYGMHAnf+GJYlQTYbIcIQcLjAQ68yQuDf3n+la91TSs0MaiA3F2Svw==} + engines: {node: '>=18.0.0'} peerDependencies: - effect: ^3.18.0 + effect: 4.0.0-beta.23 - '@effect/vitest@0.27.0': - resolution: {integrity: sha512-8bM7n9xlMUYw9GqPIVgXFwFm2jf27m/R7psI64PGpwU5+26iwyxp9eAXEsfT5S6lqztYfpQQ1Ubp5o6HfNYzJQ==} + '@effect/platform-node@4.0.0-beta.23': + resolution: {integrity: sha512-7CNVrUbxXXxXsUWtyJrrBMpf5JtjDJpuhIPIQssACYTtD711EQdy3itHHxXrIjXf7vBhx0XrekQ3PJUSN9usIw==} + engines: {node: '>=18.0.0'} peerDependencies: - effect: ^3.19.0 - vitest: ^3.2.0 + effect: 4.0.0-beta.23 + ioredis: ^5.7.0 - '@effect/workflow@0.16.0': - resolution: {integrity: sha512-MiAdlxx3TixkgHdbw+Yf1Z3tHAAE0rOQga12kIydJqj05Fnod+W/I+kQGRMY/XWRg+QUsVxhmh1qTr7Ype6lrw==} + '@effect/vitest@4.0.0-beta.23': + resolution: {integrity: sha512-2ezxm8jpBuoY9UsrwUyGsbs1lwKodR6NGV/g/rypoPN3L6WABXOcZs2mhFmY8y5EXnGZnSgsWhpxwYlEo29jcA==} peerDependencies: - '@effect/experimental': ^0.58.0 - '@effect/platform': ^0.94.0 - '@effect/rpc': ^0.73.0 - effect: ^3.19.13 + effect: 4.0.0-beta.23 + vitest: ^3.0.0 || ^4.0.0 '@emnapi/core@1.7.1': resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} @@ -1254,6 +1241,9 @@ packages: '@emnapi/runtime@1.7.1': resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} @@ -2015,8 +2005,8 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@img/colour@1.0.0': - resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} '@img/sharp-darwin-arm64@0.34.5': @@ -2411,88 +2401,6 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} - '@parcel/watcher-android-arm64@2.5.1': - resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [android] - - '@parcel/watcher-darwin-arm64@2.5.1': - resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [darwin] - - '@parcel/watcher-darwin-x64@2.5.1': - resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [darwin] - - '@parcel/watcher-freebsd-x64@2.5.1': - resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [freebsd] - - '@parcel/watcher-linux-arm-glibc@2.5.1': - resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - - '@parcel/watcher-linux-arm-musl@2.5.1': - resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - - '@parcel/watcher-linux-arm64-glibc@2.5.1': - resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - - '@parcel/watcher-linux-arm64-musl@2.5.1': - resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - - '@parcel/watcher-linux-x64-glibc@2.5.1': - resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - - '@parcel/watcher-linux-x64-musl@2.5.1': - resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - - '@parcel/watcher-win32-arm64@2.5.1': - resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [win32] - - '@parcel/watcher-win32-ia32@2.5.1': - resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} - engines: {node: '>= 10.0.0'} - cpu: [ia32] - os: [win32] - - '@parcel/watcher-win32-x64@2.5.1': - resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [win32] - - '@parcel/watcher@2.5.1': - resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} - engines: {node: '>= 10.0.0'} - '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -3019,8 +2927,8 @@ packages: react-dom: 19.1.0 vite: '>=7.0.0' - '@tanstack/react-store@0.8.0': - resolution: {integrity: sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow==} + '@tanstack/react-store@0.8.1': + resolution: {integrity: sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig==} peerDependencies: react: 19.1.0 react-dom: 19.1.0 @@ -3058,6 +2966,10 @@ packages: resolution: {integrity: sha512-/eFGKCiix1SvjxwgzrmH4pHjMiMxc+GA4nIbgEkG2RdAJqyxLcRhd7RPLG0/LZaJ7d0ad3jrtRqsHLv2152Vbw==} engines: {node: '>=12'} + '@tanstack/router-utils@1.161.4': + resolution: {integrity: sha512-r8TpjyIZoqrXXaf2DDyjd44gjGBoyE+/oEaaH68yLI9ySPO1gUWmQENZ1MZnmBnpUGN24NOZxdjDLc8npK0SAw==} + engines: {node: '>=20.19'} + '@tanstack/server-functions-plugin@1.142.1': resolution: {integrity: sha512-ltTOj6dIDlRV3M8+PzontDYFMnIQ+icUnD+OKzIRfKo6bbvC0qvy8ttuWmVJxmqHy9xsWgkNt4gZrKVjtWXIhQ==} engines: {node: '>=12'} @@ -3080,8 +2992,8 @@ packages: resolution: {integrity: sha512-tB8oZLvTn3Ry3BM2d3jk+lOaI9ZYFR7N8EmjL6urmIWqgMqzuhgFuz+xqq2XveoPBdaSLeY5N6RsONH4tV4+dA==} engines: {node: '>=22.12.0'} - '@tanstack/store@0.8.0': - resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} + '@tanstack/store@0.8.1': + resolution: {integrity: sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw==} '@tanstack/virtual-file-routes@1.141.0': resolution: {integrity: sha512-CJrWtr6L9TVzEImm9S7dQINx+xJcYP/aDkIi6gnaWtIgbZs1pnzsE0yJc2noqXZ+yAOqLx3TBGpBEs9tS0P9/A==} @@ -3166,6 +3078,9 @@ packages: '@types/node@24.10.4': resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==} + '@types/node@25.3.3': + resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3180,6 +3095,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -3463,6 +3381,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -3604,8 +3527,8 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} - babel-dead-code-elimination@1.0.11: - resolution: {integrity: sha512-mwq3W3e/pKSI6TG8lXMiDWvEi1VXYlSBlJlB3l+I0bAb5u1RNUl88udos85eOPNK3m5EXK9uO7d2g08pesTySQ==} + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} @@ -3868,6 +3791,9 @@ packages: caniuse-lite@1.0.30001761: resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} + caniuse-lite@1.0.30001775: + resolution: {integrity: sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -3903,8 +3829,8 @@ packages: cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} - cheerio@1.1.2: - resolution: {integrity: sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==} + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} engines: {node: '>=20.18.1'} chokidar@3.6.0: @@ -4201,11 +4127,6 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - detect-libc@1.0.3: - resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} - engines: {node: '>=0.10'} - hasBin: true - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -4221,8 +4142,8 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - diff@8.0.2: - resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} dir-glob@3.0.1: @@ -4382,8 +4303,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - effect@3.19.14: - resolution: {integrity: sha512-3vwdq0zlvQOxXzXNKRIPKTqZNMyGCdaFUBfMPqpsyzZDre67kgC1EEHDV4EoQTovJ4w5fmJW756f86kkuz7WFA==} + effect@4.0.0-beta.23: + resolution: {integrity: sha512-bu6Lv2Tjs8QBMz9/TfPrNur6wqRCykW0rOZCJuaylt6g6sT28niL9L4Aeak/7XTsnYp5bCWaqRrSYANBVkPFQw==} electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -4418,6 +4339,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-editor@0.4.2: resolution: {integrity: sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==} engines: {node: '>=8'} @@ -4899,9 +4824,9 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} - fast-check@3.23.2: - resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} - engines: {node: '>=8.0.0'} + fast-check@4.5.3: + resolution: {integrity: sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==} + engines: {node: '>=12.17.0'} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -4977,6 +4902,9 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.3.4: + resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==} + flow-enums-runtime@0.0.6: resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} @@ -5183,8 +5111,8 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - htmlparser2@10.0.0: - resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} @@ -5210,8 +5138,8 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} - iconv-lite@0.7.1: - resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} ieee754@1.2.1: @@ -5260,9 +5188,9 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - ini@4.1.3: - resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} @@ -5422,8 +5350,8 @@ packages: isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - isbot@5.1.32: - resolution: {integrity: sha512-VNfjM73zz2IBZmdShMfAUg10prm6t7HFUQmNAEOAVS4YH92ZrZcvkMcGX6cIgBJAzWDzPent/EeAtYEHNPNPBQ==} + isbot@5.1.35: + resolution: {integrity: sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==} engines: {node: '>=18'} isexe@2.0.0: @@ -5871,8 +5799,8 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru.min@1.1.3: - resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==} + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} magic-string@0.30.21: @@ -5995,9 +5923,9 @@ packages: engines: {node: '>=4'} hasBin: true - mime@3.0.0: - resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} - engines: {node: '>=10.0.0'} + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} hasBin: true mimic-fn@1.2.0: @@ -6134,9 +6062,6 @@ packages: deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. hasBin: true - node-addon-api@7.1.1: - resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} - node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -6431,8 +6356,8 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - prettier@3.7.4: - resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} hasBin: true @@ -6476,6 +6401,9 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + qrcode-terminal@0.11.0: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} hasBin: true @@ -6829,6 +6757,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -6840,14 +6773,14 @@ packages: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} - seroval-plugins@1.4.0: - resolution: {integrity: sha512-zir1aWzoiax6pbBVjoYVd0O1QQXgIL3eVGBMsBsNmM8Ukq90yGaWlfx0AB9dTS8GPqrOrbXn79vmItCUP9U3BQ==} + seroval-plugins@1.5.0: + resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 - seroval@1.4.1: - resolution: {integrity: sha512-9GOc+8T6LN4aByLN75uRvMbrwY5RDBW6lSlknsY4LEa9ZmWcxKcRe1G/Q3HZXjltxMHTrStnvrwAICxZrhldtg==} + seroval@1.5.0: + resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} engines: {node: '>=10'} serve-static@1.16.3: @@ -7429,6 +7362,9 @@ packages: ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + ultracite@5.0.39: resolution: {integrity: sha512-yyrftv3h7aIGEWYVfRGWn8fBB6vbCjmTK57LQr48uZsB1r/xs1vEHlwdZe4m7xfH5Bmch6xf7q9aylh0vVtkrQ==} hasBin: true @@ -7443,12 +7379,15 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@6.22.0: resolution: {integrity: sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==} engines: {node: '>=18.17'} - undici@7.16.0: - resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} engines: {node: '>=20.18.1'} unicode-canonical-property-names-ecmascript@2.0.1: @@ -7532,8 +7471,8 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} - uuid@11.1.0: - resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} hasBin: true uuid@7.0.3: @@ -7611,10 +7550,10 @@ packages: yaml: optional: true - vitefu@1.1.1: - resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==} + vitefu@1.1.2: + resolution: {integrity: sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==} peerDependencies: - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0 peerDependenciesMeta: vite: optional: true @@ -7788,6 +7727,18 @@ packages: utf-8-validate: optional: true + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xcode@3.0.1: resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} engines: {node: '>=10.0.0'} @@ -7861,9 +7812,9 @@ snapshots: '@antfu/utils@8.1.1': {} - '@babel/cli@7.28.3(@babel/core@7.28.5)': + '@babel/cli@7.28.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@jridgewell/trace-mapping': 0.3.31 commander: 6.2.1 convert-source-map: 2.0.0 @@ -7892,8 +7843,16 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.28.5': {} + '@babel/compat-data@7.29.0': {} + '@babel/core@7.28.5': dependencies: '@babel/code-frame': 7.27.1 @@ -7914,6 +7873,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/generator@7.28.5': dependencies: '@babel/parser': 7.28.5 @@ -7922,6 +7901,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: '@babel/types': 7.28.5 @@ -7934,6 +7921,14 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.28.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -7947,6 +7942,46 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-create-class-features-plugin@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.28.5 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.28.5) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + optional: true + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -7954,6 +7989,13 @@ snapshots: regexpu-core: 6.4.0 semver: 6.3.1 + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + regexpu-core: 6.4.0 + semver: 6.3.1 + '@babel/helper-define-polyfill-provider@0.6.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -7965,6 +8007,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-define-polyfill-provider@0.6.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-plugin-utils': 7.27.1 + debug: 4.4.3 + lodash.debounce: 4.0.8 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + '@babel/helper-globals@7.28.0': {} '@babel/helper-member-expression-to-functions@7.28.5': @@ -7981,6 +8034,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -7990,16 +8050,54 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-optimise-call-expression@7.27.1': + '@babel/helper-module-transforms@7.28.3(@babel/core@7.29.0)': dependencies: - '@babel/types': 7.28.5 - - '@babel/helper-plugin-utils@7.27.1': {} + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color - '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.28.5)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.28.5 + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-wrap-function': 7.28.3 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-wrap-function': 7.28.3 '@babel/traverse': 7.28.5 transitivePeerDependencies: @@ -8014,6 +8112,34 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-replace-supers@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + optional: true + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.28.5 @@ -8040,6 +8166,11 @@ snapshots: '@babel/template': 7.27.2 '@babel/types': 7.28.5 + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@babel/highlight@7.25.9': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -8051,36 +8182,40 @@ snapshots: dependencies: '@babel/types': 7.28.5 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.28.5)': + '@babel/parser@7.29.0': dependencies: - '@babel/core': 7.28.5 + '@babel/types': 7.29.0 + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-transform-optional-chaining': 7.28.5(@babel/core@7.28.5) + '@babel/plugin-transform-optional-chaining': 7.28.5(@babel/core@7.29.0) transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.3(@babel/core@7.28.5)': + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 '@babel/traverse': 7.28.5 transitivePeerDependencies: @@ -8095,58 +8230,112 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-proposal-decorators@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-decorators': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + '@babel/plugin-proposal-export-default-from@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.28.5)': + '@babel/plugin-proposal-export-default-from@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-decorators@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-decorators@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-export-default-from@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-export-default-from@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-flow@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-import-assertions@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-syntax-flow@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-import-assertions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.5)': @@ -8154,70 +8343,157 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + optional: true + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.28.5)': + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.28.5) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + optional: true + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.28.5)': @@ -8225,6 +8501,11 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-async-generator-functions@7.28.0(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -8234,6 +8515,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-async-generator-functions@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-async-to-generator@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -8243,9 +8533,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-async-to-generator@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-block-scoping@7.28.5(@babel/core@7.28.5)': @@ -8253,6 +8552,11 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-block-scoping@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-class-properties@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -8261,6 +8565,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-class-properties@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-class-static-block@7.28.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -8269,6 +8581,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-class-static-block@7.28.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-classes@7.28.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -8281,12 +8601,30 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-classes@7.28.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-globals': 7.28.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.0) + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-computed-properties@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 '@babel/template': 7.27.2 + '@babel/plugin-transform-computed-properties@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/template': 7.27.2 + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -8295,39 +8633,47 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-dotall-regex@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.28.5) + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color - '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-dotall-regex@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.28.5) + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-explicit-resource-management@7.28.0(@babel/core@7.28.5)': + '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.28.5) + + '@babel/plugin-transform-explicit-resource-management@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-exponentiation-operator@7.28.5(@babel/core@7.28.5)': + '@babel/plugin-transform-exponentiation-operator@7.28.5(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.28.5)': @@ -8335,12 +8681,23 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-flow': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-flow': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -8349,6 +8706,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -8358,9 +8723,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-json-strings@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-json-strings@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-literals@7.27.1(@babel/core@7.28.5)': @@ -8368,20 +8742,30 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-logical-assignment-operators@7.28.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-logical-assignment-operators@7.28.5(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.27.1 transitivePeerDependencies: - supports-color @@ -8394,20 +8778,45 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-systemjs@7.28.5(@babel/core@7.28.5)': + '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.5) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + optional: true + + '@babel/plugin-transform-modules-systemjs@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.27.1 transitivePeerDependencies: - supports-color @@ -8418,9 +8827,15 @@ snapshots: '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.28.5) '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-named-capturing-groups-regex@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-nullish-coalescing-operator@7.27.1(@babel/core@7.28.5)': @@ -8428,11 +8843,21 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-nullish-coalescing-operator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-numeric-separator@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-numeric-separator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-object-rest-spread@7.28.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -8444,11 +8869,22 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-object-rest-spread@7.28.4(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.27.2 '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.0) transitivePeerDependencies: - supports-color @@ -8457,13 +8893,27 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-optional-catch-binding@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-optional-chaining@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-chaining@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color + optional: true '@babel/plugin-transform-optional-chaining@7.28.5(@babel/core@7.28.5)': dependencies: @@ -8473,11 +8923,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-optional-chaining@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-private-methods@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -8486,6 +8949,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-private-methods@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-private-property-in-object@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -8495,9 +8966,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-private-property-in-object@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.28.5)': @@ -8505,6 +8985,11 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -8512,16 +8997,33 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -8533,26 +9035,48 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-regenerator@7.28.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-regexp-modifiers@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-regenerator@7.28.4(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.28.5) + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-regexp-modifiers@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-runtime@7.28.5(@babel/core@7.28.5)': @@ -8567,11 +9091,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-runtime@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-plugin-utils': 7.27.1 + babel-plugin-polyfill-corejs2: 0.4.14(@babel/core@7.29.0) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.0) + babel-plugin-polyfill-regenerator: 0.6.5(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-spread@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -8580,19 +9121,37 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-spread@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-typescript@7.28.5(@babel/core@7.28.5)': @@ -8606,15 +9165,49 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-typescript@7.28.5(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color - '@babel/plugin-transform-unicode-property-regex@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.28.5) + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.28.5) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.28.5) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + optional: true + + '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-unicode-property-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.28.5)': @@ -8623,91 +9216,97 @@ snapshots: '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.28.5) '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-unicode-sets-regex@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.28.5) + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-unicode-sets-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.27.1 - '@babel/preset-env@7.28.5(@babel/core@7.28.5)': + '@babel/preset-env@7.28.5(@babel/core@7.29.0)': dependencies: '@babel/compat-data': 7.28.5 - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.27.2 '@babel/helper-plugin-utils': 7.27.1 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.28.5) - '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.3(@babel/core@7.28.5) - '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.28.5) - '@babel/plugin-syntax-import-assertions': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.28.5) - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-async-generator-functions': 7.28.0(@babel/core@7.28.5) - '@babel/plugin-transform-async-to-generator': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-block-scoping': 7.28.5(@babel/core@7.28.5) - '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-class-static-block': 7.28.3(@babel/core@7.28.5) - '@babel/plugin-transform-classes': 7.28.4(@babel/core@7.28.5) - '@babel/plugin-transform-computed-properties': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.28.5) - '@babel/plugin-transform-dotall-regex': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-explicit-resource-management': 7.28.0(@babel/core@7.28.5) - '@babel/plugin-transform-exponentiation-operator': 7.28.5(@babel/core@7.28.5) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-json-strings': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-logical-assignment-operators': 7.28.5(@babel/core@7.28.5) - '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-modules-systemjs': 7.28.5(@babel/core@7.28.5) - '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-named-capturing-groups-regex': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-numeric-separator': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-object-rest-spread': 7.28.4(@babel/core@7.28.5) - '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-optional-catch-binding': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-optional-chaining': 7.28.5(@babel/core@7.28.5) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.28.5) - '@babel/plugin-transform-private-methods': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-private-property-in-object': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-regenerator': 7.28.4(@babel/core@7.28.5) - '@babel/plugin-transform-regexp-modifiers': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-spread': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-unicode-property-regex': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-unicode-sets-regex': 7.27.1(@babel/core@7.28.5) - '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.28.5) - babel-plugin-polyfill-corejs2: 0.4.14(@babel/core@7.28.5) - babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.28.5) - babel-plugin-polyfill-regenerator: 0.6.5(@babel/core@7.28.5) + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.3(@babel/core@7.29.0) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0) + '@babel/plugin-syntax-import-assertions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.0) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-class-static-block': 7.28.3(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.4(@babel/core@7.29.0) + '@babel/plugin-transform-computed-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-dotall-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-explicit-resource-management': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-exponentiation-operator': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-json-strings': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-logical-assignment-operators': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-systemjs': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-numeric-separator': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.4(@babel/core@7.29.0) + '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.28.4(@babel/core@7.29.0) + '@babel/plugin-transform-regexp-modifiers': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-spread': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-property-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-sets-regex': 7.27.1(@babel/core@7.29.0) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.0) + babel-plugin-polyfill-corejs2: 0.4.14(@babel/core@7.29.0) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.0) + babel-plugin-polyfill-regenerator: 0.6.5(@babel/core@7.29.0) core-js-compat: 3.47.0 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.28.5)': + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 '@babel/types': 7.28.5 esutils: 2.0.3 @@ -8724,16 +9323,40 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/preset-react@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + '@babel/preset-typescript@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.5) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.28.5) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.28.5) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) transitivePeerDependencies: - supports-color + optional: true '@babel/preset-typescript@7.28.5(@babel/core@7.28.5)': dependencies: @@ -8746,6 +9369,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + '@babel/runtime@7.28.4': {} '@babel/template@7.27.2': @@ -8754,6 +9388,12 @@ snapshots: '@babel/parser': 7.28.5 '@babel/types': 7.28.5 + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@babel/traverse@7.28.5': dependencies: '@babel/code-frame': 7.27.1 @@ -8766,11 +9406,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.28.5': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@0.2.3': {} '@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)': @@ -8842,110 +9499,32 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} - '@effect/cli@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(@effect/printer-ansi@0.46.0(@effect/typeclass@0.37.0(effect@3.19.14))(effect@3.19.14))(@effect/printer@0.46.0(@effect/typeclass@0.37.0(effect@3.19.14))(effect@3.19.14))(effect@3.19.14)': - dependencies: - '@effect/platform': 0.94.1(effect@3.19.14) - '@effect/printer': 0.46.0(@effect/typeclass@0.37.0(effect@3.19.14))(effect@3.19.14) - '@effect/printer-ansi': 0.46.0(@effect/typeclass@0.37.0(effect@3.19.14))(effect@3.19.14) - effect: 3.19.14 - ini: 4.1.3 - toml: 3.0.0 - yaml: 2.8.2 - - '@effect/cluster@0.56.1(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.46.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(effect@3.19.14)': - dependencies: - '@effect/platform': 0.94.1(effect@3.19.14) - '@effect/rpc': 0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14) - '@effect/sql': 0.46.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14) - '@effect/workflow': 0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14) - effect: 3.19.14 - kubernetes-types: 1.30.0 - - '@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1)': - dependencies: - '@effect/platform': 0.94.1(effect@3.19.14) - effect: 3.19.14 - uuid: 11.1.0 - optionalDependencies: - ioredis: 5.9.1 - '@effect/language-service@0.64.1': {} - '@effect/platform-node-shared@0.57.0(@effect/cluster@0.56.1(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.46.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.46.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14)': + '@effect/platform-node-shared@4.0.0-beta.23(effect@4.0.0-beta.23)': dependencies: - '@effect/cluster': 0.56.1(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.46.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(effect@3.19.14) - '@effect/platform': 0.94.1(effect@3.19.14) - '@effect/rpc': 0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14) - '@effect/sql': 0.46.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14) - '@parcel/watcher': 2.5.1 - effect: 3.19.14 - multipasta: 0.2.7 - ws: 8.18.3 + '@types/ws': 8.18.1 + effect: 4.0.0-beta.23 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@0.104.0(@effect/cluster@0.56.1(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.46.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.46.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14)': + '@effect/platform-node@4.0.0-beta.23(effect@4.0.0-beta.23)(ioredis@5.9.1)': dependencies: - '@effect/cluster': 0.56.1(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.46.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(effect@3.19.14) - '@effect/platform': 0.94.1(effect@3.19.14) - '@effect/platform-node-shared': 0.57.0(@effect/cluster@0.56.1(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.46.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.46.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14) - '@effect/rpc': 0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14) - '@effect/sql': 0.46.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14) - effect: 3.19.14 - mime: 3.0.0 - undici: 7.16.0 - ws: 8.18.3 + '@effect/platform-node-shared': 4.0.0-beta.23(effect@4.0.0-beta.23) + effect: 4.0.0-beta.23 + ioredis: 5.9.1 + mime: 4.1.0 + undici: 7.22.0 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform@0.94.1(effect@3.19.14)': - dependencies: - effect: 3.19.14 - find-my-way-ts: 0.1.6 - msgpackr: 1.11.8 - multipasta: 0.2.7 - - '@effect/printer-ansi@0.46.0(@effect/typeclass@0.37.0(effect@3.19.14))(effect@3.19.14)': - dependencies: - '@effect/printer': 0.46.0(@effect/typeclass@0.37.0(effect@3.19.14))(effect@3.19.14) - '@effect/typeclass': 0.37.0(effect@3.19.14) - effect: 3.19.14 - - '@effect/printer@0.46.0(@effect/typeclass@0.37.0(effect@3.19.14))(effect@3.19.14)': - dependencies: - '@effect/typeclass': 0.37.0(effect@3.19.14) - effect: 3.19.14 - - '@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)': - dependencies: - '@effect/platform': 0.94.1(effect@3.19.14) - effect: 3.19.14 - msgpackr: 1.11.8 - - '@effect/sql@0.46.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)': - dependencies: - '@effect/experimental': 0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1) - '@effect/platform': 0.94.1(effect@3.19.14) - effect: 3.19.14 - uuid: 11.1.0 - - '@effect/typeclass@0.37.0(effect@3.19.14)': - dependencies: - effect: 3.19.14 - - '@effect/vitest@0.27.0(effect@3.19.14)(vitest@3.2.4)': - dependencies: - effect: 3.19.14 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - - '@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14)': + '@effect/vitest@4.0.0-beta.23(effect@4.0.0-beta.23)(vitest@3.2.4)': dependencies: - '@effect/experimental': 0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.9.1) - '@effect/platform': 0.94.1(effect@3.19.14) - '@effect/rpc': 0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14) - effect: 3.19.14 + effect: 4.0.0-beta.23 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@emnapi/core@1.7.1': dependencies: @@ -8958,6 +9537,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 @@ -9374,7 +9958,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.18.3 optionalDependencies: - expo-router: 6.0.23(9471de8f7829ecb4f3c776787ee824a6) + expo-router: 6.0.23(a9179ff0b15fe10f2a71d6f189aa330e) react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) transitivePeerDependencies: - bufferutil @@ -9382,6 +9966,81 @@ snapshots: - supports-color - utf-8-validate + '@expo/cli@54.0.23(expo-router@6.0.23)(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))': + dependencies: + '@0no-co/graphql.web': 1.2.0 + '@expo/code-signing-certificates': 0.0.6 + '@expo/config': 12.0.13 + '@expo/config-plugins': 54.0.4 + '@expo/devcert': 1.2.1 + '@expo/env': 2.0.8 + '@expo/image-utils': 0.8.8 + '@expo/json-file': 10.0.8 + '@expo/metro': 54.2.0 + '@expo/metro-config': 54.0.14(expo@54.0.33) + '@expo/osascript': 2.3.8 + '@expo/package-manager': 1.9.10 + '@expo/plist': 0.4.8 + '@expo/prebuild-config': 54.0.8(expo@54.0.33) + '@expo/schema-utils': 0.1.8 + '@expo/spawn-async': 1.7.2 + '@expo/ws-tunnel': 1.0.6 + '@expo/xcpretty': 4.3.2 + '@react-native/dev-middleware': 0.81.5 + '@urql/core': 5.2.0 + '@urql/exchange-retry': 1.3.2(@urql/core@5.2.0) + accepts: 1.3.8 + arg: 5.0.2 + better-opn: 3.0.2 + bplist-creator: 0.1.0 + bplist-parser: 0.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + compression: 1.8.1 + connect: 3.7.0 + debug: 4.4.3 + env-editor: 0.4.2 + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-server: 1.0.5 + freeport-async: 2.0.0 + getenv: 2.0.0 + glob: 13.0.0 + lan-network: 0.1.7 + minimatch: 9.0.5 + node-forge: 1.3.3 + npm-package-arg: 11.0.3 + ora: 3.4.0 + picomatch: 3.0.1 + pretty-bytes: 5.6.0 + pretty-format: 29.7.0 + progress: 2.0.3 + prompts: 2.4.2 + qrcode-terminal: 0.11.0 + require-from-string: 2.0.2 + requireg: 0.2.2 + resolve: 1.22.11 + resolve-from: 5.0.0 + resolve.exports: 2.0.3 + semver: 7.7.3 + send: 0.19.2 + slugify: 1.6.6 + source-map-support: 0.5.21 + stacktrace-parser: 0.1.11 + structured-headers: 0.4.1 + tar: 7.5.2 + terminal-link: 2.1.1 + undici: 6.22.0 + wrap-ansi: 7.0.0 + ws: 8.18.3 + optionalDependencies: + expo-router: 6.0.23(9c9430879de51b484c3e68e2beaf4aeb) + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + transitivePeerDependencies: + - bufferutil + - graphql + - supports-color + - utf-8-validate + '@expo/code-signing-certificates@0.0.6': dependencies: node-forge: 1.3.3 @@ -9439,6 +10098,13 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + '@expo/devtools@0.1.8(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + chalk: 4.1.2 + optionalDependencies: + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + '@expo/env@2.0.8': dependencies: chalk: 4.1.2 @@ -9525,6 +10191,19 @@ snapshots: optionalDependencies: react-dom: 19.1.1(react@19.1.0) + '@expo/metro-runtime@6.1.2(expo@54.0.33)(react-dom@19.1.1(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + anser: 1.4.10 + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + pretty-format: 29.7.0 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + optionalDependencies: + react-dom: 19.1.1(react@19.1.0) + optional: true + '@expo/metro@54.2.0': dependencies: metro: 0.83.3 @@ -9602,6 +10281,12 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + '@expo/vector-icons@15.0.3(expo-font@14.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + expo-font: 14.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + '@expo/ws-tunnel@1.0.6': {} '@expo/xcpretty@4.3.2': @@ -9622,7 +10307,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.0.0': + '@img/colour@1.1.0': optional: true '@img/sharp-darwin-arm64@0.34.5': @@ -9707,7 +10392,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.7.1 + '@emnapi/runtime': 1.8.1 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -9719,8 +10404,7 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@ioredis/commands@1.5.0': - optional: true + '@ioredis/commands@1.5.0': {} '@isaacs/balanced-match@4.0.1': {} @@ -10039,66 +10723,6 @@ snapshots: '@opentelemetry/api@1.9.0': optional: true - '@parcel/watcher-android-arm64@2.5.1': - optional: true - - '@parcel/watcher-darwin-arm64@2.5.1': - optional: true - - '@parcel/watcher-darwin-x64@2.5.1': - optional: true - - '@parcel/watcher-freebsd-x64@2.5.1': - optional: true - - '@parcel/watcher-linux-arm-glibc@2.5.1': - optional: true - - '@parcel/watcher-linux-arm-musl@2.5.1': - optional: true - - '@parcel/watcher-linux-arm64-glibc@2.5.1': - optional: true - - '@parcel/watcher-linux-arm64-musl@2.5.1': - optional: true - - '@parcel/watcher-linux-x64-glibc@2.5.1': - optional: true - - '@parcel/watcher-linux-x64-musl@2.5.1': - optional: true - - '@parcel/watcher-win32-arm64@2.5.1': - optional: true - - '@parcel/watcher-win32-ia32@2.5.1': - optional: true - - '@parcel/watcher-win32-x64@2.5.1': - optional: true - - '@parcel/watcher@2.5.1': - dependencies: - detect-libc: 1.0.3 - is-glob: 4.0.3 - micromatch: 4.0.8 - node-addon-api: 7.1.1 - optionalDependencies: - '@parcel/watcher-android-arm64': 2.5.1 - '@parcel/watcher-darwin-arm64': 2.5.1 - '@parcel/watcher-darwin-x64': 2.5.1 - '@parcel/watcher-freebsd-x64': 2.5.1 - '@parcel/watcher-linux-arm-glibc': 2.5.1 - '@parcel/watcher-linux-arm-musl': 2.5.1 - '@parcel/watcher-linux-arm64-glibc': 2.5.1 - '@parcel/watcher-linux-arm64-musl': 2.5.1 - '@parcel/watcher-linux-x64-glibc': 2.5.1 - '@parcel/watcher-linux-x64-musl': 2.5.1 - '@parcel/watcher-win32-arm64': 2.5.1 - '@parcel/watcher-win32-ia32': 2.5.1 - '@parcel/watcher-win32-x64': 2.5.1 - '@pkgr/core@0.2.9': {} '@planetscale/database@1.19.0': @@ -10313,6 +10937,11 @@ snapshots: merge-options: 3.0.4 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + '@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))': + dependencies: + merge-options: 3.0.4 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + '@react-native/assets-registry@0.81.5': {} '@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.28.5)': @@ -10323,6 +10952,14 @@ snapshots: - '@babel/core' - supports-color + '@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.0)': + dependencies: + '@babel/traverse': 7.28.5 + '@react-native/codegen': 0.81.5(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + '@react-native/babel-preset@0.81.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -10373,6 +11010,56 @@ snapshots: transitivePeerDependencies: - supports-color + '@react-native/babel-preset@0.81.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.4(@babel/core@7.29.0) + '@babel/plugin-transform-computed-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-logical-assignment-operators': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-numeric-separator': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.4(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.28.4(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-spread': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/template': 7.27.2 + '@react-native/babel-plugin-codegen': 0.81.5(@babel/core@7.29.0) + babel-plugin-syntax-hermes-parser: 0.29.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + react-refresh: 0.14.2 + transitivePeerDependencies: + - supports-color + '@react-native/codegen@0.81.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -10383,6 +11070,16 @@ snapshots: nullthrows: 1.1.1 yargs: 17.7.2 + '@react-native/codegen@0.81.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.28.5 + glob: 7.2.3 + hermes-parser: 0.29.1 + invariant: 2.2.4 + nullthrows: 1.1.1 + yargs: 17.7.2 + '@react-native/community-cli-plugin@0.81.5': dependencies: '@react-native/dev-middleware': 0.81.5 @@ -10432,6 +11129,15 @@ snapshots: optionalDependencies: '@types/react': 19.1.17 + '@react-native/virtualized-lists@0.81.5(@types/react@19.1.17)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.17 + '@react-navigation/bottom-tabs@7.9.0(@react-navigation/native@7.1.26(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: '@react-navigation/elements': 2.9.3(@react-navigation/native@7.1.26(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -10445,6 +11151,20 @@ snapshots: transitivePeerDependencies: - '@react-native-masked-view/masked-view' + '@react-navigation/bottom-tabs@7.9.0(@react-navigation/native@7.1.26(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + '@react-navigation/elements': 2.9.3(@react-navigation/native@7.1.26(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/native': 7.1.26(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + color: 4.2.3 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + sf-symbols-typescript: 2.2.0 + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + optional: true + '@react-navigation/core@7.13.7(react@19.1.0)': dependencies: '@react-navigation/routers': 7.5.3 @@ -10467,6 +11187,17 @@ snapshots: use-latest-callback: 0.2.6(react@19.1.0) use-sync-external-store: 1.6.0(react@19.1.0) + '@react-navigation/elements@2.9.3(@react-navigation/native@7.1.26(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + '@react-navigation/native': 7.1.26(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + color: 4.2.3 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + use-latest-callback: 0.2.6(react@19.1.0) + use-sync-external-store: 1.6.0(react@19.1.0) + optional: true + '@react-navigation/native-stack@7.9.0(@react-navigation/native@7.1.26(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: '@react-navigation/elements': 2.9.3(@react-navigation/native@7.1.26(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -10481,15 +11212,41 @@ snapshots: transitivePeerDependencies: - '@react-native-masked-view/masked-view' - '@react-navigation/native@7.1.26(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@react-navigation/native-stack@7.9.0(@react-navigation/native@7.1.26(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + '@react-navigation/elements': 2.9.3(@react-navigation/native@7.1.26(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/native': 7.1.26(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + color: 4.2.3 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + sf-symbols-typescript: 2.2.0 + warn-once: 0.1.1 + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + optional: true + + '@react-navigation/native@7.1.26(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + '@react-navigation/core': 7.13.7(react@19.1.0) + escape-string-regexp: 4.0.0 + fast-deep-equal: 3.1.3 + nanoid: 3.3.11 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + use-latest-callback: 0.2.6(react@19.1.0) + + '@react-navigation/native@7.1.26(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: '@react-navigation/core': 7.13.7(react@19.1.0) escape-string-regexp: 4.0.0 fast-deep-equal: 3.1.3 nanoid: 3.3.11 react: 19.1.0 - react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) use-latest-callback: 0.2.6(react@19.1.0) + optional: true '@react-navigation/routers@7.5.3': dependencies: @@ -10587,17 +11344,17 @@ snapshots: tslib: 2.8.1 optional: true - '@tanstack/directive-functions-plugin@1.142.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@tanstack/directive-functions-plugin@1.142.1(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/code-frame': 7.27.1 - '@babel/core': 7.28.5 - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/core': 7.29.0 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 '@tanstack/router-utils': 1.141.0 - babel-dead-code-elimination: 1.0.11 + babel-dead-code-elimination: 1.0.12 pathe: 2.0.3 tiny-invariant: 1.3.3 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color optional: true @@ -10608,9 +11365,9 @@ snapshots: '@tanstack/react-router@1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0)': dependencies: '@tanstack/history': 1.141.0 - '@tanstack/react-store': 0.8.0(react-dom@19.1.1(react@19.1.0))(react@19.1.0) + '@tanstack/react-store': 0.8.1(react-dom@19.1.1(react@19.1.0))(react@19.1.0) '@tanstack/router-core': 1.142.8 - isbot: 5.1.32 + isbot: 5.1.35 react: 19.1.0 react-dom: 19.1.1(react@19.1.0) tiny-invariant: 1.3.3 @@ -10641,19 +11398,19 @@ snapshots: - crossws optional: true - '@tanstack/react-start@1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@tanstack/react-start@1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tanstack/react-router': 1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0) '@tanstack/react-start-client': 1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0) '@tanstack/react-start-server': 1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0) - '@tanstack/router-utils': 1.141.0 + '@tanstack/router-utils': 1.161.4 '@tanstack/start-client-core': 1.142.8 - '@tanstack/start-plugin-core': 1.142.8(@tanstack/react-router@1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/start-plugin-core': 1.142.8(@tanstack/react-router@1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0))(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/start-server-core': 1.142.8 pathe: 2.0.3 react: 19.1.0 react-dom: 19.1.1(react@19.1.0) - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@rsbuild/core' - crossws @@ -10662,9 +11419,9 @@ snapshots: - webpack optional: true - '@tanstack/react-store@0.8.0(react-dom@19.1.1(react@19.1.0))(react@19.1.0)': + '@tanstack/react-store@0.8.1(react-dom@19.1.1(react@19.1.0))(react@19.1.0)': dependencies: - '@tanstack/store': 0.8.0 + '@tanstack/store': 0.8.1 react: 19.1.0 react-dom: 19.1.1(react@19.1.0) use-sync-external-store: 1.6.0(react@19.1.0) @@ -10673,10 +11430,10 @@ snapshots: '@tanstack/router-core@1.142.8': dependencies: '@tanstack/history': 1.141.0 - '@tanstack/store': 0.8.0 + '@tanstack/store': 0.8.1 cookie-es: 2.0.0 - seroval: 1.4.1 - seroval-plugins: 1.4.0(seroval@1.4.1) + seroval: 1.5.0 + seroval-plugins: 1.5.0(seroval@1.5.0) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 optional: true @@ -10686,7 +11443,7 @@ snapshots: '@tanstack/router-core': 1.142.8 '@tanstack/router-utils': 1.141.0 '@tanstack/virtual-file-routes': 1.141.0 - prettier: 3.7.4 + prettier: 3.8.1 recast: 0.23.11 source-map: 0.7.6 tsx: 4.21.0 @@ -10695,54 +11452,69 @@ snapshots: - supports-color optional: true - '@tanstack/router-plugin@1.142.8(@tanstack/react-router@1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@tanstack/router-plugin@1.142.8(@tanstack/react-router@1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0))(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@babel/core': 7.28.5 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/core': 7.29.0 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 '@tanstack/router-core': 1.142.8 '@tanstack/router-generator': 1.142.8 '@tanstack/router-utils': 1.141.0 '@tanstack/virtual-file-routes': 1.141.0 - babel-dead-code-elimination: 1.0.11 + babel-dead-code-elimination: 1.0.12 chokidar: 3.6.0 unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0) - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color optional: true '@tanstack/router-utils@1.141.0': dependencies: - '@babel/core': 7.28.5 - '@babel/generator': 7.28.5 - '@babel/parser': 7.28.5 - '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5) + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.0 + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + ansis: 4.2.0 + diff: 8.0.3 + pathe: 2.0.3 + tinyglobby: 0.2.15 + transitivePeerDependencies: + - supports-color + optional: true + + '@tanstack/router-utils@1.161.4': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 ansis: 4.2.0 - diff: 8.0.2 + babel-dead-code-elimination: 1.0.12 + diff: 8.0.3 pathe: 2.0.3 tinyglobby: 0.2.15 transitivePeerDependencies: - supports-color optional: true - '@tanstack/server-functions-plugin@1.142.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@tanstack/server-functions-plugin@1.142.1(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/code-frame': 7.27.1 - '@babel/core': 7.28.5 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - '@tanstack/directive-functions-plugin': 1.142.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - babel-dead-code-elimination: 1.0.11 + '@babel/core': 7.29.0 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@tanstack/directive-functions-plugin': 1.142.1(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + babel-dead-code-elimination: 1.0.12 tiny-invariant: 1.3.3 transitivePeerDependencies: - supports-color @@ -10753,33 +11525,33 @@ snapshots: dependencies: '@tanstack/router-core': 1.142.8 '@tanstack/start-storage-context': 1.142.8 - seroval: 1.4.1 + seroval: 1.5.0 tiny-invariant: 1.3.3 tiny-warning: 1.0.3 optional: true - '@tanstack/start-plugin-core@1.142.8(@tanstack/react-router@1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@tanstack/start-plugin-core@1.142.8(@tanstack/react-router@1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0))(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/code-frame': 7.26.2 - '@babel/core': 7.28.5 - '@babel/types': 7.28.5 + '@babel/core': 7.29.0 + '@babel/types': 7.29.0 '@rolldown/pluginutils': 1.0.0-beta.40 '@tanstack/router-core': 1.142.8 '@tanstack/router-generator': 1.142.8 - '@tanstack/router-plugin': 1.142.8(@tanstack/react-router@1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/router-plugin': 1.142.8(@tanstack/react-router@1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0))(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/router-utils': 1.141.0 - '@tanstack/server-functions-plugin': 1.142.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/server-functions-plugin': 1.142.1(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/start-client-core': 1.142.8 '@tanstack/start-server-core': 1.142.8 - babel-dead-code-elimination: 1.0.11 - cheerio: 1.1.2 + babel-dead-code-elimination: 1.0.12 + cheerio: 1.2.0 exsolve: 1.0.8 pathe: 2.0.3 srvx: 0.9.8 tinyglobby: 0.2.15 - ufo: 1.6.1 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitefu: 1.1.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + ufo: 1.6.3 + vite: 7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.2(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) xmlbuilder2: 4.0.3 zod: 3.25.76 transitivePeerDependencies: @@ -10798,7 +11570,7 @@ snapshots: '@tanstack/start-client-core': 1.142.8 '@tanstack/start-storage-context': 1.142.8 h3-v2: h3@2.0.1-rc.6 - seroval: 1.4.1 + seroval: 1.5.0 tiny-invariant: 1.3.3 transitivePeerDependencies: - crossws @@ -10809,24 +11581,37 @@ snapshots: '@tanstack/router-core': 1.142.8 optional: true - '@tanstack/store@0.8.0': + '@tanstack/store@0.8.1': optional: true '@tanstack/virtual-file-routes@1.141.0': optional: true - '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@24.10.4))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@24.10.4))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: jest-matcher-utils: 30.2.0 picocolors: 1.1.1 pretty-format: 30.2.0 react: 19.1.0 - react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) react-test-renderer: 19.1.0(react@19.1.0) redent: 3.0.0 optionalDependencies: jest: 29.7.0(@types/node@24.10.4) + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.3.3))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + jest-matcher-utils: 30.2.0 + picocolors: 1.1.1 + pretty-format: 30.2.0 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + react-test-renderer: 19.1.0(react@19.1.0) + redent: 3.0.0 + optionalDependencies: + jest: 29.7.0(@types/node@25.3.3) + optional: true + '@tootallnate/once@2.0.0': {} '@ts-morph/common@0.26.1': @@ -10913,6 +11698,10 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/node@25.3.3': + dependencies: + undici-types: 7.18.2 + '@types/react-dom@19.2.3(@types/react@19.1.17)': dependencies: '@types/react': 19.1.17 @@ -10926,6 +11715,10 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.3.3 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -11155,6 +11948,14 @@ snapshots: optionalDependencies: vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -11179,12 +11980,12 @@ snapshots: dependencies: '@vitest/utils': 3.2.4 fflate: 0.8.2 - flatted: 3.3.3 + flatted: 3.3.4 pathe: 2.0.3 sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) optional: true '@vitest/utils@3.2.4': @@ -11221,6 +12022,9 @@ snapshots: acorn@8.15.0: {} + acorn@8.16.0: + optional: true + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -11381,12 +12185,12 @@ snapshots: aws-ssl-profiles@1.1.2: optional: true - babel-dead-code-elimination@1.0.11: + babel-dead-code-elimination@1.0.12: dependencies: - '@babel/core': 7.28.5 - '@babel/parser': 7.28.5 - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color optional: true @@ -11404,6 +12208,19 @@ snapshots: transitivePeerDependencies: - supports-color + babel-jest@29.7.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.29.0) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + babel-plugin-dynamic-import-node@2.3.3: dependencies: object.assign: 4.1.7 @@ -11434,6 +12251,15 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.29.0): + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 @@ -11442,6 +12268,14 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.29.0) + core-js-compat: 3.47.0 + transitivePeerDependencies: + - supports-color + babel-plugin-polyfill-regenerator@0.6.5(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 @@ -11449,6 +12283,13 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-polyfill-regenerator@0.6.5(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + babel-plugin-react-compiler@1.0.0: dependencies: '@babel/types': 7.28.5 @@ -11465,6 +12306,12 @@ snapshots: transitivePeerDependencies: - '@babel/core' + babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.0): + dependencies: + '@babel/plugin-syntax-flow': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 @@ -11484,6 +12331,25 @@ snapshots: '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.5) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.5) + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + babel-preset-expo@54.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.33)(react-refresh@0.14.2): dependencies: '@babel/helper-module-imports': 7.27.1 @@ -11548,19 +12414,89 @@ snapshots: - '@babel/core' - supports-color + babel-preset-expo@54.0.10(@babel/core@7.29.0)(@babel/runtime@7.28.4)(expo@54.0.33)(react-refresh@0.14.2): + dependencies: + '@babel/helper-module-imports': 7.27.1 + '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-class-static-block': 7.28.3(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.4(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.28.5(@babel/core@7.29.0) + '@babel/preset-react': 7.28.5(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@react-native/babel-preset': 0.81.5(@babel/core@7.29.0) + babel-plugin-react-compiler: 1.0.0 + babel-plugin-react-native-web: 0.21.2 + babel-plugin-syntax-hermes-parser: 0.29.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + debug: 4.4.3 + react-refresh: 0.14.2 + resolve-from: 5.0.0 + optionalDependencies: + '@babel/runtime': 7.28.4 + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + babel-preset-expo@54.0.10(@babel/core@7.29.0)(@babel/runtime@7.28.4)(expo@54.0.33)(react-refresh@0.17.0): + dependencies: + '@babel/helper-module-imports': 7.27.1 + '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-class-static-block': 7.28.3(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.4(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.28.5(@babel/core@7.29.0) + '@babel/preset-react': 7.28.5(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@react-native/babel-preset': 0.81.5(@babel/core@7.29.0) + babel-plugin-react-compiler: 1.0.0 + babel-plugin-react-native-web: 0.21.2 + babel-plugin-syntax-hermes-parser: 0.29.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + debug: 4.4.3 + react-refresh: 0.17.0 + resolve-from: 5.0.0 + optionalDependencies: + '@babel/runtime': 7.28.4 + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + babel-preset-jest@29.6.3(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5) + babel-preset-jest@29.6.3(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + balanced-match@1.0.2: {} base64-js@1.5.1: {} baseline-browser-mapping@2.9.11: {} - better-auth@1.4.18(@tanstack/react-start@1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(kysely@0.28.9)(mysql2@3.16.0))(mysql2@3.16.0)(next@16.0.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.0))(react@19.1.0))(react-dom@19.1.1(react@19.1.0))(react@19.1.0)(vitest@3.2.4): + better-auth@1.4.18(@tanstack/react-start@1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(kysely@0.28.9)(mysql2@3.16.0))(mysql2@3.16.0)(next@16.0.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.0))(react@19.1.0))(react-dom@19.1.1(react@19.1.0))(react@19.1.0)(vitest@3.2.4): dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) @@ -11575,14 +12511,14 @@ snapshots: nanostores: 1.1.0 zod: 4.3.6 optionalDependencies: - '@tanstack/react-start': 1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/react-start': 1.142.8(react-dom@19.1.1(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) drizzle-kit: 0.31.8 drizzle-orm: 0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(kysely@0.28.9)(mysql2@3.16.0) mysql2: 3.16.0 - next: 16.0.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.0))(react@19.1.0) + next: 16.0.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.1(react@19.1.0) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) better-call@1.1.8(zod@4.3.6): dependencies: @@ -11721,6 +12657,9 @@ snapshots: caniuse-lite@1.0.30001761: {} + caniuse-lite@1.0.30001775: + optional: true + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -11763,18 +12702,18 @@ snapshots: domutils: 3.2.2 optional: true - cheerio@1.1.2: + cheerio@1.2.0: dependencies: cheerio-select: 2.1.0 dom-serializer: 2.0.0 domhandler: 5.0.3 domutils: 3.2.2 encoding-sniffer: 0.2.1 - htmlparser2: 10.0.0 + htmlparser2: 10.1.0 parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 7.16.0 + undici: 7.22.0 whatwg-mimetype: 4.0.0 optional: true @@ -11842,8 +12781,7 @@ snapshots: clone@1.0.4: {} - cluster-key-slot@1.1.2: - optional: true + cluster-key-slot@1.1.2: {} co@4.6.0: {} @@ -11944,6 +12882,22 @@ snapshots: - supports-color - ts-node + create-jest@29.7.0(@types/node@25.3.3): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@25.3.3) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + optional: true + cross-env@7.0.3: dependencies: cross-spawn: 7.0.6 @@ -12053,8 +13007,7 @@ snapshots: delayed-stream@1.0.0: {} - denque@2.1.0: - optional: true + denque@2.1.0: {} depd@2.0.0: {} @@ -12062,8 +13015,6 @@ snapshots: destroy@1.2.0: {} - detect-libc@1.0.3: {} - detect-libc@2.1.2: {} detect-newline@3.1.0: {} @@ -12072,7 +13023,7 @@ snapshots: diff-sequences@29.6.3: {} - diff@8.0.2: + diff@8.0.3: optional: true dir-glob@3.0.1: @@ -12156,10 +13107,18 @@ snapshots: ee-first@1.1.1: {} - effect@3.19.14: + effect@4.0.0-beta.23: dependencies: '@standard-schema/spec': 1.1.0 - fast-check: 3.23.2 + fast-check: 4.5.3 + find-my-way-ts: 0.1.6 + ini: 6.0.0 + kubernetes-types: 1.30.0 + msgpackr: 1.11.8 + multipasta: 0.2.7 + toml: 3.0.0 + uuid: 13.0.0 + yaml: 2.8.2 electron-to-chromium@1.5.267: {} @@ -12186,6 +13145,9 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: + optional: true + env-editor@0.4.2: {} error-ex@1.3.4: @@ -12458,7 +13420,7 @@ snapshots: dependencies: eslint: 9.39.2(jiti@2.6.1) - eslint-config-universe@15.0.3(eslint@9.39.2(jiti@2.6.1))(prettier@3.7.4)(typescript@5.9.3): + eslint-config-universe@15.0.3(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -12467,12 +13429,12 @@ snapshots: eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-n: 17.23.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-node: 11.1.0(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-prettier: 5.5.4(eslint-config-prettier@9.1.2(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.7.4) + eslint-plugin-prettier: 5.5.4(eslint-config-prettier@9.1.2(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@2.6.1)) globals: 16.5.0 optionalDependencies: - prettier: 3.7.4 + prettier: 3.8.1 transitivePeerDependencies: - '@types/eslint' - eslint-import-resolver-typescript @@ -12590,10 +13552,10 @@ snapshots: resolve: 1.22.11 semver: 6.3.1 - eslint-plugin-prettier@5.5.4(eslint-config-prettier@9.1.2(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.7.4): + eslint-plugin-prettier@5.5.4(eslint-config-prettier@9.1.2(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1): dependencies: eslint: 9.39.2(jiti@2.6.1) - prettier: 3.7.4 + prettier: 3.8.1 prettier-linter-helpers: 1.0.0 synckit: 0.11.11 optionalDependencies: @@ -12745,6 +13707,16 @@ snapshots: transitivePeerDependencies: - supports-color + expo-asset@12.0.12(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + '@expo/image-utils': 0.8.8 + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-constants: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + transitivePeerDependencies: + - supports-color + expo-constants@18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)): dependencies: '@expo/config': 12.0.13 @@ -12754,6 +13726,15 @@ snapshots: transitivePeerDependencies: - supports-color + expo-constants@18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)): + dependencies: + '@expo/config': 12.0.13 + '@expo/env': 2.0.8 + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + transitivePeerDependencies: + - supports-color + expo-dev-client@6.0.20(expo@54.0.33): dependencies: expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -12788,6 +13769,11 @@ snapshots: expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + expo-file-system@19.0.21(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + expo-font@14.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -12795,6 +13781,13 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + expo-font@14.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + fontfaceobserver: 2.3.0 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + expo-json-utils@0.15.0: {} expo-keep-awake@15.0.8(expo@54.0.33)(react@19.1.0): @@ -12812,6 +13805,16 @@ snapshots: - expo - supports-color + expo-linking@8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + expo-constants: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) + invariant: 2.2.4 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + transitivePeerDependencies: + - expo + - supports-color + expo-localization@17.0.8(expo@54.0.33)(react@19.1.0): dependencies: expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -12826,26 +13829,26 @@ snapshots: transitivePeerDependencies: - supports-color - expo-module-scripts@5.0.8(@babel/core@7.28.5)(@babel/runtime@7.28.4)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(eslint@9.39.2(jiti@2.6.1))(expo@54.0.33)(jest@29.7.0(@types/node@24.10.4))(prettier@3.7.4)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react-refresh@0.17.0)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0): + expo-module-scripts@5.0.8(@babel/core@7.29.0)(@babel/runtime@7.28.4)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.25.12)(eslint@9.39.2(jiti@2.6.1))(expo@54.0.33)(jest@29.7.0(@types/node@24.10.4))(prettier@3.8.1)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react-refresh@0.17.0)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - '@babel/cli': 7.28.3(@babel/core@7.28.5) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.28.5) - '@babel/preset-env': 7.28.5(@babel/core@7.28.5) - '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5) + '@babel/cli': 7.28.3(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/preset-env': 7.28.5(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) '@expo/npm-proofread': 1.0.1 - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@24.10.4))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@24.10.4))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) '@tsconfig/node18': 18.2.6 '@types/jest': 29.5.14 babel-plugin-dynamic-import-node: 2.3.3 - babel-preset-expo: 54.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.33)(react-refresh@0.17.0) + babel-preset-expo: 54.0.10(@babel/core@7.29.0)(@babel/runtime@7.28.4)(expo@54.0.33)(react-refresh@0.17.0) commander: 12.1.0 - eslint-config-universe: 15.0.3(eslint@9.39.2(jiti@2.6.1))(prettier@3.7.4)(typescript@5.9.3) + eslint-config-universe: 15.0.3(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) glob: 13.0.0 - jest-expo: 54.0.17(@babel/core@7.28.5)(expo@54.0.33)(jest@29.7.0(@types/node@24.10.4))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + jest-expo: 54.0.17(@babel/core@7.29.0)(expo@54.0.33)(jest@29.7.0(@types/node@24.10.4))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) jest-snapshot-prettier: prettier@2.8.8 jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@24.10.4)) resolve-workspace-root: 2.0.0 - ts-jest: 29.0.5(@babel/core@7.28.5)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(jest@29.7.0(@types/node@24.10.4))(typescript@5.9.3) + ts-jest: 29.0.5(@babel/core@7.29.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.25.12)(jest@29.7.0(@types/node@24.10.4))(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - '@babel/core' @@ -12884,7 +13887,56 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) - expo-router@6.0.23(9471de8f7829ecb4f3c776787ee824a6): + expo-modules-core@3.0.29(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + invariant: 2.2.4 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + + expo-router@6.0.23(9c9430879de51b484c3e68e2beaf4aeb): + dependencies: + '@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@19.1.1(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@expo/schema-utils': 0.1.8 + '@radix-ui/react-slot': 1.2.0(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.1(react@19.1.0))(react@19.1.0) + '@react-navigation/bottom-tabs': 7.9.0(@react-navigation/native@7.1.26(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/native': 7.1.26(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/native-stack': 7.9.0(@react-navigation/native@7.1.26(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + client-only: 0.0.1 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-constants: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) + expo-linking: 8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-server: 1.0.5 + fast-deep-equal: 3.1.3 + invariant: 2.2.4 + nanoid: 3.3.11 + query-string: 7.1.3 + react: 19.1.0 + react-fast-compare: 3.2.2 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + semver: 7.6.3 + server-only: 0.0.1 + sf-symbols-typescript: 2.2.0 + shallowequal: 1.1.0 + use-latest-callback: 0.2.6(react@19.1.0) + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.1(react@19.1.0))(react@19.1.0) + optionalDependencies: + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@24.10.4))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) + react-dom: 19.1.1(react@19.1.0) + react-native-reanimated: 4.1.6(@babel/core@7.29.0)(react-native-worklets@0.7.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + - '@types/react' + - '@types/react-dom' + - supports-color + optional: true + + expo-router@6.0.23(a9179ff0b15fe10f2a71d6f189aa330e): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@19.1.1(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@expo/schema-utils': 0.1.8 @@ -12917,7 +13969,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.1.0) vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.1(react@19.1.0))(react@19.1.0) optionalDependencies: - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@24.10.4))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.3.3))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) react-dom: 19.1.1(react@19.1.0) react-native-reanimated: 4.1.6(@babel/core@7.28.5)(react-native-worklets@0.7.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) transitivePeerDependencies: @@ -12950,37 +14002,72 @@ snapshots: transitivePeerDependencies: - supports-color - expo-updates-interface@2.0.0(expo@54.0.33): - dependencies: - expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - - expo@54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + expo-updates-interface@2.0.0(expo@54.0.33): + dependencies: + expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + + expo@54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + '@babel/runtime': 7.28.4 + '@expo/cli': 54.0.23(expo-router@6.0.23)(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) + '@expo/config': 12.0.13 + '@expo/config-plugins': 54.0.4 + '@expo/devtools': 0.1.8(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@expo/fingerprint': 0.15.4 + '@expo/metro': 54.2.0 + '@expo/metro-config': 54.0.14(expo@54.0.33) + '@expo/vector-icons': 15.0.3(expo-font@14.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@ungap/structured-clone': 1.3.0 + babel-preset-expo: 54.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.33)(react-refresh@0.14.2) + expo-asset: 12.0.12(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-constants: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) + expo-file-system: 19.0.21(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) + expo-font: 14.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-keep-awake: 15.0.8(expo@54.0.33)(react@19.1.0) + expo-modules-autolinking: 3.0.24 + expo-modules-core: 3.0.29(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + pretty-format: 29.7.0 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + react-refresh: 0.14.2 + whatwg-url-without-unicode: 8.0.0-3 + optionalDependencies: + '@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@19.1.1(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - expo-router + - graphql + - supports-color + - utf-8-validate + + expo@54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: '@babel/runtime': 7.28.4 - '@expo/cli': 54.0.23(expo-router@6.0.23)(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) + '@expo/cli': 54.0.23(expo-router@6.0.23)(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) '@expo/config': 12.0.13 '@expo/config-plugins': 54.0.4 - '@expo/devtools': 0.1.8(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@expo/devtools': 0.1.8(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@expo/fingerprint': 0.15.4 '@expo/metro': 54.2.0 '@expo/metro-config': 54.0.14(expo@54.0.33) - '@expo/vector-icons': 15.0.3(expo-font@14.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@expo/vector-icons': 15.0.3(expo-font@14.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@ungap/structured-clone': 1.3.0 - babel-preset-expo: 54.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.33)(react-refresh@0.14.2) - expo-asset: 12.0.12(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - expo-constants: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) - expo-file-system: 19.0.21(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) - expo-font: 14.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + babel-preset-expo: 54.0.10(@babel/core@7.29.0)(@babel/runtime@7.28.4)(expo@54.0.33)(react-refresh@0.14.2) + expo-asset: 12.0.12(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-constants: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) + expo-file-system: 19.0.21(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) + expo-font: 14.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-keep-awake: 15.0.8(expo@54.0.33)(react@19.1.0) expo-modules-autolinking: 3.0.24 - expo-modules-core: 3.0.29(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-modules-core: 3.0.29(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) pretty-format: 29.7.0 react: 19.1.0 - react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) react-refresh: 0.14.2 whatwg-url-without-unicode: 8.0.0-3 optionalDependencies: - '@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@19.1.1(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@19.1.1(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) transitivePeerDependencies: - '@babel/core' - bufferutil @@ -12993,9 +14080,9 @@ snapshots: exsolve@1.0.8: {} - fast-check@3.23.2: + fast-check@4.5.3: dependencies: - pure-rand: 6.1.0 + pure-rand: 7.0.1 fast-deep-equal@3.1.3: {} @@ -13073,6 +14160,9 @@ snapshots: flatted@3.3.3: {} + flatted@3.3.4: + optional: true + flow-enums-runtime@0.0.6: {} fontfaceobserver@2.3.0: {} @@ -13274,12 +14364,12 @@ snapshots: html-escaper@2.0.2: {} - htmlparser2@10.0.0: + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 domhandler: 5.0.3 domutils: 3.2.2 - entities: 6.0.1 + entities: 7.0.1 optional: true http-errors@2.0.1: @@ -13318,7 +14408,7 @@ snapshots: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.7.1: + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 optional: true @@ -13367,7 +14457,7 @@ snapshots: ini@1.3.8: {} - ini@4.1.3: {} + ini@6.0.0: {} internal-slot@1.1.0: dependencies: @@ -13392,7 +14482,6 @@ snapshots: standard-as-callback: 2.1.0 transitivePeerDependencies: - supports-color - optional: true is-array-buffer@3.0.5: dependencies: @@ -13535,7 +14624,7 @@ snapshots: isarray@2.0.5: {} - isbot@5.1.32: + isbot@5.1.35: optional: true isexe@2.0.0: {} @@ -13641,6 +14730,26 @@ snapshots: - supports-color - ts-node + jest-cli@29.7.0(@types/node@25.3.3): + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@25.3.3) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@25.3.3) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + optional: true + jest-config@29.7.0(@types/node@24.10.4): dependencies: '@babel/core': 7.28.5 @@ -13671,6 +14780,37 @@ snapshots: - babel-plugin-macros - supports-color + jest-config@29.7.0(@types/node@25.3.3): + dependencies: + '@babel/core': 7.28.5 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.28.5) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 25.3.3 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + optional: true + jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -13721,21 +14861,21 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 - jest-expo@54.0.17(@babel/core@7.28.5)(expo@54.0.33)(jest@29.7.0(@types/node@24.10.4))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + jest-expo@54.0.17(@babel/core@7.29.0)(expo@54.0.33)(jest@29.7.0(@types/node@24.10.4))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: '@expo/config': 12.0.13 '@expo/json-file': 10.0.8 '@jest/create-cache-key-function': 29.7.0 '@jest/globals': 29.7.0 - babel-jest: 29.7.0(@babel/core@7.28.5) - expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + babel-jest: 29.7.0(@babel/core@7.29.0) + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) jest-environment-jsdom: 29.7.0 jest-snapshot: 29.7.0 jest-watch-select-projects: 2.0.0 jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@24.10.4)) json5: 2.2.3 lodash: 4.17.21 - react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) react-test-renderer: 19.1.0(react@19.1.0) server-only: 0.0.1 stacktrace-js: 2.0.2 @@ -13975,6 +15115,19 @@ snapshots: - supports-color - ts-node + jest@29.7.0(@types/node@25.3.3): + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@25.3.3) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + optional: true + jimp-compact@0.16.1: {} jiti@2.6.1: {} @@ -14149,11 +15302,9 @@ snapshots: lodash.debounce@4.0.8: {} - lodash.defaults@4.2.0: - optional: true + lodash.defaults@4.2.0: {} - lodash.isarguments@3.1.0: - optional: true + lodash.isarguments@3.1.0: {} lodash.memoize@4.1.2: {} @@ -14186,7 +15337,7 @@ snapshots: dependencies: yallist: 3.1.1 - lru.min@1.1.3: + lru.min@1.1.4: optional: true magic-string@0.30.21: @@ -14298,7 +15449,7 @@ snapshots: metro-source-map@0.83.3: dependencies: '@babel/traverse': 7.28.5 - '@babel/traverse--for-generate-function-map': '@babel/traverse@7.28.5' + '@babel/traverse--for-generate-function-map': '@babel/traverse@7.29.0' '@babel/types': 7.28.5 flow-enums-runtime: 0.0.6 invariant: 2.2.4 @@ -14414,7 +15565,7 @@ snapshots: mime@1.6.0: {} - mime@3.0.0: {} + mime@4.1.0: {} mimic-fn@1.2.0: {} @@ -14474,9 +15625,9 @@ snapshots: aws-ssl-profiles: 1.1.2 denque: 2.1.0 generate-function: 2.3.1 - iconv-lite: 0.7.1 + iconv-lite: 0.7.2 long: 5.3.2 - lru.min: 1.1.3 + lru.min: 1.1.4 named-placeholders: 1.1.6 seq-queue: 0.0.5 sqlstring: 2.3.3 @@ -14490,7 +15641,7 @@ snapshots: named-placeholders@1.1.6: dependencies: - lru.min: 1.1.3 + lru.min: 1.1.4 optional: true nanoid@3.3.11: {} @@ -14513,15 +15664,15 @@ snapshots: optionalDependencies: '@rollup/rollup-linux-x64-gnu': 4.54.0 - next@16.0.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.0))(react@19.1.0): + next@16.0.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 16.0.0 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001761 + caniuse-lite: 1.0.30001775 postcss: 8.4.31 react: 19.1.0 react-dom: 19.1.1(react@19.1.0) - styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.1.0) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.1.0) optionalDependencies: '@next/swc-darwin-arm64': 16.0.0 '@next/swc-darwin-x64': 16.0.0 @@ -14539,10 +15690,10 @@ snapshots: - babel-plugin-macros optional: true - nitro-codegen@0.26.4(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + nitro-codegen@0.26.4(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: chalk: 5.6.2 - react-native-nitro-modules: 0.26.4(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-nitro-modules: 0.26.4(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) ts-morph: 25.0.1 yargs: 17.7.2 zod: 4.3.6 @@ -14550,8 +15701,6 @@ snapshots: - react - react-native - node-addon-api@7.1.1: {} - node-fetch-native@1.6.7: {} node-forge@1.3.3: {} @@ -14850,7 +15999,7 @@ snapshots: prettier@2.8.8: {} - prettier@3.7.4: {} + prettier@3.8.1: {} pretty-bytes@5.6.0: {} @@ -14893,6 +16042,8 @@ snapshots: pure-rand@6.1.0: {} + pure-rand@7.0.1: {} + qrcode-terminal@0.11.0: {} quansync@0.2.11: {} @@ -14956,11 +16107,22 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + react-native-is-edge-to-edge@1.2.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + optional: true + react-native-nitro-modules@0.26.4(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + react-native-nitro-modules@0.26.4(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native-reanimated@4.1.6(@babel/core@7.28.5)(react-native-worklets@0.7.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: '@babel/core': 7.28.5 @@ -14970,11 +16132,27 @@ snapshots: react-native-worklets: 0.7.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) semver: 7.7.2 + react-native-reanimated@4.1.6(@babel/core@7.29.0)(react-native-worklets@0.7.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + '@babel/core': 7.29.0 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-worklets: 0.7.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + semver: 7.7.2 + optional: true + react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + optional: true + react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 @@ -14983,6 +16161,15 @@ snapshots: react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) warn-once: 0.1.1 + react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-freeze: 1.0.4(react@19.1.0) + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + warn-once: 0.1.1 + optional: true + react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: css-select: 5.2.2 @@ -15010,6 +16197,26 @@ snapshots: transitivePeerDependencies: - supports-color + react-native-worklets@0.7.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.4(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/preset-typescript': 7.27.1(@babel/core@7.29.0) + convert-source-map: 2.0.0 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + optional: true + react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0): dependencies: '@jest/create-cache-key-function': 29.7.0 @@ -15057,6 +16264,53 @@ snapshots: - supports-color - utf-8-validate + react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0): + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native/assets-registry': 0.81.5 + '@react-native/codegen': 0.81.5(@babel/core@7.29.0) + '@react-native/community-cli-plugin': 0.81.5 + '@react-native/gradle-plugin': 0.81.5 + '@react-native/js-polyfills': 0.81.5 + '@react-native/normalize-colors': 0.81.5 + '@react-native/virtualized-lists': 0.81.5(@types/react@19.1.17)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-jest: 29.7.0(@babel/core@7.29.0) + babel-plugin-syntax-hermes-parser: 0.29.1 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + glob: 7.2.3 + invariant: 2.2.4 + jest-environment-node: 29.7.0 + memoize-one: 5.2.1 + metro-runtime: 0.83.3 + metro-source-map: 0.83.3 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.1.0 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.26.0 + semver: 7.7.3 + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + ws: 6.2.3 + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.1.17 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + react-refresh@0.14.2: {} react-refresh@0.17.0: {} @@ -15116,13 +16370,11 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 - redis-errors@1.2.0: - optional: true + redis-errors@1.2.0: {} redis-parser@3.0.0: dependencies: redis-errors: 1.2.0 - optional: true reflect.getprototypeof@1.0.10: dependencies: @@ -15307,6 +16559,9 @@ snapshots: semver@7.7.3: {} + semver@7.7.4: + optional: true + send@0.19.2: dependencies: debug: 2.6.9 @@ -15330,12 +16585,12 @@ snapshots: serialize-error@2.1.0: {} - seroval-plugins@1.4.0(seroval@1.4.1): + seroval-plugins@1.5.0(seroval@1.5.0): dependencies: - seroval: 1.4.1 + seroval: 1.5.0 optional: true - seroval@1.4.1: + seroval@1.5.0: optional: true serve-static@1.16.3: @@ -15381,9 +16636,9 @@ snapshots: sharp@0.34.5: dependencies: - '@img/colour': 1.0.0 + '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.7.3 + semver: 7.7.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -15544,8 +16799,7 @@ snapshots: dependencies: type-fest: 0.7.1 - standard-as-callback@2.1.0: - optional: true + standard-as-callback@2.1.0: {} statuses@1.5.0: {} @@ -15654,12 +16908,12 @@ snapshots: structured-headers@0.4.1: {} - styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.1.0): + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.1.0): dependencies: client-only: 0.0.1 react: 19.1.0 optionalDependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 optional: true sucrase@3.35.1: @@ -15813,7 +17067,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.0.5(@babel/core@7.28.5)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(jest@29.7.0(@types/node@24.10.4))(typescript@5.9.3): + ts-jest@29.0.5(@babel/core@7.29.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.25.12)(jest@29.7.0(@types/node@24.10.4))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -15826,9 +17080,9 @@ snapshots: typescript: 5.9.3 yargs-parser: 21.1.1 optionalDependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.5) + babel-jest: 29.7.0(@babel/core@7.29.0) esbuild: 0.25.12 ts-morph@25.0.1: @@ -15955,6 +17209,9 @@ snapshots: ufo@1.6.1: {} + ufo@1.6.3: + optional: true + ultracite@5.0.39(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@clack/prompts': 0.11.0 @@ -16000,9 +17257,11 @@ snapshots: undici-types@7.16.0: {} + undici-types@7.18.2: {} + undici@6.22.0: {} - undici@7.16.0: {} + undici@7.22.0: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -16026,7 +17285,7 @@ snapshots: unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 - acorn: 8.15.0 + acorn: 8.16.0 picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 optional: true @@ -16095,7 +17354,7 @@ snapshots: utils-merge@1.0.1: {} - uuid@11.1.0: {} + uuid@13.0.0: {} uuid@7.0.3: {} @@ -16139,13 +17398,34 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@5.1.4(typescript@5.6.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vite-node@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-tsconfig-paths@5.1.4(typescript@5.6.3)(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.6.3) optionalDependencies: - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript @@ -16167,9 +17447,26 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitefu@1.1.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.54.0 + tinyglobby: 0.2.15 optionalDependencies: - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@types/node': 25.3.3 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.44.1 + tsx: 4.21.0 + yaml: 2.8.2 + + vitefu@1.1.2(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + optionalDependencies: + vite: 7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) optional: true vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): @@ -16216,6 +17513,50 @@ snapshots: - tsx - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 25.3.3 + '@vitest/ui': 3.2.4(vitest@3.2.4) + jsdom: 20.0.3 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vlq@1.0.1: {} w3c-xmlserializer@4.0.0: @@ -16349,6 +17690,8 @@ snapshots: ws@8.18.3: {} + ws@8.19.0: {} + xcode@3.0.1: dependencies: simple-plist: 1.3.1 From b889ea26bdfee26675091f1d136c877cdfc4e9be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Sun, 8 Mar 2026 00:29:52 +0100 Subject: [PATCH 004/129] Add @voidhash/web SDK with analytics, feature flags, and React hooks - scaffold new browser-first `@voidhash/web` package with core client/runtime modules - implement analytics queue/dispatcher, feature flag services, identity/cache/platform layers, and React provider/hooks - add web SDK tests/docs and update `@voidhash/api-spec` schema + lockfile for new contract/deps --- docs/JS-SDK.md | 541 ++++++++++++++++++ libraries/web/build.ts | 36 ++ libraries/web/package.json | 68 +++ libraries/web/src/client-effect.ts | 320 +++++++++++ libraries/web/src/client.ts | 157 +++++ .../src/core/analytics/analytics-context.ts | 25 + .../core/analytics/analytics-dispatcher.ts | 186 ++++++ .../web/src/core/analytics/analytics-queue.ts | 112 ++++ libraries/web/src/core/analytics/contracts.ts | 29 + .../caching/adapters/local-storage-cache.ts | 63 ++ .../src/core/caching/adapters/memory-cache.ts | 21 + .../web/src/core/caching/cache-manager.ts | 165 ++++++ libraries/web/src/core/event-bus.ts | 43 ++ .../feature-flags/feature-flag-service.ts | 100 ++++ .../web/src/core/http/analytics-client.ts | 70 +++ libraries/web/src/core/http/sdk-api-client.ts | 126 ++++ .../web/src/core/identity/identity-manager.ts | 85 +++ .../platform/browser-platform-provider.ts | 119 ++++ libraries/web/src/errors.ts | 31 + libraries/web/src/index.ts | 3 + .../web/src/react/hooks/use-analytics.ts | 25 + .../web/src/react/hooks/use-feature-flags.ts | 103 ++++ libraries/web/src/react/hooks/use-voidhash.ts | 12 + libraries/web/src/react/index.ts | 4 + libraries/web/src/react/provider.tsx | 92 +++ libraries/web/src/types.ts | 114 ++++ libraries/web/tests/analytics.test.ts | 101 ++++ libraries/web/tests/client.test.ts | 165 ++++++ libraries/web/tests/helpers.ts | 55 ++ libraries/web/tests/react.test.tsx | 98 ++++ libraries/web/tsconfig.json | 8 + libraries/web/vitest.unit.mts | 12 + packages/api-spec/src/schema.ts | 15 +- pnpm-lock.yaml | 154 +++-- 34 files changed, 3218 insertions(+), 40 deletions(-) create mode 100644 docs/JS-SDK.md create mode 100644 libraries/web/build.ts create mode 100644 libraries/web/package.json create mode 100644 libraries/web/src/client-effect.ts create mode 100644 libraries/web/src/client.ts create mode 100644 libraries/web/src/core/analytics/analytics-context.ts create mode 100644 libraries/web/src/core/analytics/analytics-dispatcher.ts create mode 100644 libraries/web/src/core/analytics/analytics-queue.ts create mode 100644 libraries/web/src/core/analytics/contracts.ts create mode 100644 libraries/web/src/core/caching/adapters/local-storage-cache.ts create mode 100644 libraries/web/src/core/caching/adapters/memory-cache.ts create mode 100644 libraries/web/src/core/caching/cache-manager.ts create mode 100644 libraries/web/src/core/event-bus.ts create mode 100644 libraries/web/src/core/feature-flags/feature-flag-service.ts create mode 100644 libraries/web/src/core/http/analytics-client.ts create mode 100644 libraries/web/src/core/http/sdk-api-client.ts create mode 100644 libraries/web/src/core/identity/identity-manager.ts create mode 100644 libraries/web/src/core/platform/browser-platform-provider.ts create mode 100644 libraries/web/src/errors.ts create mode 100644 libraries/web/src/index.ts create mode 100644 libraries/web/src/react/hooks/use-analytics.ts create mode 100644 libraries/web/src/react/hooks/use-feature-flags.ts create mode 100644 libraries/web/src/react/hooks/use-voidhash.ts create mode 100644 libraries/web/src/react/index.ts create mode 100644 libraries/web/src/react/provider.tsx create mode 100644 libraries/web/src/types.ts create mode 100644 libraries/web/tests/analytics.test.ts create mode 100644 libraries/web/tests/client.test.ts create mode 100644 libraries/web/tests/helpers.ts create mode 100644 libraries/web/tests/react.test.tsx create mode 100644 libraries/web/tsconfig.json create mode 100644 libraries/web/vitest.unit.mts diff --git a/docs/JS-SDK.md b/docs/JS-SDK.md new file mode 100644 index 000000000..45ff444e4 --- /dev/null +++ b/docs/JS-SDK.md @@ -0,0 +1,541 @@ +# Voidhash Web JS SDK Implementation Plan + +## Summary + +This document outlines the plan for a new browser-first Voidhash JavaScript SDK. + +The implementation should be heavily inspired by the existing React Native SDK, especially around: + +- client creation and initialization +- identity management +- cache management +- event bus usage +- feature flag fetching ergonomics +- React provider and hooks +- error handling and runtime guards + +Version 1 of the web SDK should focus on **analytics** and **feature flags**. + +The following areas are explicitly out of scope for the initial release: + +- paywalls +- payments +- purchase flows +- native/mobile-only platform features + +## Recommended Package Shape + +Create the SDK as a new library: + +- path: `libraries/web` +- package name: `@voidhash/web` + +This keeps the SDK family consistent with the existing React Native package: + +- `libraries/react-native` +- `@voidhash/react-native` + +The web package should expose: + +- a framework-agnostic browser client +- a React integration layer via subpath exports such as `@voidhash/web/react` + +## Goals + +- Ship a browser SDK that feels structurally similar to the React Native SDK. +- Reuse existing shared packages where possible, especially `@voidhash/api-spec` and `@voidhash/shared`. +- Make feature flags production-ready first, because the backend contract already exists. +- Design analytics as a first-class SDK capability for web, even though the current React Native SDK does not yet implement an analytics transport. +- Support anonymous and identified users. +- Provide a React API that mirrors the ergonomics of the React Native hooks where practical. +- Keep the SDK SSR-safe and browser-safe. +- Keep the public surface area small and predictable for the initial release. + +## Non-Goals + +- Paywall rendering +- Payment collection +- Web purchase flows +- Session replay +- Full autocapture analytics +- Framework-specific adapters beyond React +- Multi-platform abstractions that weaken the browser-first design + +## React Native Concepts To Reuse + +The React Native SDK already gives us the right architectural direction. The web SDK should intentionally mirror the same internal module boundaries where they still make sense. + +| React Native concept | Web plan | +| --- | --- | +| `createVoidhashClient` entrypoint | Keep a similar factory as the main way to construct the SDK. | +| Initialization guards in `client.tsx` | Public methods should reject or no-op consistently until initialization completes. | +| `client-effect.ts` runtime orchestration | Keep a dedicated internal runtime layer for networking, caching, identity, and feature flags. | +| `IdentityManager` | Reuse the same anonymous-to-identified user model, adapted to browser storage. | +| `CacheManager` with TTL support | Keep the same cache semantics, backed by memory plus optional persistent browser storage. | +| Event bus | Reuse an internal event bus so hooks and client subscribers share the same update path. | +| `useFeatureFlags` hook | Preserve the same basic return shape and behavior in React. | +| Shared SDK headers | Reuse the existing header model and fill the browser-compatible subset. | + +One important difference: analytics for web will need new implementation work and likely some API-spec additions, because the current React Native SDK does not yet appear to ship an analytics transport. + +## Proposed Directory Layout + +```text +libraries/web/ + package.json + tsconfig.json + src/ + index.ts + client.ts + client-effect.ts + types.ts + errors.ts + core/ + event-bus.ts + http/ + fetch-client.ts + identity/ + identity-manager.ts + caching/ + cache-manager.ts + adapters/ + memory-cache.ts + local-storage-cache.ts + platform/ + browser-platform-provider.ts + feature-flags/ + feature-flag-service.ts + analytics/ + analytics-queue.ts + analytics-dispatcher.ts + analytics-context.ts + react/ + index.ts + provider.tsx + hooks/ + use-voidhash.ts + use-feature-flags.ts + use-analytics.ts +``` + +This layout deliberately mirrors the React Native SDK structure so code can be compared across platforms without unnecessary translation. + +## Public API Proposal + +The initial API should stay compact and map cleanly to the core use cases. + +```ts +import { createVoidhashClient } from "@voidhash/web"; + +const voidhash = createVoidhashClient({ + publishableKey: "pk_live_xxx", + baseUrl: "https://api.voidhash.com", + observerMode: false, + featureFlags: { + bootstrap: false, + }, + analytics: { + enabled: true, + autoPageViews: true, + }, +}); + +await voidhash.initialize(); + +await voidhash.identify("user_123", { + plan: "pro", + companyId: "acme", +}); + +await voidhash.track("checkout_started", { + source: "pricing_page", +}); + +const flags = await voidhash.getFeatureFlags(["new-checkout", "new-nav"]); +const enabled = voidhash.isFeatureEnabled("new-checkout"); +``` + +### Core Client Methods + +- `initialize()` +- `identify(appUserId, attributes?)` +- `resetIdentity()` +- `getFeatureFlags(keys?)` +- `refreshFeatureFlags(keys?)` +- `isFeatureEnabled(key)` +- `getFeatureVariant(key)` +- `track(eventName, properties?, options?)` +- `page(pageName?, properties?)` +- `flushAnalytics()` +- `on(eventName, handler)` +- `off(eventName, handler)` +- `destroy()` + +### React Surface + +```ts +import { + VoidhashProvider, + useFeatureFlags, + useVoidhash, +} from "@voidhash/web/react"; +``` + +The React hooks should mirror the React Native ergonomics as closely as possible: + +- `useVoidhash()` +- `useFeatureFlags(keys?)` +- `useAnalytics()` or direct access through `useVoidhash()` + +## Initialization Lifecycle + +Initialization should follow the same disciplined flow as the React Native SDK: + +1. Validate configuration. +2. Create the browser platform provider. +3. Resolve cache adapters. +4. Resolve or create an anonymous `appUserId`. +5. Build common headers and auth headers. +6. Create the internal runtime services. +7. Mark the client as initialized. +8. Optionally warm feature flag state. + +Important constraints: + +- No browser globals should be touched at module import time. +- `window`, `document`, `navigator`, and `localStorage` access must happen behind runtime guards. +- Public methods should fail with clear SDK errors if called before initialization. +- React components should be able to mount safely in SSR environments without crashing. + +## Identity Model + +The web SDK should preserve the same identity concepts as React Native: + +- anonymous users get an SDK-managed generated `appUserId` +- identified users can replace the anonymous user via `identify` +- user attributes should be synchronized in a predictable order +- resetting identity should rotate back to a new anonymous user + +### Identity Requirements + +- Persist the current `appUserId` in browser storage. +- Keep an in-memory copy for fast request construction. +- Treat identity changes as cache boundaries. +- Clear or segregate user-scoped caches when the identity changes. +- Re-fetch feature flags after `identify` and `resetIdentity`. +- Emit an identity-changed event so React hooks and host apps can respond. + +## Feature Flags Plan + +Feature flags are the most straightforward capability to implement first because the backend contract already exists. + +### Behavior To Preserve From React Native + +- Fetch from the existing server-side flag evaluation endpoint. +- Cache responses by sorted key list. +- Use a 5-minute TTL by default. +- Emit a `feature-flags-fetched` event when fresh data arrives. +- Expose a React hook with: + - `data` + - `isEnabled` + - `getVariant` + - `isLoading` + - `error` + - `refetch` + +### Web-Specific Enhancements + +- Support bootstrapped flags from server-rendered HTML when available. +- Refresh on `visibilitychange` when the tab becomes active again. +- Refresh on `online` when the browser regains connectivity. +- Keep an in-memory cache for fast repeated lookups. +- Optionally persist the latest flag payloads in `localStorage`. + +### Feature Flag Response Shape + +The SDK should preserve the same conceptual shape used today: + +```ts +type FeatureFlagEntry = { + key: string; + enabled: boolean; + variantKey?: string | null; + payload?: unknown; +}; +``` + +### Initial Scope + +- Remote evaluation only +- No local rule engine +- No streaming updates +- No paywall-targeting behavior + +## Analytics Plan + +Analytics is a first-class goal of the web SDK, but it needs a clearer contract than feature flags currently do. + +### Prerequisite: API Contract Alignment + +Before analytics implementation begins, the repo should define or confirm the following in `@voidhash/api-spec`: + +- the analytics ingestion endpoint path +- the request payload shape +- supported batching behavior +- size limits and flush limits +- success and partial-failure semantics +- retryable vs non-retryable errors +- expected headers for browser SDK clients + +This should be treated as a required upstream step. The web SDK should not invent a private analytics contract inside `libraries/web`. + +### Analytics V1 Scope + +- Manual `track` calls +- Manual `page` calls +- Optional automatic initial page-view capture +- Anonymous and identified user support +- Browser context enrichment +- Batched delivery +- Reliable flush on page hide / unload where possible + +### Recommended Event Payload Shape + +The final schema should come from `@voidhash/api-spec`, but the SDK should plan around this structure: + +```ts +type AnalyticsEvent = { + event: string; + properties?: Record; + timestamp: string; + appUserId: string; + anonymousId?: string; + context: { + url?: string; + path?: string; + referrer?: string; + locale?: string; + userAgent?: string; + screen?: { + width: number; + height: number; + }; + }; +}; +``` + +### Delivery Strategy + +- Queue events in memory for low-latency writes. +- Persist a bounded retry queue in browser storage. +- Batch events on a short interval and when the queue reaches a threshold. +- Use `navigator.sendBeacon` when supported for page-exit flushes. +- Fall back to `fetch` with `keepalive` when necessary. +- Apply exponential backoff for retryable failures. +- Drop or quarantine malformed events rather than poisoning the queue. + +### Analytics V1 Non-Goals + +- Click autocapture +- DOM event autocapture +- Session replay +- Heatmaps +- Cross-tab event coordination +- Marketing attribution enrichment beyond basic browser context + +## Headers, Transport, and Platform Data + +The web SDK should reuse the existing SDK header model instead of creating a parallel convention. + +### Header Strategy + +- Reuse publishable-key auth headers. +- Reuse the browser-compatible subset of common SDK headers. +- Set web-specific platform values consistently. +- Continue sending a nonce and SDK version metadata. +- Preserve observer-mode support if it is already part of the shared contract. + +### Browser Platform Mapping + +The browser platform provider should be responsible for gathering: + +- locale information +- user agent information when permitted +- current URL context +- timezone +- screen dimensions +- debug/dev mode hints when available + +Any native-only header fields should either: + +- be omitted when optional, or +- be set through explicit shared defaults rather than ad hoc web-only behavior + +## Storage and Caching Strategy + +Use a layered cache model: + +1. In-memory cache for hot reads +2. `localStorage` adapter for persistence +3. Graceful fallback to memory-only mode when storage is unavailable + +### What Should Be Cached + +- feature flag responses +- current identity +- pending analytics queue +- last successful analytics flush metadata if useful for debugging + +### Cache Rules + +- Keep the 5-minute feature flag TTL from React Native by default. +- Namespace cache keys by environment and `appUserId`. +- Keep cache clearing explicit and testable. +- Bound the analytics queue so storage growth is controlled. + +## Error Handling + +The public client should keep the same pattern as React Native: + +- internal errors stay implementation-specific +- public methods translate them into stable SDK errors +- hooks expose user-actionable errors without leaking internal details + +The web SDK should define clear error categories for: + +- initialization failures +- configuration errors +- analytics dispatch failures +- feature flag fetch failures +- storage access failures +- identity errors + +## React Integration + +The React layer should be a thin wrapper over the core client, not a second implementation. + +### Provider Behavior + +- `VoidhashProvider` accepts an already-created client or client config. +- It initializes the client once on mount. +- It exposes SDK state through context. +- It subscribes to SDK events and updates hooks from the shared event bus. + +### Hook Design + +`useFeatureFlags(keys?)` should preserve the React Native mental model: + +- fetch only when initialized +- reuse cached data when available +- subscribe to `feature-flags-fetched` +- avoid unnecessary rerenders +- expose helpers for `isEnabled` and `getVariant` + +An analytics hook can stay simple in V1: + +- expose `track` +- expose `page` +- expose `flushAnalytics` +- expose analytics enabled/disabled state + +## Build and Publishing Requirements + +- Publish TypeScript types. +- Publish ESM output. +- Publish CJS output only if the repo still requires it. +- Keep the package tree-shakeable. +- Avoid Node-only dependencies. +- Ensure the browser build does not accidentally pull in React unless the React subpath is imported. + +## Testing Plan + +### Unit Tests + +- config validation +- initialization guards +- identity creation and reset +- cache TTL behavior +- feature flag response caching +- analytics queue enqueue/dequeue behavior +- analytics retry rules +- header construction + +### Browser Integration Tests + +- `localStorage` unavailable +- `sendBeacon` available vs unavailable +- `visibilitychange` refresh behavior +- `online` refresh behavior +- multiple tabs with distinct identities +- SSR-safe import and mount behavior + +### Contract Tests + +- feature flag request/response compatibility with `@voidhash/api-spec` +- analytics contract compatibility once defined upstream + +## Example Apps and Documentation + +The SDK should ship with examples in `examples/`: + +- a vanilla JavaScript example +- a React example + +Documentation should cover: + +- installation +- initialization +- identify vs anonymous behavior +- tracking events +- fetching feature flags +- React hooks +- SSR caveats +- storage and privacy behavior + +## Phased Delivery Plan + +| Phase | Scope | Output | +| --- | --- | --- | +| 0 | API and package alignment | Confirm `libraries/web`, confirm `@voidhash/web`, finalize analytics API contract in `@voidhash/api-spec`. | +| 1 | Core runtime | Create client factory, runtime layer, event bus, identity manager, cache manager, browser platform provider, fetch transport. | +| 2 | Feature flags | Implement `getFeatureFlags`, caching, refetch behavior, React hook parity, tests. | +| 3 | Analytics | Implement `track`, `page`, queueing, batching, retrying, page-exit flush, tests. | +| 4 | React layer and examples | Add provider, hooks, example apps, usage docs. | +| 5 | Hardening and release | Add observability, edge-case fixes, publishing config, release docs. | + +## Decisions To Lock Early + +- Use `libraries/web` and `@voidhash/web`. +- Ship a core browser SDK first, with React support as a thin wrapper. +- Keep feature flags server-evaluated in V1. +- Keep analytics manual-first in V1. +- Exclude paywalls and payments completely from the initial implementation. +- Upstream any shared contract changes into `@voidhash/api-spec` before depending on them in the SDK. + +## Open Questions + +- What is the final public analytics ingestion contract? +- Should automatic page-view tracking be enabled by default or opt-in? +- Should bootstrapped feature flags be part of the initial release or a follow-up? +- Do we want a separate package for React helpers later, or is a `/react` subpath enough? +- What observer-mode semantics should the web SDK expose at launch? +- How much user-agent and browser-context enrichment should happen by default from a privacy standpoint? + +## Recommended First Implementation Sequence + +1. Create `libraries/web` with package scaffolding and shared exports. +2. Port the React Native identity, cache, and event bus concepts into browser-safe modules. +3. Implement feature flags first using the existing API contract and 5-minute TTL parity. +4. Add the React provider and `useFeatureFlags` hook. +5. Finalize the analytics API contract in `@voidhash/api-spec`. +6. Implement analytics queueing, batching, and flush behavior. +7. Add examples, tests, and publishable package metadata. + +## Success Criteria For V1 + +- A browser app can initialize the SDK with a publishable key. +- The SDK can manage anonymous and identified users safely. +- Feature flags can be fetched, cached, and consumed from React hooks. +- Analytics events can be queued and delivered reliably from the browser. +- The SDK remains fully independent of paywall and payment code. +- The package fits the existing monorepo and naming conventions cleanly. diff --git a/libraries/web/build.ts b/libraries/web/build.ts new file mode 100644 index 000000000..eeb40bb59 --- /dev/null +++ b/libraries/web/build.ts @@ -0,0 +1,36 @@ +import * as tsup from "tsup"; + +const main = async () => { + await tsup.build({ + dts: true, + entryPoints: { + index: "./src/index.ts", + react: "./src/react/index.ts", + }, + external: ["react"], + format: ["cjs", "esm"], + outDir: "./dist", + outExtension: (ctx) => { + if (ctx.format === "cjs") { + return { + dts: ".d.ts", + js: ".js", + }; + } + + return { + dts: ".d.mts", + js: ".mjs", + }; + }, + sourcemap: true, + splitting: false, + target: "es2022", + }); +}; + +main().catch((error) => { + // biome-ignore lint/suspicious/noConsole: Build script should print the failure. + console.error(error); + process.exit(1); +}); diff --git a/libraries/web/package.json b/libraries/web/package.json new file mode 100644 index 000000000..19cbcf99d --- /dev/null +++ b/libraries/web/package.json @@ -0,0 +1,68 @@ +{ + "name": "@voidhash/web", + "version": "0.0.1-alpha.1", + "description": "Browser-first Voidhash SDK.", + "repository": { + "type": "git", + "url": "https://github.com/voidhashcom/voidhash", + "directory": "libraries/web" + }, + "type": "module", + "files": [ + "dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./react": { + "import": { + "types": "./dist/react.d.ts", + "default": "./dist/react.mjs" + }, + "require": { + "types": "./dist/react.d.ts", + "default": "./dist/react.js" + } + } + }, + "scripts": { + "build": "rm -rf ./dist && tsx build.ts", + "typecheck": "tsgo --noEmit", + "test": "vitest run -c vitest.unit.mts", + "test:watch": "vitest -c vitest.unit.mts" + }, + "dependencies": { + "@effect/platform": "catalog:", + "@voidhash/api-spec": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@voidhash/tsconfig": "workspace:*", + "jsdom": "^20.0.3", + "react": "catalog:", + "react-dom": "catalog:", + "tsup": "^6.1.3", + "tsx": "^4.19.3", + "typescript": "5.6.3", + "vite-tsconfig-paths": "catalog:", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "react": "*" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } +} diff --git a/libraries/web/src/client-effect.ts b/libraries/web/src/client-effect.ts new file mode 100644 index 000000000..3b741c781 --- /dev/null +++ b/libraries/web/src/client-effect.ts @@ -0,0 +1,320 @@ +import { VoidhashConfigurationError } from "./errors"; +import type { + AnalyticsFlushResult, + ResolvedVoidhashConfig, + VoidhashClientOptions, + VoidhashTrackOptions, + VoidhashTraits, +} from "./types"; +import { createAnalyticsEvent } from "./core/analytics/analytics-context"; +import { AnalyticsDispatcher } from "./core/analytics/analytics-dispatcher"; +import { AnalyticsQueue } from "./core/analytics/analytics-queue"; +import { CacheManager } from "./core/caching/cache-manager"; +import { LocalStorageCacheAdapter } from "./core/caching/adapters/local-storage-cache"; +import { MemoryCacheAdapter } from "./core/caching/adapters/memory-cache"; +import { EventBus } from "./core/event-bus"; +import { FeatureFlagService } from "./core/feature-flags/feature-flag-service"; +import { AnalyticsHttpClient } from "./core/http/analytics-client"; +import { SdkApiClient } from "./core/http/sdk-api-client"; +import { IdentityManager } from "./core/identity/identity-manager"; +import { BrowserPlatformProvider } from "./core/platform/browser-platform-provider"; + +const DEFAULT_BASE_URL = "https://api.voidhash.com"; + +const assertPositiveInteger = (name: string, value: number) => { + if (!Number.isInteger(value) || value < 1) { + throw new VoidhashConfigurationError(`${name} must be a positive integer.`); + } +}; + +const deriveAnalyticsBaseUrl = (baseUrl: string, override?: string) => { + if (override) { + return new URL(override).toString(); + } + + const url = new URL(baseUrl); + if (url.hostname.startsWith("api.")) { + url.hostname = `i.${url.hostname.slice(4)}`; + } + + return url.toString(); +}; + +export const resolveVoidhashConfig = ( + options: VoidhashClientOptions +): ResolvedVoidhashConfig => { + const baseUrl = new URL(options.baseUrl ?? DEFAULT_BASE_URL).toString(); + const analyticsBaseUrl = deriveAnalyticsBaseUrl( + baseUrl, + options.analytics?.baseUrl + ); + const maxBatchSize = options.analytics?.maxBatchSize ?? 20; + const maxBatchBytes = options.analytics?.maxBatchBytes ?? 262_144; + const maxQueueSize = options.analytics?.maxQueueSize ?? 1_000; + const flushIntervalMs = options.analytics?.flushIntervalMs ?? 5_000; + const ttlMs = options.featureFlags?.ttlMs ?? 300_000; + + if (!options.publishableKey.trim()) { + throw new VoidhashConfigurationError("publishableKey is required."); + } + + assertPositiveInteger("analytics.maxBatchSize", maxBatchSize); + assertPositiveInteger("analytics.maxBatchBytes", maxBatchBytes); + assertPositiveInteger("analytics.maxQueueSize", maxQueueSize); + assertPositiveInteger("analytics.flushIntervalMs", flushIntervalMs); + assertPositiveInteger("featureFlags.ttlMs", ttlMs); + + return { + analytics: { + baseUrl: analyticsBaseUrl, + enabled: options.analytics?.enabled ?? true, + flushIntervalMs, + maxBatchBytes, + maxBatchSize, + maxQueueSize, + }, + baseUrl, + featureFlags: { + persist: options.featureFlags?.persist ?? true, + prefetchOnInit: options.featureFlags?.prefetchOnInit ?? false, + refreshOnOnline: options.featureFlags?.refreshOnOnline ?? true, + refreshOnVisibility: options.featureFlags?.refreshOnVisibility ?? true, + ttlMs, + }, + initialAppUserId: options.initialAppUserId, + observerMode: options.observerMode ?? false, + publishableKey: options.publishableKey, + }; +}; + +export class VoidhashClientEffect { + private readonly analyticsDispatcher: AnalyticsDispatcher; + private readonly analyticsHttpClient: AnalyticsHttpClient; + private readonly analyticsQueue: AnalyticsQueue; + private readonly cache: CacheManager; + private readonly eventBus: EventBus; + private readonly featureFlags: FeatureFlagService; + private readonly identityManager: IdentityManager; + private listeners: Array<() => void> = []; + private readonly platform: BrowserPlatformProvider; + private readonly sdkApiClient: SdkApiClient; + + constructor(private readonly config: ResolvedVoidhashConfig) { + this.platform = new BrowserPlatformProvider(); + this.eventBus = new EventBus(); + this.cache = new CacheManager( + `@voidhash/web:${this.config.publishableKey}:${this.config.baseUrl}`, + new MemoryCacheAdapter(), + LocalStorageCacheAdapter.create() + ); + this.sdkApiClient = new SdkApiClient( + this.config.baseUrl, + this.config.publishableKey, + this.config.observerMode, + this.platform + ); + this.analyticsHttpClient = new AnalyticsHttpClient( + this.config.analytics.baseUrl, + this.config.publishableKey + ); + this.identityManager = new IdentityManager( + this.cache, + this.sdkApiClient, + this.eventBus, + this.platform + ); + this.featureFlags = new FeatureFlagService( + this.cache, + this.sdkApiClient, + this.eventBus, + this.config.featureFlags.ttlMs, + async () => this.identityManager.getAppUserId() + ); + this.analyticsQueue = new AnalyticsQueue( + this.cache, + this.config.analytics.maxQueueSize + ); + this.analyticsDispatcher = new AnalyticsDispatcher( + this.analyticsQueue, + this.analyticsHttpClient, + { + flushIntervalMs: this.config.analytics.flushIntervalMs, + maxBatchBytes: this.config.analytics.maxBatchBytes, + maxBatchSize: this.config.analytics.maxBatchSize, + }, + this.eventBus + ); + } + + async destroy() { + this.detachBrowserListeners(); + const flushResult = this.config.analytics.enabled + ? await this.analyticsDispatcher.flush({ force: true }).catch(() => null) + : null; + this.analyticsDispatcher.stop(); + await this.sdkApiClient.destroy(); + return flushResult; + } + + getAppUserId() { + return this.identityManager.getAppUserId(); + } + + getEventBus() { + return this.eventBus; + } + + getFeatureVariant(key: string) { + return this.featureFlags.getVariant(key); + } + + isFeatureEnabled(key: string) { + return this.featureFlags.isEnabled(key); + } + + async flushAnalytics(): Promise { + if (!this.config.analytics.enabled) { + return null; + } + + return this.analyticsDispatcher.flush({ force: true }); + } + + async getFeatureFlags(keys?: string[]) { + return this.featureFlags.getFeatureFlags(keys); + } + + async identify(appUserId: string, traits?: VoidhashTraits) { + await this.identityManager.identify(appUserId, traits); + await this.featureFlags.clearCachedFlags(); + await this.featureFlags.refreshTrackedKeySets(); + } + + async initialize() { + const appUserId = await this.identityManager.initialize( + this.config.initialAppUserId + ); + + if (this.config.analytics.enabled) { + this.analyticsDispatcher.start(); + } + + this.attachBrowserListeners(); + + if (this.config.featureFlags.prefetchOnInit) { + await this.featureFlags.getFeatureFlags(); + } + + this.eventBus.emit("initialized", { appUserId }); + } + + async page( + pageName?: string, + properties?: Record, + options?: VoidhashTrackOptions + ) { + const pageProperties = pageName + ? { + ...properties, + page_name: pageName, + } + : properties; + + await this.track("page", pageProperties, options); + } + + async refreshFeatureFlags(keys?: string[]) { + return this.featureFlags.refreshFeatureFlags(keys); + } + + async resetIdentity() { + await this.identityManager.resetIdentity(); + await this.featureFlags.clearCachedFlags(); + await this.featureFlags.refreshTrackedKeySets(); + } + + async track( + eventName: string, + properties?: Record, + options?: VoidhashTrackOptions + ) { + if (!this.config.analytics.enabled) { + return; + } + + const appUserId = await this.identityManager.getAppUserId(); + if (!appUserId) { + return; + } + + const event = createAnalyticsEvent( + this.platform, + eventName, + properties, + options + ); + const droppedCount = await this.analyticsQueue.enqueue({ + appUserId, + id: event.event_id, + payload: event, + }); + + if (droppedCount > 0) { + this.eventBus.emit("error", { + message: `Dropped ${droppedCount} analytics event(s) because the queue is full.`, + source: "analytics", + }); + } + + const queueSize = await this.analyticsQueue.size(); + if (queueSize >= this.config.analytics.maxBatchSize) { + await this.analyticsDispatcher.flush(); + } + } + + private attachBrowserListeners() { + if (typeof window === "undefined") { + return; + } + + const onlineHandler = () => { + if (this.config.featureFlags.refreshOnOnline) { + void this.featureFlags.refreshTrackedKeySets(); + } + }; + const pageHideHandler = () => { + if (this.config.analytics.enabled) { + void this.analyticsDispatcher.flush({ force: true, keepalive: true }); + } + }; + const visibilityHandler = () => { + if ( + this.config.featureFlags.refreshOnVisibility && + typeof document !== "undefined" && + document.visibilityState === "visible" + ) { + void this.featureFlags.refreshTrackedKeySets(); + } + }; + + window.addEventListener("online", onlineHandler); + window.addEventListener("pagehide", pageHideHandler); + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", visibilityHandler); + } + + this.listeners.push(() => window.removeEventListener("online", onlineHandler)); + this.listeners.push(() => window.removeEventListener("pagehide", pageHideHandler)); + if (typeof document !== "undefined") { + this.listeners.push(() => + document.removeEventListener("visibilitychange", visibilityHandler) + ); + } + } + + private detachBrowserListeners() { + for (const cleanup of this.listeners.splice(0)) { + cleanup(); + } + } +} diff --git a/libraries/web/src/client.ts b/libraries/web/src/client.ts new file mode 100644 index 000000000..c149cdce6 --- /dev/null +++ b/libraries/web/src/client.ts @@ -0,0 +1,157 @@ +import { VoidhashDestroyedError, VoidhashNotInitializedError } from "./errors"; +import { VoidhashClientEffect, resolveVoidhashConfig } from "./client-effect"; +import type { + AnalyticsFlushResult, + FeatureFlagsResult, + VoidhashClientOptions, + VoidhashEventMap, + VoidhashEventName, + VoidhashTrackOptions, + VoidhashTraits, +} from "./types"; + +type ClientState = "destroyed" | "idle" | "initializing" | "ready"; + +export class VoidhashWebClient { + private readonly effect: VoidhashClientEffect; + private state: ClientState = "idle"; + private initializePromise: Promise | null = null; + + constructor(private readonly options: VoidhashClientOptions) { + this.effect = new VoidhashClientEffect(resolveVoidhashConfig(options)); + } + + async destroy() { + if (this.state === "destroyed") { + return; + } + + if (this.state === "initializing" && this.initializePromise) { + await this.initializePromise; + } + + await this.effect.destroy(); + this.state = "destroyed"; + } + + getAppUserId() { + if (this.state !== "ready") { + return null; + } + + return this.effect.getAppUserId(); + } + + async getFeatureFlags(keys?: string[]) { + this.ensureReady(); + return this.effect.getFeatureFlags(keys); + } + + getFeatureVariant(key: string) { + this.ensureReady(); + return this.effect.getFeatureVariant(key); + } + + async identify(appUserId: string, traits?: VoidhashTraits) { + this.ensureReady(); + await this.effect.identify(appUserId, traits); + } + + async initialize() { + if (this.state === "destroyed") { + throw new VoidhashDestroyedError(); + } + + if (this.state === "ready") { + return; + } + + if (this.initializePromise) { + return this.initializePromise; + } + + this.state = "initializing"; + this.initializePromise = this.effect + .initialize() + .then(() => { + this.state = "ready"; + }) + .finally(() => { + this.initializePromise = null; + }); + + return this.initializePromise; + } + + isFeatureEnabled(key: string) { + this.ensureReady(); + return this.effect.isFeatureEnabled(key); + } + + off( + eventName: TEvent, + handler: (payload: VoidhashEventMap[TEvent]) => void + ) { + this.effect.getEventBus().off(eventName, handler); + } + + on( + eventName: TEvent, + handler: (payload: VoidhashEventMap[TEvent]) => void + ) { + return this.effect.getEventBus().on(eventName, handler); + } + + async page( + pageName?: string, + properties?: Record, + options?: VoidhashTrackOptions + ) { + this.ensureReady(); + await this.effect.page(pageName, properties, options); + } + + async refreshFeatureFlags(keys?: string[]) { + this.ensureReady(); + return this.effect.refreshFeatureFlags(keys); + } + + async resetIdentity() { + this.ensureReady(); + await this.effect.resetIdentity(); + } + + async track( + eventName: string, + properties?: Record, + options?: VoidhashTrackOptions + ) { + this.ensureReady(); + await this.effect.track(eventName, properties, options); + } + + async flushAnalytics(): Promise { + this.ensureReady(); + return this.effect.flushAnalytics(); + } + + private ensureReady() { + if (this.state === "destroyed") { + throw new VoidhashDestroyedError(); + } + + if (this.state !== "ready") { + throw new VoidhashNotInitializedError(); + } + } +} + +export const createVoidhashClient = (options: VoidhashClientOptions) => + new VoidhashWebClient(options); + +export type { + AnalyticsFlushResult, + FeatureFlagsResult, + VoidhashClientOptions, + VoidhashTrackOptions, +}; diff --git a/libraries/web/src/core/analytics/analytics-context.ts b/libraries/web/src/core/analytics/analytics-context.ts new file mode 100644 index 000000000..8bc99b0bc --- /dev/null +++ b/libraries/web/src/core/analytics/analytics-context.ts @@ -0,0 +1,25 @@ +import type { VoidhashTrackOptions } from "../../types"; +import { BrowserPlatformProvider } from "../platform/browser-platform-provider"; +import type { AnalyticsRequestEvent } from "./contracts"; + +const trimUndefined = (entries: Record) => + Object.fromEntries( + Object.entries(entries).filter(([, value]) => typeof value !== "undefined") + ); + +const createEventId = (platform: BrowserPlatformProvider) => + `evt_${platform.randomId().split("-").join("")}`; + +export const createAnalyticsEvent = ( + platform: BrowserPlatformProvider, + eventName: string, + properties?: Record, + options?: VoidhashTrackOptions +): AnalyticsRequestEvent => ({ + context: platform.buildAnalyticsContext(), + event_id: options?.eventId ?? createEventId(platform), + event_name: eventName, + event_ts: options?.timestamp ?? new Date().toISOString(), + properties: trimUndefined(properties ?? {}), + session_id: options?.sessionId, +}); diff --git a/libraries/web/src/core/analytics/analytics-dispatcher.ts b/libraries/web/src/core/analytics/analytics-dispatcher.ts new file mode 100644 index 000000000..f0243fdc1 --- /dev/null +++ b/libraries/web/src/core/analytics/analytics-dispatcher.ts @@ -0,0 +1,186 @@ +import { VoidhashAnalyticsError } from "../../errors"; +import type { EventBus } from "../event-bus"; +import { AnalyticsHttpClient } from "../http/analytics-client"; +import { AnalyticsQueue } from "./analytics-queue"; +import type { + AnalyticsRequestEvent, + AnalyticsSendBatchResult, + QueuedAnalyticsEvent, +} from "./contracts"; + +const MAX_INGEST_BATCH_SIZE = 100; +const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]); + +const buildIdSet = (events: ReadonlyArray) => + new Set(events.map((event) => event.id)); + +const getBackoffMs = (attempts: number) => + Math.min(1000 * 2 ** Math.max(attempts - 1, 0), 30_000); + +export class AnalyticsDispatcher { + private flushIntervalId: ReturnType | null = null; + private inFlightFlush: Promise | null = null; + + constructor( + private readonly queue: AnalyticsQueue, + private readonly client: AnalyticsHttpClient, + private readonly config: { + readonly flushIntervalMs: number; + readonly maxBatchBytes: number; + readonly maxBatchSize: number; + }, + private readonly eventBus: EventBus + ) {} + + start() { + if (this.flushIntervalId) { + return; + } + + this.flushIntervalId = setInterval(() => { + void this.flush().catch((error) => { + this.eventBus.emit("error", { + error, + message: "Scheduled analytics flush failed.", + source: "analytics", + }); + }); + }, this.config.flushIntervalMs); + } + + stop() { + if (this.flushIntervalId) { + clearInterval(this.flushIntervalId); + this.flushIntervalId = null; + } + } + + async flush(options?: { force?: boolean; keepalive?: boolean }) { + if (this.inFlightFlush) { + return this.inFlightFlush; + } + + this.inFlightFlush = this.flushInternal(options).finally(() => { + this.inFlightFlush = null; + }); + + return this.inFlightFlush; + } + + private async flushInternal(options?: { force?: boolean; keepalive?: boolean }) { + const batch = await this.queue.peekBatch({ + ignoreAvailability: options?.force, + maxBatchBytes: this.config.maxBatchBytes, + maxBatchSize: Math.min(this.config.maxBatchSize, MAX_INGEST_BATCH_SIZE), + }); + + if (batch.length === 0) { + return null; + } + + return this.sendBatch(batch, options); + } + + private async sendBatch( + batch: ReadonlyArray, + options?: { keepalive?: boolean } + ): Promise { + if (batch.length === 0) { + return null; + } + + const distinctId = batch[0]?.appUserId; + if (!distinctId) { + return null; + } + + try { + const result = await this.client.send( + distinctId, + { events: batch.map((entry) => entry.payload as AnalyticsRequestEvent) }, + options + ); + const ids = buildIdSet(batch); + + if (result.status === 202) { + await this.queue.drop(ids); + const response = { + accepted: Number(result.data?.accepted ?? batch.length), + rejected: Number(result.data?.rejected ?? 0), + requestId: + typeof result.data?.request_id === "string" + ? result.data.request_id + : undefined, + }; + + this.eventBus.emit("analytics-flushed", response); + if (response.rejected > 0) { + this.eventBus.emit("analytics-partial-rejection", response); + } + + return response; + } + + if (result.status === 413) { + return this.handlePayloadTooLarge(batch, options); + } + + if (RETRYABLE_STATUS_CODES.has(result.status)) { + await this.queue.postpone( + ids, + Date.now() + getBackoffMs((batch[0]?.attempts ?? 0) + 1) + ); + return null; + } + + await this.queue.drop(ids); + this.eventBus.emit("error", { + message: `Dropping analytics batch after non-retryable ${result.status} response.`, + source: "analytics", + }); + return null; + } catch (error) { + if (error instanceof VoidhashAnalyticsError) { + const ids = buildIdSet(batch); + await this.queue.postpone( + ids, + Date.now() + getBackoffMs((batch[0]?.attempts ?? 0) + 1) + ); + return null; + } + + throw error; + } + } + + private async handlePayloadTooLarge( + batch: ReadonlyArray, + options?: { keepalive?: boolean } + ): Promise { + if (batch.length === 1) { + await this.queue.drop(buildIdSet(batch)); + this.eventBus.emit("error", { + message: "Dropping analytics event after 413 response.", + source: "analytics", + }); + return { + accepted: 0, + rejected: 1, + }; + } + + const midpoint = Math.ceil(batch.length / 2); + const first = await this.sendBatch(batch.slice(0, midpoint), options); + const second = await this.sendBatch(batch.slice(midpoint), options); + + if (!first && !second) { + return null; + } + + return { + accepted: (first?.accepted ?? 0) + (second?.accepted ?? 0), + rejected: (first?.rejected ?? 0) + (second?.rejected ?? 0), + requestId: second?.requestId ?? first?.requestId, + }; + } +} diff --git a/libraries/web/src/core/analytics/analytics-queue.ts b/libraries/web/src/core/analytics/analytics-queue.ts new file mode 100644 index 000000000..1919a5e0e --- /dev/null +++ b/libraries/web/src/core/analytics/analytics-queue.ts @@ -0,0 +1,112 @@ +import type { CacheManager } from "../caching/cache-manager"; +import type { QueuedAnalyticsEvent } from "./contracts"; + +const QUEUE_KEY = "analytics:queue"; + +const estimateEventBytes = (event: QueuedAnalyticsEvent) => + new TextEncoder().encode(JSON.stringify(event.payload)).byteLength; + +export class AnalyticsQueue { + private events: QueuedAnalyticsEvent[] = []; + private isLoaded = false; + + constructor( + private readonly cache: CacheManager, + private readonly maxQueueSize: number + ) {} + + async drop(ids: ReadonlySet) { + await this.load(); + this.events = this.events.filter((event) => !ids.has(event.id)); + await this.persist(); + } + + async enqueue(event: Omit) { + await this.load(); + this.events.push({ + ...event, + attempts: 0, + availableAt: Date.now(), + }); + const droppedCount = Math.max(this.events.length - this.maxQueueSize, 0); + if (droppedCount > 0) { + this.events.splice(0, droppedCount); + } + await this.persist(); + return droppedCount; + } + + async peekBatch(input: { + ignoreAvailability?: boolean; + maxBatchBytes: number; + maxBatchSize: number; + now?: number; + }) { + await this.load(); + const now = input.now ?? Date.now(); + const dueEvents = this.events.filter((event) => + input.ignoreAvailability ? true : event.availableAt <= now + ); + + if (dueEvents.length === 0) { + return []; + } + + const firstAppUserId = dueEvents[0]?.appUserId; + const selected: QueuedAnalyticsEvent[] = []; + let totalBytes = 0; + + for (const event of dueEvents) { + if (event.appUserId !== firstAppUserId) { + break; + } + + const nextBytes = estimateEventBytes(event); + if (selected.length > 0 && totalBytes + nextBytes > input.maxBatchBytes) { + break; + } + + selected.push(event); + totalBytes += nextBytes; + + if (selected.length >= input.maxBatchSize) { + break; + } + } + + return selected; + } + + async postpone(ids: ReadonlySet, nextAvailableAt: number) { + await this.load(); + this.events = this.events.map((event) => + ids.has(event.id) + ? { + ...event, + attempts: event.attempts + 1, + availableAt: nextAvailableAt, + } + : event + ); + await this.persist(); + } + + async size() { + await this.load(); + return this.events.length; + } + + private async load() { + if (this.isLoaded) { + return; + } + + const cached = await this.cache.get(QUEUE_KEY); + this.events = cached?.value ?? []; + this.isLoaded = true; + } + + private async persist() { + await this.cache.set(QUEUE_KEY, this.events); + } +} diff --git a/libraries/web/src/core/analytics/contracts.ts b/libraries/web/src/core/analytics/contracts.ts new file mode 100644 index 000000000..ad392a1af --- /dev/null +++ b/libraries/web/src/core/analytics/contracts.ts @@ -0,0 +1,29 @@ +import type { AnalyticsFlushResult } from "../../types"; + +export interface AnalyticsRequestEvent { + readonly context?: Record; + readonly event_id: string; + readonly event_name: string; + readonly event_ts: string; + readonly properties?: Record; + readonly session_id?: string; +} + +export interface AnalyticsBatchRequest { + readonly events: ReadonlyArray; +} + +export interface QueuedAnalyticsEvent { + readonly appUserId: string; + readonly attempts: number; + readonly availableAt: number; + readonly id: string; + readonly payload: AnalyticsRequestEvent; +} + +export interface AnalyticsTransportResult { + readonly data?: Record; + readonly status: number; +} + +export interface AnalyticsSendBatchResult extends AnalyticsFlushResult {} diff --git a/libraries/web/src/core/caching/adapters/local-storage-cache.ts b/libraries/web/src/core/caching/adapters/local-storage-cache.ts new file mode 100644 index 000000000..03345bc66 --- /dev/null +++ b/libraries/web/src/core/caching/adapters/local-storage-cache.ts @@ -0,0 +1,63 @@ +import { VoidhashStorageError } from "../../../errors"; +import type { CacheAdapter } from "../cache-manager"; + +export class LocalStorageCacheAdapter implements CacheAdapter { + static create() { + if (typeof window === "undefined" || !window.localStorage) { + return null; + } + + try { + const probeKey = "__voidhash_probe__"; + window.localStorage.setItem(probeKey, "1"); + window.localStorage.removeItem(probeKey); + return new LocalStorageCacheAdapter(window.localStorage); + } catch { + return null; + } + } + + constructor(private readonly storage: Storage) {} + + async delete(key: string) { + try { + this.storage.removeItem(key); + } catch (error) { + throw new VoidhashStorageError("Failed to delete from localStorage.", { + cause: error, + }); + } + } + + async get(key: string) { + try { + return this.storage.getItem(key); + } catch (error) { + throw new VoidhashStorageError("Failed to read from localStorage.", { + cause: error, + }); + } + } + + async keys() { + try { + return Array.from({ length: this.storage.length }, (_, index) => + this.storage.key(index) + ).filter((key): key is string => typeof key === "string"); + } catch (error) { + throw new VoidhashStorageError("Failed to enumerate localStorage.", { + cause: error, + }); + } + } + + async set(key: string, value: string) { + try { + this.storage.setItem(key, value); + } catch (error) { + throw new VoidhashStorageError("Failed to write to localStorage.", { + cause: error, + }); + } + } +} diff --git a/libraries/web/src/core/caching/adapters/memory-cache.ts b/libraries/web/src/core/caching/adapters/memory-cache.ts new file mode 100644 index 000000000..4b6f56328 --- /dev/null +++ b/libraries/web/src/core/caching/adapters/memory-cache.ts @@ -0,0 +1,21 @@ +import type { CacheAdapter } from "../cache-manager"; + +export class MemoryCacheAdapter implements CacheAdapter { + private store = new Map(); + + async delete(key: string) { + this.store.delete(key); + } + + async get(key: string) { + return this.store.get(key) ?? null; + } + + async keys() { + return [...this.store.keys()]; + } + + async set(key: string, value: string) { + this.store.set(key, value); + } +} diff --git a/libraries/web/src/core/caching/cache-manager.ts b/libraries/web/src/core/caching/cache-manager.ts new file mode 100644 index 000000000..d0d97c486 --- /dev/null +++ b/libraries/web/src/core/caching/cache-manager.ts @@ -0,0 +1,165 @@ +export interface CacheAdapter { + delete(key: string): Promise; + get(key: string): Promise; + keys(): Promise>; + set(key: string, value: string): Promise; +} + +interface CacheEnvelope { + readonly createdAt: number; + readonly expiresAt: number | null; + readonly staleAt: number | null; + readonly value: T; +} + +export interface CacheHit extends CacheEnvelope { + readonly isExpired: boolean; + readonly isStale: boolean; +} + +const CACHE_INDEX_SUFFIX = "__keys__"; + +export class CacheManager { + private memoryIndex = new Set(); + private readonly persistentIndexKey: string; + + constructor( + private readonly namespace: string, + private readonly memory: CacheAdapter, + private readonly persistent: CacheAdapter | null + ) { + this.persistentIndexKey = this.buildStorageKey(CACHE_INDEX_SUFFIX); + } + + async clearAll() { + const keys = await this.getCacheKeys(); + await Promise.all(keys.map((key) => this.delete(key))); + } + + async clearPrefix(prefix: string) { + const keys = await this.getCacheKeys(); + const matchedKeys = keys.filter((key) => key.startsWith(prefix)); + await Promise.all(matchedKeys.map((key) => this.delete(key))); + } + + async delete(key: string) { + const storageKey = this.buildStorageKey(key); + await this.memory.delete(storageKey); + if (this.persistent) { + await this.persistent.delete(storageKey); + } + this.memoryIndex.delete(storageKey); + await this.persistIndex(); + } + + async get(key: string): Promise | null> { + const storageKey = this.buildStorageKey(key); + const rawValue = + (await this.memory.get(storageKey)) ?? + (this.persistent ? await this.persistent.get(storageKey) : null); + + if (!rawValue) { + return null; + } + + const cachedValue = JSON.parse(rawValue) as CacheEnvelope; + const isExpired = + typeof cachedValue.expiresAt === "number" + ? cachedValue.expiresAt < Date.now() + : false; + const isStale = + typeof cachedValue.staleAt === "number" + ? cachedValue.staleAt < Date.now() + : false; + + if (isExpired) { + await this.delete(key); + return null; + } + + if (!(await this.memory.get(storageKey))) { + await this.memory.set(storageKey, rawValue); + } + + await this.rememberKey(storageKey); + + return { + ...cachedValue, + isExpired, + isStale, + }; + } + + async getCacheKeys() { + const storageKeys = await this.loadIndexedStorageKeys(); + const keys = new Set([ + ...this.memoryIndex, + ...storageKeys, + ]); + + return [...keys] + .filter((key) => key.startsWith(`${this.namespace}:`)) + .filter((key) => key !== this.persistentIndexKey) + .map((key) => key.slice(this.namespace.length + 1)); + } + + async set( + key: string, + value: T, + options?: { staleTime?: number; ttl?: number } + ) { + const storageKey = this.buildStorageKey(key); + const envelope: CacheEnvelope = { + createdAt: Date.now(), + expiresAt: options?.ttl ? Date.now() + options.ttl : null, + staleAt: options?.staleTime ? Date.now() + options.staleTime : null, + value, + }; + const serialized = JSON.stringify(envelope); + + await this.memory.set(storageKey, serialized); + if (this.persistent) { + await this.persistent.set(storageKey, serialized); + } + + await this.rememberKey(storageKey); + } + + private buildStorageKey(key: string) { + return `${this.namespace}:${key}`; + } + + private async loadIndexedStorageKeys() { + if (!this.persistent) { + return []; + } + + const rawIndex = await this.persistent.get(this.persistentIndexKey); + if (!rawIndex) { + return []; + } + + try { + return JSON.parse(rawIndex) as string[]; + } catch { + return []; + } + } + + private async persistIndex() { + const serialized = JSON.stringify([...this.memoryIndex]); + await this.memory.set(this.persistentIndexKey, serialized); + if (this.persistent) { + await this.persistent.set(this.persistentIndexKey, serialized); + } + } + + private async rememberKey(storageKey: string) { + if (storageKey === this.persistentIndexKey) { + return; + } + + this.memoryIndex.add(storageKey); + await this.persistIndex(); + } +} diff --git a/libraries/web/src/core/event-bus.ts b/libraries/web/src/core/event-bus.ts new file mode 100644 index 000000000..22da53101 --- /dev/null +++ b/libraries/web/src/core/event-bus.ts @@ -0,0 +1,43 @@ +import type { VoidhashEventMap, VoidhashEventName } from "../types"; + +export class EventBus { + private listeners: { + [TEvent in VoidhashEventName]: Set< + (payload: VoidhashEventMap[TEvent]) => void + >; + } = { + "analytics-flushed": new Set(), + "analytics-partial-rejection": new Set(), + error: new Set(), + "feature-flags-updated": new Set(), + "identity-changed": new Set(), + initialized: new Set(), + }; + + emit( + event: TEvent, + payload: VoidhashEventMap[TEvent] + ) { + for (const listener of this.listeners[event] ?? []) { + listener(payload); + } + } + + off( + event: TEvent, + listener: (payload: VoidhashEventMap[TEvent]) => void + ) { + this.listeners[event]?.delete(listener); + } + + on( + event: TEvent, + listener: (payload: VoidhashEventMap[TEvent]) => void + ) { + this.listeners[event]?.add(listener); + + return () => { + this.off(event, listener); + }; + } +} diff --git a/libraries/web/src/core/feature-flags/feature-flag-service.ts b/libraries/web/src/core/feature-flags/feature-flag-service.ts new file mode 100644 index 000000000..1ab1aae63 --- /dev/null +++ b/libraries/web/src/core/feature-flags/feature-flag-service.ts @@ -0,0 +1,100 @@ +import type { FeatureFlagEntry, FeatureFlagsResult } from "../../types"; +import type { CacheManager } from "../caching/cache-manager"; +import type { EventBus } from "../event-bus"; +import { SdkApiClient } from "../http/sdk-api-client"; + +const serializeKeys = (keys?: ReadonlyArray) => + keys && keys.length > 0 ? [...keys].sort().join(",") : "all"; + +export class FeatureFlagService { + private latestFlags = new Map(); + private trackedKeySets = new Set(); + + constructor( + private readonly cache: CacheManager, + private readonly sdkApi: SdkApiClient, + private readonly eventBus: EventBus, + private readonly ttlMs: number, + private readonly getAppUserId: () => Promise + ) {} + + async clearCachedFlags() { + this.latestFlags.clear(); + await this.cache.clearPrefix("feature-flags:"); + } + + getTrackedKeys() { + return [...this.trackedKeySets]; + } + + getVariant(key: string) { + return this.latestFlags.get(key) ?? null; + } + + isEnabled(key: string) { + return this.latestFlags.get(key)?.enabled ?? false; + } + + async refreshTrackedKeySets() { + if (this.trackedKeySets.size === 0) { + return; + } + + await Promise.all( + [...this.trackedKeySets].map((serializedKeys) => + this.refreshFeatureFlags( + serializedKeys === "all" ? undefined : serializedKeys.split(",") + ) + ) + ); + } + + async getFeatureFlags(keys?: ReadonlyArray) { + return this.getOrRefreshFeatureFlags(keys, false); + } + + async refreshFeatureFlags(keys?: ReadonlyArray) { + return this.getOrRefreshFeatureFlags(keys, true); + } + + private cacheKey(appUserId: string, keys?: ReadonlyArray) { + return `feature-flags:${appUserId}:${serializeKeys(keys)}`; + } + + private async getOrRefreshFeatureFlags( + keys: ReadonlyArray | undefined, + forceRefresh: boolean + ): Promise { + const appUserId = await this.getAppUserId(); + if (!appUserId) { + return { flags: [] }; + } + + const cacheKey = this.cacheKey(appUserId, keys); + const serializedKeys = serializeKeys(keys); + this.trackedKeySets.add(serializedKeys); + + if (!forceRefresh) { + const cached = await this.cache.get(cacheKey); + if (cached && !cached.isExpired && !cached.isStale) { + this.rememberFlags(cached.value.flags); + return cached.value; + } + } + + const result = await this.sdkApi.evaluateFeatureFlags(appUserId, keys); + this.rememberFlags(result.flags); + await this.cache.set(cacheKey, result, { ttl: this.ttlMs }); + this.eventBus.emit("feature-flags-updated", { + keys, + result, + }); + return result; + } + + private rememberFlags(flags: ReadonlyArray) { + for (const flag of flags) { + this.latestFlags.set(flag.key, flag); + } + } +} diff --git a/libraries/web/src/core/http/analytics-client.ts b/libraries/web/src/core/http/analytics-client.ts new file mode 100644 index 000000000..5d770de97 --- /dev/null +++ b/libraries/web/src/core/http/analytics-client.ts @@ -0,0 +1,70 @@ +import { VoidhashAnalyticsError } from "../../errors"; +import type { + AnalyticsBatchRequest, + AnalyticsTransportResult, +} from "../analytics/contracts"; + +const DEFAULT_TIMEOUT_MS = 10_000; + +export class AnalyticsHttpClient { + constructor( + private readonly baseUrl: string, + private readonly publishableKey: string + ) {} + + async send( + distinctId: string, + request: AnalyticsBatchRequest, + options?: { keepalive?: boolean; timeoutMs?: number } + ): Promise { + const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const controller = + typeof AbortController !== "undefined" && !options?.keepalive + ? new AbortController() + : null; + const timeoutId = + controller && timeoutMs > 0 + ? setTimeout(() => controller.abort(), timeoutMs) + : null; + + try { + const response = await fetch(new URL("/v1/events", this.baseUrl), { + body: JSON.stringify(request), + headers: { + "content-type": "application/json", + "x-distinct-id": distinctId, + "x-publishable-key": this.publishableKey, + }, + keepalive: options?.keepalive ?? false, + method: "POST", + signal: controller?.signal, + }); + let data: Record | undefined; + + try { + data = (await response.json()) as Record; + } catch { + data = undefined; + } + + return { + data, + status: response.status, + }; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new VoidhashAnalyticsError("Analytics flush timed out.", { + cause: error, + }); + } + + throw new VoidhashAnalyticsError("Analytics request failed.", { + cause: error, + }); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } +} diff --git a/libraries/web/src/core/http/sdk-api-client.ts b/libraries/web/src/core/http/sdk-api-client.ts new file mode 100644 index 000000000..63deb7eef --- /dev/null +++ b/libraries/web/src/core/http/sdk-api-client.ts @@ -0,0 +1,126 @@ +import { VoidhashFeatureFlagsError, VoidhashIdentityError } from "../../errors"; +import type { FeatureFlagsResult, VoidhashTraits } from "../../types"; +import { BrowserPlatformProvider } from "../platform/browser-platform-provider"; + +type ApiClientRequest = { + readonly headers: Record; + readonly payload?: Record; +}; + +const trimUndefined = (record: Record) => + Object.fromEntries( + Object.entries(record).filter(([, value]) => typeof value !== "undefined") + ) as Record; + +export class SdkApiClient { + constructor( + private readonly baseUrl: string, + private readonly publishableKey: string, + private readonly observerMode: boolean, + private readonly platform = new BrowserPlatformProvider() + ) {} + + async destroy() {} + + async evaluateFeatureFlags( + appUserId: string, + flagKeys?: ReadonlyArray + ): Promise { + try { + return await this.request("/sdk/evaluate-flags", { + headers: this.buildHeaders(appUserId), + payload: trimUndefined({ + flagKeys, + }), + }); + } catch (error) { + throw new VoidhashFeatureFlagsError("Failed to fetch feature flags.", { + cause: error, + }); + } + } + + async identify( + currentAppUserId: string, + appUserId: string, + traits?: VoidhashTraits + ) { + try { + await this.request("/sdk/identify", { + headers: this.buildHeaders(currentAppUserId), + payload: trimUndefined({ + appUserId, + traits: this.normalizeTraits(traits), + }), + }); + } catch (error) { + throw new VoidhashIdentityError("Failed to identify app user.", { + cause: error, + }); + } + } + + async syncTraits(appUserId: string, traits?: VoidhashTraits) { + try { + await this.request("/sdk/sync-customer-attributes", { + headers: this.buildHeaders(appUserId), + payload: trimUndefined({ + traits: this.normalizeTraits(traits), + }), + }); + } catch (error) { + throw new VoidhashIdentityError("Failed to sync customer traits.", { + cause: error, + }); + } + } + + private buildHeaders(appUserId: string) { + return { + ...this.platform.getSdkHeaders({ + observerMode: this.observerMode, + publishableKey: this.publishableKey, + }), + "x-app-user-id": appUserId, + }; + } + + private normalizeTraits(traits?: VoidhashTraits) { + if (!traits || Object.keys(traits).length === 0) { + return undefined; + } + + return traits; + } + + private async request(path: string, input: ApiClientRequest) { + const controller = + typeof AbortController !== "undefined" ? new AbortController() : null; + const timeoutId = controller + ? setTimeout(() => controller.abort(), 10_000) + : null; + + try { + const response = await fetch(new URL(path, this.baseUrl), { + body: input.payload ? JSON.stringify(input.payload) : undefined, + headers: trimUndefined(input.headers), + method: input.payload ? "POST" : "GET", + signal: controller?.signal, + }); + + if (!response.ok) { + throw new Error(`SDK request failed with status ${response.status}.`); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } +} diff --git a/libraries/web/src/core/identity/identity-manager.ts b/libraries/web/src/core/identity/identity-manager.ts new file mode 100644 index 000000000..8bb94c4f1 --- /dev/null +++ b/libraries/web/src/core/identity/identity-manager.ts @@ -0,0 +1,85 @@ +import { VoidhashIdentityError } from "../../errors"; +import type { EventBus } from "../event-bus"; +import { SdkApiClient } from "../http/sdk-api-client"; +import { BrowserPlatformProvider } from "../platform/browser-platform-provider"; +import type { CacheManager } from "../caching/cache-manager"; +import type { VoidhashTraits } from "../../types"; + +const APP_USER_ID_KEY = "identity:app-user-id"; +const ANONYMOUS_USER_ID_PREFIX = "vh:anon:"; + +const buildTraitsKey = (appUserId: string) => `identity:traits:${appUserId}`; + +export class IdentityManager { + private currentAppUserId: string | null = null; + + constructor( + private readonly cache: CacheManager, + private readonly sdkApi: SdkApiClient, + private readonly eventBus: EventBus, + private readonly platform = new BrowserPlatformProvider() + ) {} + + getAppUserId() { + return this.currentAppUserId; + } + + async identify(appUserId: string, traits?: VoidhashTraits) { + const currentAppUserId = await this.requireAppUserId(); + await this.syncTraits(currentAppUserId); + await this.sdkApi.identify(currentAppUserId, appUserId, traits); + await this.cache.set(APP_USER_ID_KEY, appUserId); + await this.cache.set(buildTraitsKey(appUserId), traits ?? {}); + this.currentAppUserId = appUserId; + this.eventBus.emit("identity-changed", { + appUserId, + previousAppUserId: currentAppUserId, + }); + } + + async initialize(initialAppUserId?: string) { + const cachedAppUserId = await this.cache.get(APP_USER_ID_KEY); + this.currentAppUserId = + initialAppUserId ?? + cachedAppUserId?.value ?? + `${ANONYMOUS_USER_ID_PREFIX}${this.platform.randomId()}`; + + await this.cache.set(APP_USER_ID_KEY, this.currentAppUserId); + + if (initialAppUserId && cachedAppUserId?.value && cachedAppUserId.value !== initialAppUserId) { + await this.identify(initialAppUserId); + return this.currentAppUserId; + } + + return this.currentAppUserId; + } + + async resetIdentity() { + const currentAppUserId = await this.requireAppUserId(); + await this.syncTraits(currentAppUserId); + const nextAnonymousId = `${ANONYMOUS_USER_ID_PREFIX}${this.platform.randomId()}`; + await this.cache.set(APP_USER_ID_KEY, nextAnonymousId); + await this.cache.set(buildTraitsKey(nextAnonymousId), {}); + this.currentAppUserId = nextAnonymousId; + this.eventBus.emit("identity-changed", { + appUserId: nextAnonymousId, + previousAppUserId: currentAppUserId, + }); + } + + async syncTraits(appUserId?: string) { + const resolvedAppUserId = appUserId ?? (await this.requireAppUserId()); + const cachedTraits = await this.cache.get( + buildTraitsKey(resolvedAppUserId) + ); + await this.sdkApi.syncTraits(resolvedAppUserId, cachedTraits?.value); + } + + private async requireAppUserId() { + if (!this.currentAppUserId) { + throw new VoidhashIdentityError("App user id has not been initialized."); + } + + return this.currentAppUserId; + } +} diff --git a/libraries/web/src/core/platform/browser-platform-provider.ts b/libraries/web/src/core/platform/browser-platform-provider.ts new file mode 100644 index 000000000..eb5c44c2e --- /dev/null +++ b/libraries/web/src/core/platform/browser-platform-provider.ts @@ -0,0 +1,119 @@ +const SDK_VERSION = "0.0.1-alpha.1"; + +const trimUndefined = (entries: Record) => + Object.fromEntries( + Object.entries(entries).filter(([, value]) => typeof value !== "undefined") + ); + +const safeUrl = (value: string | undefined) => { + if (!value) { + return null; + } + + try { + return new URL(value); + } catch { + return null; + } +}; + +export class BrowserPlatformProvider { + private getCurrentUrl() { + if (typeof window === "undefined") { + return null; + } + + return safeUrl(window.location.href); + } + + private getReferrerUrl() { + if (typeof document === "undefined") { + return null; + } + + return safeUrl(document.referrer); + } + + private isDebugBuild() { + if (typeof window === "undefined") { + return false; + } + + const hostname = window.location.hostname; + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname.endsWith(".local") + ); + } + + buildAnalyticsContext() { + const currentUrl = this.getCurrentUrl(); + const referrerUrl = this.getReferrerUrl(); + const navigatorRef = + typeof navigator !== "undefined" ? navigator : undefined; + const screenRef = typeof screen !== "undefined" ? screen : undefined; + const viewport = + typeof window !== "undefined" + ? { + viewportHeight: window.innerHeight, + viewportWidth: window.innerWidth, + } + : undefined; + + return trimUndefined({ + locale: navigatorRef?.language, + page_title: + typeof document !== "undefined" ? document.title || undefined : undefined, + referrer_origin: referrerUrl?.origin, + referrer_path: referrerUrl?.pathname, + screen_height: screenRef?.height, + screen_width: screenRef?.width, + sdk: "web", + sdk_version: SDK_VERSION, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + url_origin: currentUrl?.origin, + url_path: currentUrl?.pathname, + user_agent: navigatorRef?.userAgent, + viewport_height: viewport?.viewportHeight, + viewport_width: viewport?.viewportWidth, + }); + } + + getSdkHeaders(input: { + observerMode: boolean; + publishableKey: string; + }): Record { + const navigatorRef = + typeof navigator !== "undefined" ? navigator : undefined; + + return { + "x-client-bundle-id": "", + "x-client-locale": navigatorRef?.language, + "x-client-version": undefined, + "x-is-backgrounded": "false", + "x-is-debug-build": this.isDebugBuild() ? "true" : "false", + "x-nonce": this.randomId(), + "x-observer-mode": input.observerMode ? "true" : "false", + "x-platform": "web", + "x-platform-brand": undefined, + "x-platform-device": navigatorRef?.platform, + "x-platform-flavor": "browser", + "x-platform-flavor-version": undefined, + "x-platform-version": navigatorRef?.userAgent, + "x-preferred-locales": navigatorRef?.languages?.join(","), + "x-publishable-key": input.publishableKey, + "x-sdk": "web", + "x-sdk-version": SDK_VERSION, + "x-storefront": undefined, + }; + } + + randomId() { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + + return `vh_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; + } +} diff --git a/libraries/web/src/errors.ts b/libraries/web/src/errors.ts new file mode 100644 index 000000000..6b375dd1d --- /dev/null +++ b/libraries/web/src/errors.ts @@ -0,0 +1,31 @@ +export class VoidhashError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message); + this.name = new.target.name; + if (options?.cause) { + (this as Error & { cause?: unknown }).cause = options.cause; + } + } +} + +export class VoidhashConfigurationError extends VoidhashError {} + +export class VoidhashNotInitializedError extends VoidhashError { + constructor() { + super("Voidhash client has not been initialized."); + } +} + +export class VoidhashDestroyedError extends VoidhashError { + constructor() { + super("Voidhash client has already been destroyed."); + } +} + +export class VoidhashStorageError extends VoidhashError {} + +export class VoidhashFeatureFlagsError extends VoidhashError {} + +export class VoidhashAnalyticsError extends VoidhashError {} + +export class VoidhashIdentityError extends VoidhashError {} diff --git a/libraries/web/src/index.ts b/libraries/web/src/index.ts new file mode 100644 index 000000000..8061769a6 --- /dev/null +++ b/libraries/web/src/index.ts @@ -0,0 +1,3 @@ +export * from "./client"; +export * from "./errors"; +export * from "./types"; diff --git a/libraries/web/src/react/hooks/use-analytics.ts b/libraries/web/src/react/hooks/use-analytics.ts new file mode 100644 index 000000000..57ad3a799 --- /dev/null +++ b/libraries/web/src/react/hooks/use-analytics.ts @@ -0,0 +1,25 @@ +import React from "react"; + +import { useVoidhash } from "./use-voidhash"; + +export const useAnalytics = () => { + const { client } = useVoidhash(); + + return React.useMemo( + () => ({ + enabled: true, + flushAnalytics: () => client.flushAnalytics(), + page: ( + pageName?: string, + properties?: Record, + options?: { eventId?: string; sessionId?: string; timestamp?: string } + ) => client.page(pageName, properties, options), + track: ( + eventName: string, + properties?: Record, + options?: { eventId?: string; sessionId?: string; timestamp?: string } + ) => client.track(eventName, properties, options), + }), + [client] + ); +}; diff --git a/libraries/web/src/react/hooks/use-feature-flags.ts b/libraries/web/src/react/hooks/use-feature-flags.ts new file mode 100644 index 000000000..a9448a146 --- /dev/null +++ b/libraries/web/src/react/hooks/use-feature-flags.ts @@ -0,0 +1,103 @@ +import React from "react"; + +import type { FeatureFlagsResult } from "../../types"; +import { useVoidhash } from "./use-voidhash"; + +const serializeKeys = (keys?: ReadonlyArray) => + keys && keys.length > 0 ? [...keys].sort().join(",") : "all"; + +export const useFeatureFlags = (keys?: string[]) => { + const { appUserId, client, isInitialized } = useVoidhash(); + const [data, setData] = React.useState({ flags: [] }); + const [error, setError] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const serializedKeys = React.useMemo(() => serializeKeys(keys), [keys]); + const resolvedKeys = React.useMemo( + () => (serializedKeys === "all" ? undefined : serializedKeys.split(",")), + [serializedKeys] + ); + + const updateData = React.useCallback((nextData: FeatureFlagsResult) => { + setData((previous) => + JSON.stringify(previous) === JSON.stringify(nextData) ? previous : nextData + ); + }, []); + + const refetch = React.useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const nextData = await client.refreshFeatureFlags(resolvedKeys); + updateData(nextData); + return nextData; + } catch (cause) { + const nextError = + cause instanceof Error ? cause : new Error("Failed to refresh flags."); + setError(nextError); + throw nextError; + } finally { + setIsLoading(false); + } + }, [client, resolvedKeys, updateData]); + + React.useEffect(() => { + if (!isInitialized) { + return; + } + + let isMounted = true; + setIsLoading(true); + setError(null); + + void client + .getFeatureFlags(resolvedKeys) + .then((nextData) => { + if (isMounted) { + updateData(nextData); + } + }) + .catch((cause) => { + if (isMounted) { + setError( + cause instanceof Error ? cause : new Error("Failed to load flags.") + ); + } + }) + .finally(() => { + if (isMounted) { + setIsLoading(false); + } + }); + + return () => { + isMounted = false; + }; + }, [appUserId, client, isInitialized, resolvedKeys, updateData]); + + React.useEffect(() => { + return client.on("feature-flags-updated", (event) => { + if (serializeKeys(event.keys) === serializedKeys) { + updateData(event.result); + } + }); + }, [client, serializedKeys, updateData]); + + const isEnabled = React.useCallback( + (key: string) => data.flags.find((flag) => flag.key === key)?.enabled ?? false, + [data.flags] + ); + + const getVariant = React.useCallback( + (key: string) => data.flags.find((flag) => flag.key === key) ?? null, + [data.flags] + ); + + return { + data, + error, + getVariant, + isEnabled, + isLoading, + refetch, + }; +}; diff --git a/libraries/web/src/react/hooks/use-voidhash.ts b/libraries/web/src/react/hooks/use-voidhash.ts new file mode 100644 index 000000000..217305cfa --- /dev/null +++ b/libraries/web/src/react/hooks/use-voidhash.ts @@ -0,0 +1,12 @@ +import React from "react"; + +import { VoidhashReactContext } from "../provider"; + +export const useVoidhash = () => { + const context = React.useContext(VoidhashReactContext); + if (!context) { + throw new Error("useVoidhash must be used within a VoidhashProvider."); + } + + return context; +}; diff --git a/libraries/web/src/react/index.ts b/libraries/web/src/react/index.ts new file mode 100644 index 000000000..11de499df --- /dev/null +++ b/libraries/web/src/react/index.ts @@ -0,0 +1,4 @@ +export * from "./provider"; +export * from "./hooks/use-analytics"; +export * from "./hooks/use-feature-flags"; +export * from "./hooks/use-voidhash"; diff --git a/libraries/web/src/react/provider.tsx b/libraries/web/src/react/provider.tsx new file mode 100644 index 000000000..d6f9eab43 --- /dev/null +++ b/libraries/web/src/react/provider.tsx @@ -0,0 +1,92 @@ +import React, { createContext, useEffect, useMemo, useRef, useState } from "react"; + +import { + createVoidhashClient, + type VoidhashClientOptions, + type VoidhashWebClient, +} from "../client"; + +interface ProviderBaseProps { + readonly children: React.ReactNode; +} + +interface ProviderWithClient extends ProviderBaseProps { + readonly client: VoidhashWebClient; + readonly config?: never; +} + +interface ProviderWithConfig extends ProviderBaseProps { + readonly client?: never; + readonly config: VoidhashClientOptions; +} + +export interface VoidhashReactContextValue { + readonly appUserId: string | null; + readonly client: VoidhashWebClient; + readonly isInitialized: boolean; +} + +export const VoidhashReactContext = + createContext(null); + +export function VoidhashProvider(props: ProviderWithClient | ProviderWithConfig) { + const clientRef = useRef(null); + if (!clientRef.current) { + clientRef.current = + "client" in props && props.client + ? props.client + : createVoidhashClient((props as ProviderWithConfig).config); + } + + const client = clientRef.current; + if (!client) { + throw new Error("VoidhashProvider failed to create a client instance."); + } + const [isInitialized, setIsInitialized] = useState(false); + const [appUserId, setAppUserId] = useState(null); + + useEffect(() => { + let isMounted = true; + const removeInitialized = client.on("initialized", ({ appUserId }) => { + if (!isMounted) { + return; + } + setIsInitialized(true); + setAppUserId(appUserId); + }); + const removeIdentityChanged = client.on("identity-changed", ({ appUserId }) => { + if (isMounted) { + setAppUserId(appUserId); + } + }); + + void client.initialize().then(() => { + if (isMounted) { + setIsInitialized(true); + setAppUserId(client.getAppUserId()); + } + }); + + return () => { + isMounted = false; + removeInitialized(); + removeIdentityChanged(); + void client.destroy(); + }; + }, [client]); + + const value = useMemo( + () => ({ + appUserId, + client, + isInitialized, + }), + [appUserId, client, isInitialized] + ); + + return ( + + {props.children} + + ); +} diff --git a/libraries/web/src/types.ts b/libraries/web/src/types.ts new file mode 100644 index 000000000..9bfe9effc --- /dev/null +++ b/libraries/web/src/types.ts @@ -0,0 +1,114 @@ +export type VoidhashTraitValue = string | number | boolean | null; + +export type VoidhashTraits = Record; + +export interface FeatureFlagEntry { + readonly enabled: boolean; + readonly key: string; + readonly payload: unknown | null; + readonly variantKey: string | null; +} + +export interface FeatureFlagsResult { + readonly flags: ReadonlyArray; +} + +export interface VoidhashFeatureFlagsOptions { + readonly persist?: boolean; + readonly prefetchOnInit?: boolean; + readonly refreshOnOnline?: boolean; + readonly refreshOnVisibility?: boolean; + readonly ttlMs?: number; +} + +export interface VoidhashAnalyticsOptions { + readonly baseUrl?: string; + readonly enabled?: boolean; + readonly flushIntervalMs?: number; + readonly maxBatchBytes?: number; + readonly maxBatchSize?: number; + readonly maxQueueSize?: number; +} + +export interface VoidhashClientOptions { + readonly analytics?: VoidhashAnalyticsOptions; + readonly baseUrl?: string; + readonly featureFlags?: VoidhashFeatureFlagsOptions; + readonly initialAppUserId?: string; + readonly observerMode?: boolean; + readonly publishableKey: string; +} + +export interface VoidhashTrackOptions { + readonly eventId?: string; + readonly sessionId?: string; + readonly timestamp?: string; +} + +export interface AnalyticsFlushResult { + readonly accepted: number; + readonly rejected: number; + readonly requestId?: string; +} + +export interface InitializedEvent { + readonly appUserId: string; +} + +export interface IdentityChangedEvent { + readonly appUserId: string; + readonly previousAppUserId: string | null; +} + +export interface FeatureFlagsUpdatedEvent { + readonly keys?: ReadonlyArray; + readonly result: FeatureFlagsResult; +} + +export interface AnalyticsFlushedEvent extends AnalyticsFlushResult {} + +export interface AnalyticsPartialRejectionEvent extends AnalyticsFlushResult {} + +export interface VoidhashErrorEvent { + readonly error?: unknown; + readonly message: string; + readonly source: + | "analytics" + | "client" + | "feature-flags" + | "identity" + | "storage"; +} + +export interface VoidhashEventMap { + readonly "analytics-flushed": AnalyticsFlushedEvent; + readonly "analytics-partial-rejection": AnalyticsPartialRejectionEvent; + readonly error: VoidhashErrorEvent; + readonly "feature-flags-updated": FeatureFlagsUpdatedEvent; + readonly "identity-changed": IdentityChangedEvent; + readonly initialized: InitializedEvent; +} + +export type VoidhashEventName = keyof VoidhashEventMap; + +export interface ResolvedVoidhashConfig { + readonly analytics: { + readonly baseUrl: string; + readonly enabled: boolean; + readonly flushIntervalMs: number; + readonly maxBatchBytes: number; + readonly maxBatchSize: number; + readonly maxQueueSize: number; + }; + readonly baseUrl: string; + readonly featureFlags: { + readonly persist: boolean; + readonly prefetchOnInit: boolean; + readonly refreshOnOnline: boolean; + readonly refreshOnVisibility: boolean; + readonly ttlMs: number; + }; + readonly initialAppUserId?: string; + readonly observerMode: boolean; + readonly publishableKey: string; +} diff --git a/libraries/web/tests/analytics.test.ts b/libraries/web/tests/analytics.test.ts new file mode 100644 index 000000000..43d51d81c --- /dev/null +++ b/libraries/web/tests/analytics.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createVoidhashClient } from "../src/index"; +import { createJsonResponse } from "./helpers"; + +describe("analytics delivery", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("retries a retryable analytics failure on the next flush", async () => { + let analyticsAttempts = 0; + vi.stubGlobal("fetch", vi.fn(async (input: URL | RequestInfo) => { + const url = input.toString(); + if (url.endsWith("/v1/events")) { + analyticsAttempts += 1; + if (analyticsAttempts === 1) { + return createJsonResponse({ error: "try again" }, 503); + } + + return createJsonResponse( + { + accepted: 1, + rejected: 0, + request_id: "req_retry", + }, + 202 + ); + } + + return createJsonResponse({}); + })); + const client = createVoidhashClient({ + analytics: { + flushIntervalMs: 60_000, + }, + publishableKey: "vh_pk_test", + }); + + await client.initialize(); + await client.track("purchase_started"); + + expect(await client.flushAnalytics()).toBeNull(); + expect(await client.flushAnalytics()).toEqual({ + accepted: 1, + rejected: 0, + requestId: "req_retry", + }); + + await client.destroy(); + }); + + it("splits batches when the ingest service returns 413", async () => { + let analyticsAttempts = 0; + vi.stubGlobal("fetch", vi.fn(async (input: URL | RequestInfo) => { + const url = input.toString(); + if (url.endsWith("/v1/events")) { + analyticsAttempts += 1; + if (analyticsAttempts === 1) { + return createJsonResponse({ error: "payload too large" }, 413); + } + + return createJsonResponse( + { + accepted: 1, + rejected: 0, + request_id: `req_${analyticsAttempts}`, + }, + 202 + ); + } + + return createJsonResponse({}); + })); + const client = createVoidhashClient({ + analytics: { + flushIntervalMs: 60_000, + maxBatchBytes: 100_000, + maxBatchSize: 10, + }, + publishableKey: "vh_pk_test", + }); + + await client.initialize(); + await client.track("event_one"); + await client.track("event_two"); + + expect(await client.flushAnalytics()).toEqual({ + accepted: 2, + rejected: 0, + requestId: "req_3", + }); + expect(analyticsAttempts).toBe(3); + + await client.destroy(); + }); +}); diff --git a/libraries/web/tests/client.test.ts b/libraries/web/tests/client.test.ts new file mode 100644 index 000000000..a112bb07d --- /dev/null +++ b/libraries/web/tests/client.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + VoidhashNotInitializedError, + createVoidhashClient, +} from "../src/index"; +import { createJsonResponse, flushMicrotasks, installFetchMock } from "./helpers"; + +describe("VoidhashWebClient", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("rejects operational methods before initialize", async () => { + const client = createVoidhashClient({ + publishableKey: "vh_pk_test", + }); + + await expect(client.getFeatureFlags()).rejects.toBeInstanceOf( + VoidhashNotInitializedError + ); + }); + + it("initializes, fetches flags, derives analytics url, and flushes events", async () => { + const { calls } = installFetchMock((call) => { + if (call.url.endsWith("/sdk/evaluate-flags")) { + return createJsonResponse({ + flags: [ + { + enabled: true, + key: "new-nav", + payload: { color: "blue" }, + variantKey: "on", + }, + ], + }); + } + + if (call.url.endsWith("/v1/events")) { + return createJsonResponse( + { + accepted: 1, + rejected: 0, + request_id: "req_1", + }, + 202 + ); + } + + if (call.url.endsWith("/sdk/sync-customer-attributes")) { + return createJsonResponse({}); + } + + return createJsonResponse({}); + }); + const client = createVoidhashClient({ + analytics: { + flushIntervalMs: 60_000, + }, + baseUrl: "https://api.voidhash.test", + publishableKey: "vh_pk_test", + }); + + await client.initialize(); + const appUserId = client.getAppUserId(); + const flags = await client.getFeatureFlags(["new-nav"]); + await client.track("checkout_started", { source: "pricing_page" }); + const flushResult = await client.flushAnalytics(); + + expect(appUserId).toMatch(/^vh:anon:/); + expect(flags.flags[0]?.key).toBe("new-nav"); + expect(client.isFeatureEnabled("new-nav")).toBe(true); + expect(flushResult).toEqual({ + accepted: 1, + rejected: 0, + requestId: "req_1", + }); + + const analyticsCall = calls.find((call) => call.url.includes("/v1/events")); + expect(analyticsCall?.url).toBe("https://i.voidhash.test/v1/events"); + expect(analyticsCall?.headers["x-distinct-id"]).toBe(appUserId); + + await client.destroy(); + }); + + it("syncs traits before identify and reset identity", async () => { + const { calls } = installFetchMock((call) => { + if (call.url.endsWith("/sdk/identify")) { + return createJsonResponse({ + appUserId: "user_123", + }); + } + + return createJsonResponse({}); + }); + const client = createVoidhashClient({ + analytics: { + enabled: false, + }, + publishableKey: "vh_pk_test", + }); + + await client.initialize(); + const initialAppUserId = client.getAppUserId(); + await client.identify("user_123", { companyId: "acme", plan: "pro" }); + await client.resetIdentity(); + + const syncCalls = calls.filter((call) => + call.url.endsWith("/sdk/sync-customer-attributes") + ); + const identifyCall = calls.find((call) => call.url.endsWith("/sdk/identify")); + + expect(syncCalls[0]?.headers["x-app-user-id"]).toBe(initialAppUserId); + expect(JSON.parse(identifyCall?.body ?? "{}")).toEqual({ + appUserId: "user_123", + traits: { + companyId: "acme", + plan: "pro", + }, + }); + expect(syncCalls[1]?.headers["x-app-user-id"]).toBe("user_123"); + expect(client.getAppUserId()).toMatch(/^vh:anon:/); + + await client.destroy(); + }); + + it("refreshes tracked feature flags when the browser comes back online", async () => { + const { calls } = installFetchMock((call) => { + if (call.url.endsWith("/sdk/evaluate-flags")) { + return createJsonResponse({ + flags: [ + { + enabled: true, + key: "new-nav", + payload: null, + variantKey: "on", + }, + ], + }); + } + + return createJsonResponse({}); + }); + const client = createVoidhashClient({ + analytics: { + enabled: false, + }, + publishableKey: "vh_pk_test", + }); + + await client.initialize(); + await client.getFeatureFlags(["new-nav"]); + window.dispatchEvent(new Event("online")); + await flushMicrotasks(); + + const flagCalls = calls.filter((call) => call.url.endsWith("/sdk/evaluate-flags")); + expect(flagCalls).toHaveLength(2); + + await client.destroy(); + }); +}); diff --git a/libraries/web/tests/helpers.ts b/libraries/web/tests/helpers.ts new file mode 100644 index 000000000..c22e2e44e --- /dev/null +++ b/libraries/web/tests/helpers.ts @@ -0,0 +1,55 @@ +import { vi } from "vitest"; + +export interface FetchCall { + readonly body?: string; + readonly headers: Record; + readonly method: string; + readonly url: string; +} + +export const createJsonResponse = ( + body: Record, + status = 200 +) => + new Response(JSON.stringify(body), { + headers: { + "content-type": "application/json", + }, + status, + }); + +export const flushMicrotasks = async (times = 4) => { + for (let index = 0; index < times; index += 1) { + await Promise.resolve(); + } +}; + +export const installFetchMock = ( + handler: (call: FetchCall) => Promise | Response +) => { + const calls: FetchCall[] = []; + const fetchMock = vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => { + const headers = new Headers(init?.headers); + const call: FetchCall = { + body: + typeof init?.body === "string" + ? init.body + : init?.body instanceof Uint8Array + ? new TextDecoder().decode(init.body) + : undefined, + headers: Object.fromEntries(headers.entries()), + method: init?.method ?? "GET", + url: input.toString(), + }; + + calls.push(call); + return handler(call); + }); + + vi.stubGlobal("fetch", fetchMock); + + return { + calls, + fetchMock, + }; +}; diff --git a/libraries/web/tests/react.test.tsx b/libraries/web/tests/react.test.tsx new file mode 100644 index 000000000..377d628b7 --- /dev/null +++ b/libraries/web/tests/react.test.tsx @@ -0,0 +1,98 @@ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createJsonResponse } from "./helpers"; + +describe("react integration", () => { + afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT; + }); + + it("keeps the root entry importable without react", async () => { + const mod = await import("../src/index"); + + expect(typeof mod.createVoidhashClient).toBe("function"); + }); + + it("renders provider and hooks safely", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + vi.stubGlobal("fetch", vi.fn(async (input: URL | RequestInfo) => { + const url = input.toString(); + if (url.endsWith("/sdk/evaluate-flags")) { + return createJsonResponse({ + flags: [ + { + enabled: true, + key: "new-nav", + payload: null, + variantKey: "on", + }, + ], + }); + } + + return createJsonResponse({}); + })); + + const { + VoidhashProvider, + useFeatureFlags, + useVoidhash, + } = await import("../src/react/index"); + + const container = document.createElement("div"); + const root = createRoot(container); + + function TestComponent() { + const { appUserId, isInitialized } = useVoidhash(); + const flags = useFeatureFlags(["new-nav"]); + + return ( +
+ {String(isInitialized)} + {appUserId ?? ""} + {String(flags.isEnabled("new-nav"))} +
+ ); + } + + await act(async () => { + root.render( + + + + ); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.querySelector('[data-testid="ready"]')?.textContent).toBe( + "true" + ); + expect( + container.querySelector('[data-testid="app-user-id"]')?.textContent + ).toMatch(/^vh:anon:/); + expect(container.querySelector('[data-testid="flag"]')?.textContent).toBe( + "true" + ); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/libraries/web/tsconfig.json b/libraries/web/tsconfig.json new file mode 100644 index 000000000..75343a881 --- /dev/null +++ b/libraries/web/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../packages/tsconfig/react-library.json", + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "rootDir": "." + }, + "include": ["src", "tests", "build.ts", "vitest.unit.mts"] +} diff --git a/libraries/web/vitest.unit.mts b/libraries/web/vitest.unit.mts new file mode 100644 index 000000000..d6abf3f2b --- /dev/null +++ b/libraries/web/vitest.unit.mts @@ -0,0 +1,12 @@ +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + environment: "jsdom", + exclude: ["./node_modules/**", "./dist/**"], + include: ["./tests/**/*.test.ts", "./tests/**/*.test.tsx"], + reporters: ["verbose"], + }, +}); diff --git a/packages/api-spec/src/schema.ts b/packages/api-spec/src/schema.ts index 17cc9cc67..53b02dbcf 100644 --- a/packages/api-spec/src/schema.ts +++ b/packages/api-spec/src/schema.ts @@ -250,11 +250,11 @@ const CommonSdkHeaders = Schema.Struct({ "x-platform": Schema.String, "x-platform-brand": Schema.optional(Schema.String), "x-platform-device": Schema.optional(Schema.String), - "x-platform-flavor": Schema.Literal("native"), + "x-platform-flavor": Schema.Literals(["native", "browser"]), "x-platform-flavor-version": Schema.optional(Schema.String), "x-platform-version": Schema.optional(Schema.String), "x-preferred-locales": Schema.optional(Schema.String), - "x-sdk": Schema.Literal("react-native"), + "x-sdk": Schema.Literals(["react-native", "web"]), "x-sdk-version": Schema.String, "x-storefront": Schema.optional(Schema.String), }); @@ -264,6 +264,15 @@ export const SdkHeaders = Schema.Struct({ ...CommonSdkHeaders.fields, }); +const SdkTraitValue = Schema.Union([ + Schema.String, + Schema.Number, + Schema.Boolean, + Schema.Null, +]); + +const SdkTraits = Schema.Record(Schema.String, SdkTraitValue); + // SDK Identify export class SdkIdentifyBody extends Schema.Class( "SdkIdentifyBody" @@ -271,6 +280,7 @@ export class SdkIdentifyBody extends Schema.Class( appUserId: Schema.String, email: Schema.optional(Schema.String), name: Schema.optional(Schema.String), + traits: Schema.optional(SdkTraits), }) {} // SDK Sync Customer Attributes @@ -279,6 +289,7 @@ export class SdkSyncCustomerAttributesBody extends Schema.Class Date: Tue, 10 Mar 2026 11:18:55 +0100 Subject: [PATCH 005/129] feat: serveer sdk wip (#76) * feat: serveer sdk wip * chore: improve --- libraries/node/build.ts | 34 ++ libraries/node/package.json | 49 +++ libraries/node/src/effect-client.ts | 20 + libraries/node/src/errors.ts | 9 + libraries/node/src/index.ts | 10 + libraries/node/src/internal/client-types.ts | 172 +++++++++ .../node/src/internal/filter-sdk-group.ts | 13 + .../node/src/internal/json-compatible-api.ts | 57 +++ .../src/internal/make-generated-client.ts | 101 +++++ .../internal/normalize-generated-client.ts | 122 ++++++ libraries/node/src/promise-client.ts | 58 +++ libraries/node/src/types.ts | 5 + libraries/node/tests/client.test.ts | 352 ++++++++++++++++++ libraries/node/tests/helpers.ts | 58 +++ libraries/node/tsconfig.json | 12 + libraries/node/vitest.unit.mts | 12 + pnpm-lock.yaml | 31 ++ 17 files changed, 1115 insertions(+) create mode 100644 libraries/node/build.ts create mode 100644 libraries/node/package.json create mode 100644 libraries/node/src/effect-client.ts create mode 100644 libraries/node/src/errors.ts create mode 100644 libraries/node/src/index.ts create mode 100644 libraries/node/src/internal/client-types.ts create mode 100644 libraries/node/src/internal/filter-sdk-group.ts create mode 100644 libraries/node/src/internal/json-compatible-api.ts create mode 100644 libraries/node/src/internal/make-generated-client.ts create mode 100644 libraries/node/src/internal/normalize-generated-client.ts create mode 100644 libraries/node/src/promise-client.ts create mode 100644 libraries/node/src/types.ts create mode 100644 libraries/node/tests/client.test.ts create mode 100644 libraries/node/tests/helpers.ts create mode 100644 libraries/node/tsconfig.json create mode 100644 libraries/node/vitest.unit.mts diff --git a/libraries/node/build.ts b/libraries/node/build.ts new file mode 100644 index 000000000..a6335e077 --- /dev/null +++ b/libraries/node/build.ts @@ -0,0 +1,34 @@ +import * as tsup from "tsup"; + +const main = async () => { + await tsup.build({ + dts: true, + entryPoints: { + index: "./src/index.ts", + }, + format: ["cjs", "esm"], + outDir: "./dist", + outExtension: (ctx) => { + if (ctx.format === "cjs") { + return { + dts: ".d.ts", + js: ".js", + }; + } + + return { + dts: ".d.mts", + js: ".mjs", + }; + }, + sourcemap: true, + splitting: false, + target: "es2022", + }); +}; + +main().catch((error) => { + // biome-ignore lint/suspicious/noConsole: Build script should print the failure. + console.error(error); + process.exit(1); +}); diff --git a/libraries/node/package.json b/libraries/node/package.json new file mode 100644 index 000000000..cf416fb78 --- /dev/null +++ b/libraries/node/package.json @@ -0,0 +1,49 @@ +{ + "name": "@voidhash/node", + "version": "0.0.1-alpha.1", + "description": "Fetch-compatible Voidhash Node SDK.", + "repository": { + "type": "git", + "url": "https://github.com/voidhashcom/voidhash", + "directory": "libraries/node" + }, + "type": "module", + "files": [ + "dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "scripts": { + "build": "rm -rf ./dist && tsx build.ts", + "typecheck": "tsgo --noEmit", + "test": "vitest run -c vitest.unit.mts", + "test:watch": "vitest -c vitest.unit.mts" + }, + "dependencies": { + "@voidhash/api-spec": "workspace:*" + }, + "devDependencies": { + "@effect/platform": "catalog:", + "@voidhash/tsconfig": "workspace:*", + "effect": "catalog:", + "tsx": "^4.19.3", + "typescript": "5.6.3", + "vite-tsconfig-paths": "catalog:", + "vitest": "^3.2.4", + "tsup": "^6.1.3" + }, + "peerDependencies": { + "@effect/platform": "catalog:", + "effect": "catalog:" + } +} diff --git a/libraries/node/src/effect-client.ts b/libraries/node/src/effect-client.ts new file mode 100644 index 000000000..602cf7646 --- /dev/null +++ b/libraries/node/src/effect-client.ts @@ -0,0 +1,20 @@ +import { Effect } from "effect"; + +import type { VoidhashNodeClientOptions } from "./types"; +import type { PublicVoidhashNodeEffectClient } from "./internal/client-types"; +import { + type FilterSdkGroup, + filterSdkGroup, +} from "./internal/filter-sdk-group"; +import { makeGeneratedClient } from "./internal/make-generated-client"; +import { normalizeGeneratedClient } from "./internal/normalize-generated-client"; + +export type VoidhashNodeEffectClient = + FilterSdkGroup; + +export const createVoidhashNodeEffectClient = ( + options: VoidhashNodeClientOptions +): VoidhashNodeEffectClient => + filterSdkGroup( + normalizeGeneratedClient(Effect.runSync(makeGeneratedClient(options))) + ) as VoidhashNodeEffectClient; diff --git a/libraries/node/src/errors.ts b/libraries/node/src/errors.ts new file mode 100644 index 000000000..67e36dc03 --- /dev/null +++ b/libraries/node/src/errors.ts @@ -0,0 +1,9 @@ +export class VoidhashNodeConfigurationError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message); + this.name = new.target.name; + if (options?.cause) { + (this as Error & { cause?: unknown }).cause = options.cause; + } + } +} diff --git a/libraries/node/src/index.ts b/libraries/node/src/index.ts new file mode 100644 index 000000000..2f8f7258c --- /dev/null +++ b/libraries/node/src/index.ts @@ -0,0 +1,10 @@ +export { VoidhashNodeConfigurationError } from "./errors"; +export { + createVoidhashNodeEffectClient, + type VoidhashNodeEffectClient, +} from "./effect-client"; +export { + createVoidhashNodeClient, + type VoidhashNodeClient, +} from "./promise-client"; +export type { VoidhashNodeClientOptions } from "./types"; diff --git a/libraries/node/src/internal/client-types.ts b/libraries/node/src/internal/client-types.ts new file mode 100644 index 000000000..73da656f6 --- /dev/null +++ b/libraries/node/src/internal/client-types.ts @@ -0,0 +1,172 @@ +import type { VoidhashV1Api } from "@voidhash/api-spec"; +import type { Effect } from "effect"; +import type * as Schema from "effect/Schema"; +import type * as HttpClientError from "effect/unstable/http/HttpClientError"; +import type { HttpClientResponse } from "effect/unstable/http"; +import type { HttpApiSchemaError } from "effect/unstable/httpapi/HttpApiError"; +import type { + HttpApi, + HttpApiClient, + HttpApiEndpoint, + HttpApiGroup, + HttpApiMiddleware, +} from "effect/unstable/httpapi"; +import type { Brand } from "effect/Brand"; +import type * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema"; + +type Simplify = { [Key in keyof T]: T[Key] } & {}; + +type ApiGroups = + Api extends HttpApi.HttpApi ? Groups : never; + +type EncodedClientRequest< + Params extends Schema.Top, + Query extends Schema.Top, + Payload extends Schema.Top, + Headers extends Schema.Top, + WithResponse extends boolean, +> = ( + & ([Params["Encoded"]] extends [never] + ? {} + : { readonly params: Params["Encoded"] }) + & ([Query["Encoded"]] extends [never] + ? {} + : { readonly query: Query["Encoded"] }) + & ([Headers["Encoded"]] extends [never] + ? {} + : { readonly headers: Headers["Encoded"] }) + & ([Payload["Encoded"]] extends [never] + ? {} + : Payload["Encoded"] extends infer EncodedPayload + ? EncodedPayload extends + | Brand + | Brand + ? { readonly payload: FormData } + : { readonly payload: Payload["Encoded"] } + : { readonly payload: Payload["Encoded"] }) +) extends infer Request + ? keyof Request extends never + ? void | { readonly withResponse?: WithResponse } + : Request & { readonly withResponse?: WithResponse } + : void; + +type EffectMethod = + Endpoint extends HttpApiEndpoint.HttpApiEndpoint< + infer _Name, + infer _Method, + infer _Path, + infer Params, + infer Query, + infer Payload, + infer Headers, + infer Success, + infer Error, + infer Middleware, + infer _MR + > + ? ( + request: Simplify< + EncodedClientRequest + > + ) => Effect.Effect< + WithResponse extends true + ? [Success["Type"], HttpClientResponse.HttpClientResponse] + : Success["Type"], + | Error["Type"] + | HttpApiMiddleware.Error + | HttpApiMiddleware.ClientError + | HttpApiSchemaError + | HttpClientError.HttpClientError + | Schema.SchemaError, + | Params["DecodingServices"] + | Query["DecodingServices"] + | Payload["DecodingServices"] + | Headers["DecodingServices"] + | Params["EncodingServices"] + | Query["EncodingServices"] + | Payload["EncodingServices"] + | Headers["EncodingServices"] + | Success["DecodingServices"] + | Error["DecodingServices"] + > + : never; + +type EffectTopLevelMethods = + Extract extends + HttpApiGroup.HttpApiGroup + ? Endpoints extends infer Endpoint + ? [HttpApiEndpoint.Name, EffectMethod] + : never + : never; + +type EffectClient = Simplify< + & { + readonly [Group in Extract as HttpApiGroup.Name]: + Group extends HttpApiGroup.HttpApiGroup + ? { + readonly [Endpoint in Endpoints as HttpApiEndpoint.Name]: + EffectMethod; + } + : never; + } + & { + readonly [Method in EffectTopLevelMethods as Method[0]]: Method[1]; + } +>; + +type PromiseTopLevelMethods = + Extract extends + HttpApiGroup.HttpApiGroup + ? Endpoints extends infer Endpoint + ? [HttpApiEndpoint.Name, PromiseMethod] + : never + : never; + +type PromiseMethod = + Endpoint extends HttpApiEndpoint.HttpApiEndpoint< + infer _Name, + infer _Method, + infer _Path, + infer Params, + infer Query, + infer Payload, + infer Headers, + infer Success, + infer _Error, + infer _Middleware, + infer _MR + > + ? ( + request: Simplify< + EncodedClientRequest + > + ) => Promise< + WithResponse extends true + ? [Success["Type"], HttpClientResponse.HttpClientResponse] + : Success["Type"] + > + : never; + +type PromiseClient = Simplify< + & { + readonly [Group in Extract as HttpApiGroup.Name]: + Group extends HttpApiGroup.HttpApiGroup + ? { + readonly [Endpoint in Endpoints as HttpApiEndpoint.Name]: + PromiseMethod; + } + : never; + } + & { + readonly [Method in PromiseTopLevelMethods as Method[0]]: Method[1]; + } +>; + +type PromiseClientForApi = + Api extends HttpApi.HttpApi ? PromiseClient : never; + +export type GeneratedVoidhashNodeEffectClient = HttpApiClient.ForApi; +export type PublicVoidhashNodeEffectClient = EffectClient>; + +export type GeneratedVoidhashNodeClient = PromiseClientForApi; +export type PublicVoidhashNodeClient = PromiseClientForApi; diff --git a/libraries/node/src/internal/filter-sdk-group.ts b/libraries/node/src/internal/filter-sdk-group.ts new file mode 100644 index 000000000..c45eb49b0 --- /dev/null +++ b/libraries/node/src/internal/filter-sdk-group.ts @@ -0,0 +1,13 @@ +export type FilterSdkGroup = TClient extends { readonly sdk: unknown } + ? Omit + : TClient; + +export const filterSdkGroup = ( + client: TClient +): FilterSdkGroup => { + const { sdk: _sdk, ...filteredClient } = client as TClient & { + readonly sdk?: unknown; + }; + + return filteredClient as FilterSdkGroup; +}; diff --git a/libraries/node/src/internal/json-compatible-api.ts b/libraries/node/src/internal/json-compatible-api.ts new file mode 100644 index 000000000..837e9e28b --- /dev/null +++ b/libraries/node/src/internal/json-compatible-api.ts @@ -0,0 +1,57 @@ +import { Schema } from "effect"; +import type { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; + +const cloneWithPrototype = ( + value: T, + properties: Record +): T => + Object.assign(Object.create(Object.getPrototypeOf(value)), value, properties) as T; + +const mapPayloadSchemas = ( + payload: HttpApiEndpoint.AnyWithProps["payload"] +): HttpApiEndpoint.AnyWithProps["payload"] => + new Map( + Array.from(payload.entries(), ([contentType, value]) => [ + contentType, + { + ...value, + schemas: value.schemas.map((schema) => Schema.toCodecJson(schema)) as typeof value.schemas, + }, + ]) + ); + +const mapEndpoint = ( + endpoint: TEndpoint +): TEndpoint => + cloneWithPrototype(endpoint, { + error: new Set( + Array.from(endpoint.error, (schema) => Schema.toCodecJson(schema)) + ) as TEndpoint["error"], + headers: endpoint.headers ? Schema.toCodecJson(endpoint.headers) : undefined, + params: endpoint.params ? Schema.toCodecJson(endpoint.params) : undefined, + payload: mapPayloadSchemas(endpoint.payload) as TEndpoint["payload"], + query: endpoint.query ? Schema.toCodecJson(endpoint.query) : undefined, + success: new Set( + Array.from(endpoint.success, (schema) => Schema.toCodecJson(schema)) + ) as TEndpoint["success"], + }); + +const mapGroup = (group: TGroup): TGroup => + cloneWithPrototype(group, { + endpoints: Object.fromEntries( + Object.entries(group.endpoints).map(([endpointName, endpoint]) => [ + endpointName, + mapEndpoint(endpoint as HttpApiEndpoint.AnyWithProps), + ]) + ) as TGroup["endpoints"], + }); + +export const toJsonCompatibleApi = (api: TApi): TApi => + cloneWithPrototype(api, { + groups: Object.fromEntries( + Object.entries((api as TApi & { groups: Record }).groups).map(([groupName, group]) => [ + groupName, + mapGroup(group as HttpApiGroup.AnyWithProps), + ]) + ), + }); diff --git a/libraries/node/src/internal/make-generated-client.ts b/libraries/node/src/internal/make-generated-client.ts new file mode 100644 index 000000000..55de4d4fd --- /dev/null +++ b/libraries/node/src/internal/make-generated-client.ts @@ -0,0 +1,101 @@ +import { VoidhashV1Api } from "@voidhash/api-spec"; +import { Effect } from "effect"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, +} from "effect/unstable/http"; +import { HttpApiClient } from "effect/unstable/httpapi"; + +import { VoidhashNodeConfigurationError } from "../errors"; +import type { VoidhashNodeClientOptions } from "../types"; +import type { GeneratedVoidhashNodeEffectClient } from "./client-types"; +import { toJsonCompatibleApi } from "./json-compatible-api"; + +export const DEFAULT_BASE_URL = "https://api.voidhash.com"; + +const SECRET_KEY_HEADER = "x-secret-key"; + +const hasSecretKeyHeader = ( + headers: Record | undefined +) => + Object.keys(headers ?? {}).some( + (headerName) => headerName.toLowerCase() === SECRET_KEY_HEADER + ); + +const normalizeHeaders = ( + headers: Record | undefined +): Record => + Object.fromEntries( + Object.entries(headers ?? {}).filter( + (entry): entry is [string, string] => typeof entry[1] === "string" + ) + ); + +const resolveBaseUrl = (baseUrl: string | undefined) => { + try { + const resolved = new URL(baseUrl ?? DEFAULT_BASE_URL); + + if (resolved.protocol !== "http:" && resolved.protocol !== "https:") { + throw new VoidhashNodeConfigurationError( + "baseUrl must use the http or https protocol." + ); + } + + return resolved.toString(); + } catch (error) { + if (error instanceof VoidhashNodeConfigurationError) { + throw error; + } + + throw new VoidhashNodeConfigurationError("baseUrl must be a valid URL.", { + cause: error, + }); + } +}; + +const resolveOptions = (options: VoidhashNodeClientOptions) => { + if (!options.secretKey.trim()) { + throw new VoidhashNodeConfigurationError("secretKey is required."); + } + + if (typeof globalThis.fetch !== "function") { + throw new VoidhashNodeConfigurationError( + "globalThis.fetch must be available." + ); + } + + if (hasSecretKeyHeader(options.headers)) { + throw new VoidhashNodeConfigurationError( + "headers.x-secret-key cannot be set explicitly." + ); + } + + return { + baseUrl: resolveBaseUrl(options.baseUrl), + headers: normalizeHeaders(options.headers), + secretKey: options.secretKey, + }; +}; + +const JsonVoidhashV1Api = toJsonCompatibleApi(VoidhashV1Api); + +export const makeGeneratedClient = ( + options: VoidhashNodeClientOptions +): Effect.Effect => { + const resolvedOptions = resolveOptions(options); + + return HttpApiClient.make(JsonVoidhashV1Api, { + baseUrl: resolvedOptions.baseUrl, + transformClient: (client) => + client.pipe( + HttpClient.mapRequest((request) => + HttpClientRequest.setHeader( + HttpClientRequest.setHeaders(request, resolvedOptions.headers), + SECRET_KEY_HEADER, + resolvedOptions.secretKey + ) + ) + ), + }).pipe(Effect.provide(FetchHttpClient.layer)); +}; diff --git a/libraries/node/src/internal/normalize-generated-client.ts b/libraries/node/src/internal/normalize-generated-client.ts new file mode 100644 index 000000000..bdba221fa --- /dev/null +++ b/libraries/node/src/internal/normalize-generated-client.ts @@ -0,0 +1,122 @@ +import { VoidhashV1Api } from "@voidhash/api-spec"; +import { Effect, Schema } from "effect"; +import { HttpApi, HttpApiEndpoint } from "effect/unstable/httpapi"; + +import type { GeneratedVoidhashNodeEffectClient } from "./client-types"; + +type RequestPartName = "headers" | "params" | "payload" | "query"; + +type EndpointNormalizers = Partial< + Record Effect.Effect> +>; + +const endpointNormalizers = new Map(); + +const endpointKey = (group: string, endpoint: string) => `${group}.${endpoint}`; + +const getPayloadSchema = (endpoint: HttpApiEndpoint.AnyWithProps) => { + const schemas = Array.from(endpoint.payload.values()).flatMap((value) => value.schemas); + + if (schemas.length === 0) { + return undefined; + } + + return schemas.length === 1 ? schemas[0] : Schema.Union(schemas); +}; + +HttpApi.reflect(VoidhashV1Api, { + onGroup: () => {}, + onEndpoint: ({ endpoint, group }) => { + const normalizers: EndpointNormalizers = {}; + + if (endpoint.params) { + normalizers.params = Schema.decodeUnknownEffect(endpoint.params); + } + + if (endpoint.query) { + normalizers.query = Schema.decodeUnknownEffect(endpoint.query); + } + + if (endpoint.headers) { + normalizers.headers = Schema.decodeUnknownEffect(endpoint.headers); + } + + const payloadSchema = getPayloadSchema(endpoint); + if (payloadSchema) { + normalizers.payload = Schema.decodeUnknownEffect(payloadSchema); + } + + endpointNormalizers.set(endpointKey(group.identifier, endpoint.name), normalizers); + }, +}); + +const normalizeRequest = ( + normalizers: EndpointNormalizers, + request: Record | undefined +) => + Effect.gen(function*() { + if (request === undefined) { + return undefined; + } + + const normalized = { + ...request, + }; + + if (normalizers.params && "params" in request) { + normalized.params = yield* normalizers.params(request.params); + } + + if (normalizers.query && "query" in request) { + normalized.query = yield* normalizers.query(request.query); + } + + if (normalizers.headers && "headers" in request) { + normalized.headers = yield* normalizers.headers(request.headers); + } + + if (normalizers.payload && "payload" in request) { + normalized.payload = yield* normalizers.payload(request.payload); + } + + return normalized; + }); + +export const normalizeGeneratedClient = ( + client: GeneratedVoidhashNodeEffectClient +): GeneratedVoidhashNodeEffectClient => + Object.fromEntries( + Object.entries(client).map(([groupName, groupValue]) => { + if (!groupValue || typeof groupValue !== "object") { + return [groupName, groupValue]; + } + + const normalizedGroup = Object.fromEntries( + Object.entries(groupValue).map(([endpointName, endpoint]) => { + if (typeof endpoint !== "function") { + return [endpointName, endpoint]; + } + + const normalizers = endpointNormalizers.get(endpointKey(groupName, endpointName)); + + if (!normalizers) { + return [endpointName, endpoint]; + } + + return [ + endpointName, + (request?: Record) => + Effect.flatMap(normalizeRequest(normalizers, request), (normalized) => + Reflect.apply( + endpoint as (request?: unknown) => Effect.Effect, + groupValue, + [normalized] + ) + ), + ]; + }) + ); + + return [groupName, normalizedGroup]; + }) + ) as GeneratedVoidhashNodeEffectClient; diff --git a/libraries/node/src/promise-client.ts b/libraries/node/src/promise-client.ts new file mode 100644 index 000000000..612f06186 --- /dev/null +++ b/libraries/node/src/promise-client.ts @@ -0,0 +1,58 @@ +import { Effect } from "effect"; + +import { + createVoidhashNodeEffectClient, + type VoidhashNodeEffectClient, +} from "./effect-client"; +import type { PublicVoidhashNodeClient } from "./internal/client-types"; +import type { FilterSdkGroup } from "./internal/filter-sdk-group"; +import type { VoidhashNodeClientOptions } from "./types"; + +type RuntimePromisifyClient = { + readonly [Key in keyof TClient]: TClient[Key] extends ( + ...args: infer Args + ) => Effect.Effect + ? (...args: Args) => Promise + : TClient[Key] extends object + ? RuntimePromisifyClient + : TClient[Key]; +}; + +const promisifyClient = ( + client: TClient +): RuntimePromisifyClient => { + const entries = Object.entries(client).map(([key, value]) => { + if (typeof value === "function") { + return [ + key, + (...args: Array) => + Effect.runPromise( + Reflect.apply( + value as (...parameters: Array) => Effect.Effect, + client, + args + ) + ), + ]; + } + + if (value && typeof value === "object") { + return [key, promisifyClient(value)]; + } + + return [key, value]; + }); + + return Object.fromEntries(entries) as RuntimePromisifyClient; +}; + +export type VoidhashNodeClient = FilterSdkGroup; + +export const createVoidhashNodeClient = ( + options: VoidhashNodeClientOptions +): VoidhashNodeClient => + promisifyClient( + createVoidhashNodeEffectClient(options) + ) as unknown as VoidhashNodeClient; + +export type { VoidhashNodeEffectClient }; diff --git a/libraries/node/src/types.ts b/libraries/node/src/types.ts new file mode 100644 index 000000000..191017910 --- /dev/null +++ b/libraries/node/src/types.ts @@ -0,0 +1,5 @@ +export type VoidhashNodeClientOptions = { + secretKey: string; + baseUrl?: string; + headers?: Record; +}; diff --git a/libraries/node/tests/client.test.ts b/libraries/node/tests/client.test.ts new file mode 100644 index 000000000..713b13f8f --- /dev/null +++ b/libraries/node/tests/client.test.ts @@ -0,0 +1,352 @@ +import { ActionForbiddenError } from "@voidhash/api-spec/errors"; +import { Cause, Effect, Exit, Option } from "effect"; +import { + afterEach, + describe, + expect, + expectTypeOf, + it, + vi, +} from "vitest"; + +import { + VoidhashNodeConfigurationError, + createVoidhashNodeClient, + createVoidhashNodeEffectClient, + type VoidhashNodeClient, + type VoidhashNodeEffectClient, +} from "../src/index"; +import { createJsonResponse, installFetchMock } from "./helpers"; + +const EXPECTED_GROUPS = [ + "api_keys", + "auth", + "changesets", + "customers", + "organizations", + "payment_provider_configurations", + "payment_provider_products", + "paywall_locations", + "perks", + "product_perks", + "products", + "projects", + "users", + "webhooks", +] as const; + +type HasKey = TKey extends keyof TValue + ? true + : false; + +const extractEffectFailure = async ( + effect: Effect.Effect +) => { + const exit = await Effect.runPromiseExit(effect); + + if (Exit.isSuccess(exit)) { + throw new Error("Expected effect failure."); + } + + const typedError = Cause.findErrorOption(exit.cause); + + return Option.isSome(typedError) + ? typedError.value + : Cause.squash(exit.cause); +}; + +describe("@voidhash/node", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("exposes the exact non-sdk namespaces and omits sdk in types", () => { + const effectClient = createVoidhashNodeEffectClient({ + secretKey: "vh_sk_test", + }); + const promiseClient = createVoidhashNodeClient({ + secretKey: "vh_sk_test", + }); + + expect(Object.keys(effectClient).sort()).toEqual([...EXPECTED_GROUPS].sort()); + expect(Object.keys(promiseClient).sort()).toEqual([...EXPECTED_GROUPS].sort()); + expect("sdk" in effectClient).toBe(false); + expect("sdk" in promiseClient).toBe(false); + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + it("fails immediately for invalid configuration", () => { + expect(() => + createVoidhashNodeEffectClient({ + secretKey: " ", + }) + ).toThrow(VoidhashNodeConfigurationError); + + expect(() => + createVoidhashNodeEffectClient({ + baseUrl: "not a url", + secretKey: "vh_sk_test", + }) + ).toThrow(VoidhashNodeConfigurationError); + + expect(() => + createVoidhashNodeEffectClient({ + headers: { + "x-secret-key": "attempted_override", + }, + secretKey: "vh_sk_test", + }) + ).toThrow(VoidhashNodeConfigurationError); + + vi.stubGlobal("fetch", undefined); + + expect(() => + createVoidhashNodeClient({ + secretKey: "vh_sk_test", + }) + ).toThrow(VoidhashNodeConfigurationError); + }); + + it("uses the default baseUrl and sends x-secret-key on auth.session()", async () => { + const { calls } = installFetchMock(() => + createJsonResponse({ + method: "secret-key", + name: "voidhash", + organizations: [], + projects: [], + }) + ); + + const client = createVoidhashNodeEffectClient({ + headers: { + "x-trace-id": "trace_123", + }, + secretKey: "vh_sk_test", + }); + + const session = await Effect.runPromise(client.auth.session()); + + expect(session).toEqual({ + method: "secret-key", + name: "voidhash", + organizations: [], + projects: [], + }); + expect(calls[0]?.method).toBe("GET"); + expect(calls[0]?.url).toBe("https://api.voidhash.com/api/v1/auth/session"); + expect(calls[0]?.headers["x-secret-key"]).toBe("vh_sk_test"); + expect(calls[0]?.headers["x-trace-id"]).toBe("trace_123"); + }); + + it("supports path params with projects.listProjects({ params })", async () => { + const { calls } = installFetchMock(() => + createJsonResponse([ + { + id: "proj_1", + name: "Alpha", + slug: "alpha", + }, + ]) + ); + + const client = createVoidhashNodeClient({ + baseUrl: "https://api.voidhash.test", + secretKey: "vh_sk_test", + }); + + const projects = await client.projects.listProjects({ + params: { + organizationId: "org_123", + }, + }); + + expect(projects).toEqual([ + { + id: "proj_1", + name: "Alpha", + slug: "alpha", + }, + ]); + expect(calls[0]?.method).toBe("GET"); + expect(calls[0]?.url).toBe( + "https://api.voidhash.test/api/v1/projects/org_123" + ); + expect(calls[0]?.headers["x-secret-key"]).toBe("vh_sk_test"); + }); + + it("supports POST bodies with customers.createCustomer({ payload })", async () => { + const { calls } = installFetchMock(() => + createJsonResponse({ + appUserId: "user_123", + email: "user@example.com", + id: "customer_123", + name: "Taylor", + }) + ); + + const client = createVoidhashNodeClient({ + baseUrl: "https://api.voidhash.test", + secretKey: "vh_sk_test", + }); + + const customer = await client.customers.createCustomer({ + payload: { + appUserId: "user_123", + email: "user@example.com", + name: "Taylor", + }, + }); + + expect(customer).toEqual({ + appUserId: "user_123", + email: "user@example.com", + id: "customer_123", + name: "Taylor", + }); + expect(calls[0]?.method).toBe("POST"); + expect(calls[0]?.url).toBe("https://api.voidhash.test/api/v1/customers"); + expect(JSON.parse(calls[0]?.body ?? "{}")).toEqual({ + appUserId: "user_123", + email: "user@example.com", + name: "Taylor", + }); + expect(calls[0]?.headers["x-secret-key"]).toBe("vh_sk_test"); + }); + + it("supports PATCH bodies with webhooks.updateWebhookEndpoint({ params, payload })", async () => { + const { calls } = installFetchMock(() => + createJsonResponse({ + consecutiveFailures: 0, + createdAt: "2026-03-09T12:00:00.000Z", + description: "Updated description", + events: ["purchase.completed"], + id: "wh_123", + lastSuccessAt: null, + name: "Updated endpoint", + projectId: "proj_123", + secret: "secret_123", + status: "active", + url: "https://example.com/hooks", + }) + ); + + const client = createVoidhashNodeClient({ + baseUrl: "https://api.voidhash.test", + secretKey: "vh_sk_test", + }); + + const endpoint = await client.webhooks.updateWebhookEndpoint({ + params: { + endpointId: "wh_123", + }, + payload: { + description: "Updated description", + events: ["purchase.completed"], + name: "Updated endpoint", + status: "active", + url: "https://example.com/hooks", + }, + }); + + expect(endpoint.id).toBe("wh_123"); + expect(endpoint.createdAt).toEqual(new Date("2026-03-09T12:00:00.000Z")); + expect(calls[0]?.method).toBe("PATCH"); + expect(calls[0]?.url).toBe( + "https://api.voidhash.test/api/v1/webhooks/endpoints/wh_123" + ); + expect(JSON.parse(calls[0]?.body ?? "{}")).toEqual({ + description: "Updated description", + events: ["purchase.completed"], + name: "Updated endpoint", + status: "active", + url: "https://example.com/hooks", + }); + expect(calls[0]?.headers["x-secret-key"]).toBe("vh_sk_test"); + }); + + it("supports DELETE requests with api_keys.deleteApiKey({ params })", async () => { + const { calls } = installFetchMock(() => new Response(null, { status: 204 })); + + const client = createVoidhashNodeClient({ + baseUrl: "https://api.voidhash.test", + secretKey: "vh_sk_test", + }); + + const result = await client.api_keys.deleteApiKey({ + params: { + apiKeyId: "ak_123", + }, + }); + + expect(result).toBeUndefined(); + expect(calls[0]?.method).toBe("DELETE"); + expect(calls[0]?.url).toBe( + "https://api.voidhash.test/api/v1/api-keys/ak_123" + ); + expect(calls[0]?.headers["x-secret-key"]).toBe("vh_sk_test"); + }); + + it("surfaces matching success values through effect and promise factories", async () => { + installFetchMock(() => + createJsonResponse({ + method: "secret-key", + name: "voidhash", + organizations: [], + projects: [], + }) + ); + + const effectClient = createVoidhashNodeEffectClient({ + baseUrl: "https://api.voidhash.test", + secretKey: "vh_sk_test", + }); + const promiseClient = createVoidhashNodeClient({ + baseUrl: "https://api.voidhash.test", + secretKey: "vh_sk_test", + }); + + const effectResult = await Effect.runPromise(effectClient.auth.session()); + const promiseResult = await promiseClient.auth.session(); + + expect(promiseResult).toEqual(effectResult); + }); + + it("surfaces matching failure objects through effect and promise factories", async () => { + installFetchMock(() => + createJsonResponse( + new ActionForbiddenError({ + message: "Forbidden", + }), + 403 + ) + ); + + const effectClient = createVoidhashNodeEffectClient({ + baseUrl: "https://api.voidhash.test", + secretKey: "vh_sk_test", + }); + const promiseClient = createVoidhashNodeClient({ + baseUrl: "https://api.voidhash.test", + secretKey: "vh_sk_test", + }); + + const effectError = await extractEffectFailure(effectClient.auth.session()); + const promiseError = await promiseClient.auth + .session() + .then( + () => { + throw new Error("Expected promise client failure."); + }, + (error: unknown) => error + ); + + expect(promiseError).toStrictEqual(effectError); + expect(promiseError).toBeInstanceOf(ActionForbiddenError); + }); +}); diff --git a/libraries/node/tests/helpers.ts b/libraries/node/tests/helpers.ts new file mode 100644 index 000000000..138d1671f --- /dev/null +++ b/libraries/node/tests/helpers.ts @@ -0,0 +1,58 @@ +import { vi } from "vitest"; + +export interface FetchCall { + readonly body?: string; + readonly headers: Record; + readonly method: string; + readonly url: string; +} + +export const createJsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + headers: { + "content-type": "application/json", + }, + status, + }); + +let currentCalls: FetchCall[] = []; +let currentHandler: (call: FetchCall) => Promise | Response = () => + new Response(null, { status: 500 }); + +const fetchMock = vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => { + const request = input instanceof Request ? input : undefined; + const headers = new Headers(request?.headers ?? init?.headers); + const bodySource = init?.body; + const requestBody = + typeof bodySource === "string" + ? bodySource + : bodySource instanceof Uint8Array + ? new TextDecoder().decode(bodySource) + : request + ? await request.clone().text() + : undefined; + const call: FetchCall = { + body: requestBody === "" ? undefined : requestBody, + headers: Object.fromEntries(headers.entries()), + method: init?.method ?? request?.method ?? "GET", + url: request?.url ?? input.toString(), + }; + + currentCalls.push(call); + return currentHandler(call); +}); + +export const installFetchMock = ( + handler: (call: FetchCall) => Promise | Response +) => { + currentCalls = []; + currentHandler = handler; + fetchMock.mockClear(); + + vi.stubGlobal("fetch", fetchMock); + + return { + calls: currentCalls, + fetchMock, + }; +}; diff --git a/libraries/node/tsconfig.json b/libraries/node/tsconfig.json new file mode 100644 index 000000000..d6b542bcd --- /dev/null +++ b/libraries/node/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../packages/tsconfig/base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "target": "ES2022", + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "tests", "build.ts", "vitest.unit.mts"] +} diff --git a/libraries/node/vitest.unit.mts b/libraries/node/vitest.unit.mts new file mode 100644 index 000000000..a828dc593 --- /dev/null +++ b/libraries/node/vitest.unit.mts @@ -0,0 +1,12 @@ +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + environment: "node", + exclude: ["./node_modules/**", "./dist/**"], + include: ["./tests/**/*.test.ts"], + reporters: ["verbose"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 874a5f5d0..9f9656ef2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -224,6 +224,37 @@ importers: specifier: workspace:* version: link:../../apps/cli + libraries/node: + dependencies: + '@voidhash/api-spec': + specifier: workspace:* + version: link:../../packages/api-spec + devDependencies: + '@effect/platform': + specifier: 'catalog:' + version: 0.94.5(effect@4.0.0-beta.23) + '@voidhash/tsconfig': + specifier: workspace:* + version: link:../../packages/tsconfig + effect: + specifier: 4.0.0-beta.23 + version: 4.0.0-beta.23 + tsup: + specifier: ^6.1.3 + version: 6.7.0(postcss@8.5.6)(typescript@5.6.3) + tsx: + specifier: ^4.19.3 + version: 4.21.0 + typescript: + specifier: 5.6.3 + version: 5.6.3 + vite-tsconfig-paths: + specifier: 'catalog:' + version: 5.1.4(typescript@5.6.3)(vite@7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + libraries/react-native: dependencies: '@react-native-async-storage/async-storage': From a8483882d72b4d3aab0615d6f6eaaf0b3ff3c0a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Tue, 24 Mar 2026 11:52:15 +0100 Subject: [PATCH 006/129] feat: server sdk (#77) * feat: serveer sdk wip * chore: improve * wip * wip * feat: publishing --- .../publish-api-spec-canary-preview.yml | 55 -- .../publish-libraries-canary-preview.yml | 119 +++++ docs/JS-SDK.md | 16 +- docs/customer-identifier-refactor.md | 222 ++++++++ examples/react-native-example/app/index.tsx | 4 +- libraries/node/build.ts | 1 + libraries/node/package.json | 32 ++ libraries/node/src/effect-client.ts | 2 +- libraries/node/src/effect.ts | 6 + libraries/node/src/index.ts | 6 +- libraries/node/src/promise-client.ts | 6 +- libraries/node/tests/client.test.ts | 50 +- libraries/react-native/README.md | 2 +- libraries/react-native/package.json | 22 +- .../src/__tests__/core/client-effect.test.ts | 169 ++++-- .../core/customer-info-manager.test.ts | 6 +- .../__tests__/core/identity-manager.test.ts | 58 +-- .../__tests__/helpers/effect-test-harness.ts | 14 +- libraries/react-native/src/client-effect.ts | 488 +++++++++++------- .../react-native/src/client-react-native.ts | 4 +- libraries/react-native/src/client.tsx | 39 +- libraries/react-native/src/constants.ts | 2 +- .../src/core/analytics/constants.ts | 0 .../src/core/analytics/service.ts | 13 + .../react-native/src/core/analytics/types.ts | 43 ++ .../react-native/src/core/analytics/utils.ts | 77 +++ .../identity/customer-attribute-manager.ts | 20 +- .../core/identity/customer-info-manager.ts | 32 +- .../src/core/identity/identity-manager.ts | 71 ++- .../react-native/src/core/testing/client.ts | 6 +- libraries/react-native/src/core/types.ts | 2 +- .../react-native/src/core/utils/crypto.ts | 7 + .../src/core/utils/get-common-sdk-headers.ts | 2 +- libraries/web/package.json | 31 +- libraries/web/src/client-effect.ts | 359 +++++-------- libraries/web/src/client.ts | 308 ++++++++--- .../src/core/analytics/analytics-context.ts | 60 ++- .../core/analytics/analytics-dispatcher.ts | 186 ------- .../web/src/core/analytics/analytics-queue.ts | 112 ---- .../src/core/analytics/analytics-service.ts | 398 ++++++++++++++ libraries/web/src/core/analytics/contracts.ts | 21 +- .../caching/adapters/browser-cache-adapter.ts | 104 ++++ .../caching/adapters/local-storage-cache.ts | 63 --- .../src/core/caching/adapters/memory-cache.ts | 21 - .../web/src/core/caching/cache-adapter.ts | 11 + .../web/src/core/caching/cache-manager.ts | 282 +++++----- libraries/web/src/core/constants.ts | 1 + libraries/web/src/core/event-bus.ts | 8 + .../feature-flags/feature-flag-service.ts | 202 +++++--- .../web/src/core/http/analytics-client.ts | 70 --- libraries/web/src/core/http/sdk-api-client.ts | 126 ----- .../web/src/core/identity/identity-manager.ts | 191 ++++--- .../web/src/core/networking/api-client.ts | 21 + .../networking/event-capture-api-client.ts | 22 + .../core/networking/json-compatible-api.ts | 57 ++ .../networking/normalize-generated-client.ts | 121 +++++ .../platform/browser-platform-provider.ts | 2 +- .../src/core/platform/platform-provider.ts | 13 + libraries/web/src/core/sdk-configuration.ts | 8 + .../web/src/react/hooks/use-feature-flags.ts | 4 +- libraries/web/src/react/provider.tsx | 18 +- libraries/web/src/types.ts | 11 +- libraries/web/tests/analytics.test.ts | 114 +++- libraries/web/tests/client.test.ts | 55 +- libraries/web/tests/helpers.ts | 43 +- libraries/web/tests/react.test.tsx | 6 +- packages/api-spec/package.json | 1 + packages/api-spec/src/api.ts | 4 +- packages/api-spec/src/auth.ts | 2 +- packages/api-spec/src/errors/customer.ts | 4 +- packages/api-spec/src/errors/perk.ts | 2 +- packages/api-spec/src/errors/product.ts | 2 +- packages/api-spec/src/errors/project.ts | 2 +- packages/api-spec/src/errors/sdk.ts | 6 +- packages/api-spec/src/event-capture.ts | 184 +++++++ packages/api-spec/src/schema.ts | 14 +- packages/shared/src/auth.ts | 4 +- 77 files changed, 3120 insertions(+), 1750 deletions(-) delete mode 100644 .github/workflows/publish-api-spec-canary-preview.yml create mode 100644 .github/workflows/publish-libraries-canary-preview.yml create mode 100644 docs/customer-identifier-refactor.md create mode 100644 libraries/node/src/effect.ts create mode 100644 libraries/react-native/src/core/analytics/constants.ts create mode 100644 libraries/react-native/src/core/analytics/service.ts create mode 100644 libraries/react-native/src/core/analytics/types.ts create mode 100644 libraries/react-native/src/core/analytics/utils.ts create mode 100644 libraries/react-native/src/core/utils/crypto.ts delete mode 100644 libraries/web/src/core/analytics/analytics-dispatcher.ts delete mode 100644 libraries/web/src/core/analytics/analytics-queue.ts create mode 100644 libraries/web/src/core/analytics/analytics-service.ts create mode 100644 libraries/web/src/core/caching/adapters/browser-cache-adapter.ts delete mode 100644 libraries/web/src/core/caching/adapters/local-storage-cache.ts delete mode 100644 libraries/web/src/core/caching/adapters/memory-cache.ts create mode 100644 libraries/web/src/core/caching/cache-adapter.ts create mode 100644 libraries/web/src/core/constants.ts delete mode 100644 libraries/web/src/core/http/analytics-client.ts delete mode 100644 libraries/web/src/core/http/sdk-api-client.ts create mode 100644 libraries/web/src/core/networking/api-client.ts create mode 100644 libraries/web/src/core/networking/event-capture-api-client.ts create mode 100644 libraries/web/src/core/networking/json-compatible-api.ts create mode 100644 libraries/web/src/core/networking/normalize-generated-client.ts create mode 100644 libraries/web/src/core/platform/platform-provider.ts create mode 100644 libraries/web/src/core/sdk-configuration.ts create mode 100644 packages/api-spec/src/event-capture.ts diff --git a/.github/workflows/publish-api-spec-canary-preview.yml b/.github/workflows/publish-api-spec-canary-preview.yml deleted file mode 100644 index 00782bfeb..000000000 --- a/.github/workflows/publish-api-spec-canary-preview.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Publish API Spec Canary (Preview) - -on: - push: - branches: - - preview - paths: - - packages/api-spec/** - - pnpm-lock.yaml - - pnpm-workspace.yaml - - package.json - - .github/workflows/publish-api-spec-canary-preview.yml - -permissions: - contents: read - -concurrency: api-spec-canary-preview-${{ github.ref }} - -jobs: - publish-canary: - runs-on: blacksmith-2vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - registry-url: https://registry.npmjs.org - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.19.0 - run_install: false - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Typecheck api-spec - run: pnpm --filter @voidhash/api-spec typecheck - - - name: Publish canary from preview - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - set -euo pipefail - short_sha="${GITHUB_SHA::7}" - canary_version="$(SHORT_SHA="$short_sha" node -e 'const fs=require("node:fs"); const p="./packages/api-spec/package.json"; const pkg=JSON.parse(fs.readFileSync(p,"utf8")); const run=process.env.GITHUB_RUN_NUMBER; const attempt=process.env.GITHUB_RUN_ATTEMPT; const sha=process.env.SHORT_SHA; pkg.version=`${pkg.version}-canary.${run}.${attempt}.${sha}`; fs.writeFileSync(p, JSON.stringify(pkg,null,2)+"\n"); process.stdout.write(pkg.version);')" - echo "Publishing @voidhash/api-spec@${canary_version}" - cd packages/api-spec - npm publish --tag canary --access public diff --git a/.github/workflows/publish-libraries-canary-preview.yml b/.github/workflows/publish-libraries-canary-preview.yml new file mode 100644 index 000000000..da29ee67e --- /dev/null +++ b/.github/workflows/publish-libraries-canary-preview.yml @@ -0,0 +1,119 @@ +name: Publish SDK Canaries (Preview) + +on: + push: + branches: + - preview + paths: + - libraries/** + - packages/api-spec/** + - packages/shared/** + - packages/tsconfig/** + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - turbo.json + - .github/workflows/publish-libraries-canary-preview.yml + +permissions: + contents: read + +concurrency: library-canaries-preview-${{ github.ref }} + +jobs: + publish-canary: + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.19.0 + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Prepare coordinated canary versions + run: | + set -euo pipefail + short_sha="${GITHUB_SHA::7}" + SHORT_SHA="$short_sha" node <<'EOF' + const fs = require("node:fs"); + + const canarySuffix = `canary.${process.env.GITHUB_RUN_NUMBER}.${process.env.GITHUB_RUN_ATTEMPT}.${process.env.SHORT_SHA}`; + const packages = [ + { path: "./packages/api-spec/package.json", indent: 2 }, + { path: "./libraries/node/package.json", indent: 2, apiSpecField: "dependencies" }, + { path: "./libraries/web/package.json", indent: 2, apiSpecField: "dependencies" }, + { path: "./libraries/react-native/package.json", indent: "\t", apiSpecField: "devDependencies" }, + ]; + + const apiSpecPath = "./packages/api-spec/package.json"; + const apiSpec = JSON.parse(fs.readFileSync(apiSpecPath, "utf8")); + const canaryVersion = `${apiSpec.version}-${canarySuffix}`; + apiSpec.version = canaryVersion; + fs.writeFileSync(apiSpecPath, JSON.stringify(apiSpec, null, 2) + "\n"); + + for (const pkg of packages.slice(1)) { + const manifest = JSON.parse(fs.readFileSync(pkg.path, "utf8")); + manifest.version = canaryVersion; + if (pkg.apiSpecField) { + manifest[pkg.apiSpecField]["@voidhash/api-spec"] = canaryVersion; + } + fs.writeFileSync(pkg.path, JSON.stringify(manifest, null, pkg.indent) + "\n"); + } + + process.stdout.write(canaryVersion); + EOF + + - name: Typecheck api-spec + run: pnpm --filter @voidhash/api-spec typecheck + + - name: Typecheck node + run: pnpm --filter @voidhash/node typecheck + + - name: Typecheck web + run: pnpm --filter @voidhash/web typecheck + + - name: Typecheck react-native + run: pnpm --filter @voidhash/react-native typecheck + + - name: Build node + run: pnpm --filter @voidhash/node build + + - name: Build web + run: pnpm --filter @voidhash/web build + + - name: Build react-native + run: pnpm --filter @voidhash/react-native build + + - name: Publish api-spec canary + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm --filter @voidhash/api-spec publish --tag canary --access public --no-git-checks + + - name: Publish node canary + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm --filter @voidhash/node publish --tag canary --access public --no-git-checks + + - name: Publish web canary + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm --filter @voidhash/web publish --tag canary --access public --no-git-checks + + - name: Publish react-native canary + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm --filter @voidhash/react-native publish --tag canary --access public --no-git-checks diff --git a/docs/JS-SDK.md b/docs/JS-SDK.md index 45ff444e4..8afedcfae 100644 --- a/docs/JS-SDK.md +++ b/docs/JS-SDK.md @@ -158,8 +158,8 @@ const enabled = voidhash.isFeatureEnabled("new-checkout"); ### Core Client Methods - `initialize()` -- `identify(appUserId, attributes?)` -- `resetIdentity()` +- `identify(externalUserId, attributes?)` +- `reset()` - `getFeatureFlags(keys?)` - `refreshFeatureFlags(keys?)` - `isFeatureEnabled(key)` @@ -194,7 +194,7 @@ Initialization should follow the same disciplined flow as the React Native SDK: 1. Validate configuration. 2. Create the browser platform provider. 3. Resolve cache adapters. -4. Resolve or create an anonymous `appUserId`. +4. Resolve or create an anonymous `distinctId`. 5. Build common headers and auth headers. 6. Create the internal runtime services. 7. Mark the client as initialized. @@ -211,18 +211,18 @@ Important constraints: The web SDK should preserve the same identity concepts as React Native: -- anonymous users get an SDK-managed generated `appUserId` +- anonymous users get an SDK-managed generated `distinctId` - identified users can replace the anonymous user via `identify` - user attributes should be synchronized in a predictable order - resetting identity should rotate back to a new anonymous user ### Identity Requirements -- Persist the current `appUserId` in browser storage. +- Persist the current `distinctId` in browser storage. - Keep an in-memory copy for fast request construction. - Treat identity changes as cache boundaries. - Clear or segregate user-scoped caches when the identity changes. -- Re-fetch feature flags after `identify` and `resetIdentity`. +- Re-fetch feature flags after `identify` and `reset`. - Emit an identity-changed event so React hooks and host apps can respond. ## Feature Flags Plan @@ -308,7 +308,7 @@ type AnalyticsEvent = { event: string; properties?: Record; timestamp: string; - appUserId: string; + distinctId: string; anonymousId?: string; context: { url?: string; @@ -389,7 +389,7 @@ Use a layered cache model: ### Cache Rules - Keep the 5-minute feature flag TTL from React Native by default. -- Namespace cache keys by environment and `appUserId`. +- Namespace cache keys by environment and `distinctId`. - Keep cache clearing explicit and testable. - Bound the analytics queue so storage growth is controlled. diff --git a/docs/customer-identifier-refactor.md b/docs/customer-identifier-refactor.md new file mode 100644 index 000000000..7fe74b365 --- /dev/null +++ b/docs/customer-identifier-refactor.md @@ -0,0 +1,222 @@ +# Customer Identifier Refactor + +Status: Draft +Owner: SDK + platform + +## Summary + +This document defines the clean-slate identity model for `voidhash`. + +The goal is to remove the current overlap between: + +- `appUserId` +- `distinctId` +- anonymous user identifiers + +and replace it with one clear public identity model for SDKs and shared contracts. + +## Design Goal + +The public/open-source SDK and shared types should model exactly two concepts: + +1. `distinctId` +2. `customerId` + +`distinctId` is public and event-facing. + +`customerId` is internal and server-issued. + +Everything else is metadata or compatibility glue and should not be part of the long-term design. + +## Canonical Terms + +### `distinctId` + +`distinctId` is the current identity attached to SDK traffic and analytics events. + +It can represent: + +- an anonymous SDK-generated identity +- an identified app-provided identity + +It is the only identifier that SDKs should persist locally. + +### `customerId` + +`customerId` is the canonical internal person/customer record id. + +It is: + +- generated by the backend +- stable across merges and identity resolution +- not supplied by the integrating app + +### `externalUserId` + +`externalUserId` is an optional naming convenience at the SDK method boundary only. + +Example: + +- `identify(externalUserId: string)` + +Internally, the SDK should immediately treat that value as the new `distinctId`. + +`externalUserId` should not become a separate persisted or transport-level identity field. + +## Explicit Non-Goals + +The following should not exist as first-class identity concepts in the target model: + +- `appUserId` +- `anonymousId` +- `identifiedDistinctId` +- `analyticsIdentityId` + +These names may exist temporarily during migration, but they should not remain in the long-term public design. + +## Public SDK Model + +## Initialization + +Target initialization API: + +```ts +createVoidhashClient({ + publishableKey: "...", + baseUrl: "...", + distinctId?: string, +}) +``` + +Rules: + +- if `distinctId` is provided, use it as the current identity +- otherwise generate a new anonymous `distinctId` +- the SDK persists exactly one current `distinctId` + +## Identity API + +Target public API: + +```ts +await client.identify("user_123", { + email: "user@example.com", + name: "User", +}) + +await client.reset() + +const distinctId = await client.getDistinctId() +``` + +Recommended method names: + +- `identify(externalUserId, traits?)` +- `reset()` +- `getDistinctId()` + +Avoid: + +- `identify(appUserId, ...)` +- `signOut()` as the primary identity primitive + +`signOut()` may still exist as a product convenience, but identity rotation should be modeled as `reset()`. + +## Anonymous Identity + +Anonymous users are not a separate identifier type. + +They are just `distinctId` values generated by the SDK, for example: + +```txt +anon_ +``` + +The current `vh:anon:` prefix is acceptable if kept consistent, but the important rule is conceptual: + +- anonymous identity is still just `distinctId` + +## Identify Semantics + +Calling: + +```ts +await client.identify("user_123") +``` + +means: + +1. read current `distinctId` +2. emit an identify signal from old identity to new identity +3. switch current SDK identity to `"user_123"` + +The canonical identify payload should conceptually be: + +```ts +{ + event: "$identify", + distinctId: "user_123", + properties: { + previous_distinct_id: "anon_abc", + } +} +``` + +This keeps the model aligned with the event pipeline: + +- event target identity is `distinctId` +- previous identity is explicit + +## Shared Type Changes + +The shared/open-source packages should move toward: + +- `distinctId` in SDK runtime and client types +- `customerId` in server responses +- no `appUserId` in new shared contracts + +Target response shape: + +```ts +type SdkCustomer = { + customerId: string + distinctId: string + email: string | null + name: string | null +} +``` + +If the backend still exposes the customer's primary identified value, it should still be called `distinctId`, not `appUserId`. + +## Migration Direction In OSS + +The OSS repo should make these changes: + +1. Rename SDK runtime state from `appUserId` to `distinctId`. +2. Rename `userId` init options to `distinctId`. +3. Rename headers and request fields in shared API contracts away from `x-app-user-id` where possible. +4. Update docs and examples to use `distinctId` consistently. +5. Treat identify as an identity transition from previous `distinctId` to next `distinctId`. + +Because breaking changes are acceptable, this should be done directly rather than layering long-term compatibility abstractions into the OSS API. + +## Design Rules + +When adding new SDK or shared API surface area: + +1. Use `distinctId` for the event/customer identity supplied by the SDK. +2. Use `customerId` for the backend canonical customer record. +3. Do not introduce another synonym for either concept. +4. Do not store both `appUserId` and `distinctId` in SDK state. +5. Prefer one identity value in transit and one canonical identity value in storage. + +## Result + +The target public model is: + +- SDK stores one `distinctId` +- backend returns one `customerId` +- identify changes the current `distinctId` +- anonymous and identified users use the same identity field + +That is the simplest model that still supports anonymous users, identified users, and downstream identity resolution. diff --git a/examples/react-native-example/app/index.tsx b/examples/react-native-example/app/index.tsx index 698e19fa1..e3f4eea71 100644 --- a/examples/react-native-example/app/index.tsx +++ b/examples/react-native-example/app/index.tsx @@ -14,10 +14,10 @@ export default function HomeScreen() { // Mock authentication const { user, isLoading } = useCurrentUser(); - // Signs out the user + // Resets the current identity and signs the example user out. const handleSignOut = async () => { await fakeAuthService.signOut(); - await voidhash.client.signOut(); + await voidhash.client.reset(); }; // Resets the Voidhash cache. This is useful for testing. diff --git a/libraries/node/build.ts b/libraries/node/build.ts index a6335e077..1ab0ae09b 100644 --- a/libraries/node/build.ts +++ b/libraries/node/build.ts @@ -5,6 +5,7 @@ const main = async () => { dts: true, entryPoints: { index: "./src/index.ts", + effect: "./src/effect.ts", }, format: ["cjs", "esm"], outDir: "./dist", diff --git a/libraries/node/package.json b/libraries/node/package.json index cf416fb78..cd2a6c1a3 100644 --- a/libraries/node/package.json +++ b/libraries/node/package.json @@ -1,7 +1,21 @@ { "name": "@voidhash/node", "version": "0.0.1-alpha.1", + "private": false, "description": "Fetch-compatible Voidhash Node SDK.", + "keywords": [ + "voidhash", + "sdk", + "node", + "server", + "payments" + ], + "homepage": "https://voidhash.com/docs", + "bugs": { + "url": "https://github.com/voidhashcom/voidhash/issues" + }, + "license": "MIT", + "author": "Voidhash (https://voidhash.com)", "repository": { "type": "git", "url": "https://github.com/voidhashcom/voidhash", @@ -11,6 +25,14 @@ "files": [ "dist" ], + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "sideEffects": false, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "exports": { ".": { "import": { @@ -21,6 +43,16 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" } + }, + "./effect": { + "import": { + "types": "./dist/effect.d.ts", + "default": "./dist/effect.mjs" + }, + "require": { + "types": "./dist/effect.d.ts", + "default": "./dist/effect.js" + } } }, "scripts": { diff --git a/libraries/node/src/effect-client.ts b/libraries/node/src/effect-client.ts index 602cf7646..e672b2446 100644 --- a/libraries/node/src/effect-client.ts +++ b/libraries/node/src/effect-client.ts @@ -12,7 +12,7 @@ import { normalizeGeneratedClient } from "./internal/normalize-generated-client" export type VoidhashNodeEffectClient = FilterSdkGroup; -export const createVoidhashNodeEffectClient = ( +export const createVoidhashSdk = ( options: VoidhashNodeClientOptions ): VoidhashNodeEffectClient => filterSdkGroup( diff --git a/libraries/node/src/effect.ts b/libraries/node/src/effect.ts new file mode 100644 index 000000000..27ca5ceb0 --- /dev/null +++ b/libraries/node/src/effect.ts @@ -0,0 +1,6 @@ +export { VoidhashNodeConfigurationError } from "./errors"; +export { + createVoidhashSdk, + type VoidhashNodeEffectClient, +} from "./effect-client"; +export type { VoidhashNodeClientOptions } from "./types"; diff --git a/libraries/node/src/index.ts b/libraries/node/src/index.ts index 2f8f7258c..2ccbba02e 100644 --- a/libraries/node/src/index.ts +++ b/libraries/node/src/index.ts @@ -1,10 +1,6 @@ export { VoidhashNodeConfigurationError } from "./errors"; export { - createVoidhashNodeEffectClient, - type VoidhashNodeEffectClient, -} from "./effect-client"; -export { - createVoidhashNodeClient, + createVoidhashSdk, type VoidhashNodeClient, } from "./promise-client"; export type { VoidhashNodeClientOptions } from "./types"; diff --git a/libraries/node/src/promise-client.ts b/libraries/node/src/promise-client.ts index 612f06186..ae71f443b 100644 --- a/libraries/node/src/promise-client.ts +++ b/libraries/node/src/promise-client.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { - createVoidhashNodeEffectClient, + createVoidhashSdk as createVoidhashEffectSdk, type VoidhashNodeEffectClient, } from "./effect-client"; import type { PublicVoidhashNodeClient } from "./internal/client-types"; @@ -48,11 +48,11 @@ const promisifyClient = ( export type VoidhashNodeClient = FilterSdkGroup; -export const createVoidhashNodeClient = ( +export const createVoidhashSdk = ( options: VoidhashNodeClientOptions ): VoidhashNodeClient => promisifyClient( - createVoidhashNodeEffectClient(options) + createVoidhashEffectSdk(options) ) as unknown as VoidhashNodeClient; export type { VoidhashNodeEffectClient }; diff --git a/libraries/node/tests/client.test.ts b/libraries/node/tests/client.test.ts index 713b13f8f..fbc37ad5b 100644 --- a/libraries/node/tests/client.test.ts +++ b/libraries/node/tests/client.test.ts @@ -11,11 +11,13 @@ import { import { VoidhashNodeConfigurationError, - createVoidhashNodeClient, - createVoidhashNodeEffectClient, + createVoidhashSdk, type VoidhashNodeClient, - type VoidhashNodeEffectClient, } from "../src/index"; +import { + createVoidhashSdk as createVoidhashEffectSdk, + type VoidhashNodeEffectClient, +} from "../src/effect"; import { createJsonResponse, installFetchMock } from "./helpers"; const EXPECTED_GROUPS = [ @@ -61,10 +63,10 @@ describe("@voidhash/node", () => { }); it("exposes the exact non-sdk namespaces and omits sdk in types", () => { - const effectClient = createVoidhashNodeEffectClient({ + const effectClient = createVoidhashEffectSdk({ secretKey: "vh_sk_test", }); - const promiseClient = createVoidhashNodeClient({ + const promiseClient = createVoidhashSdk({ secretKey: "vh_sk_test", }); @@ -83,20 +85,20 @@ describe("@voidhash/node", () => { it("fails immediately for invalid configuration", () => { expect(() => - createVoidhashNodeEffectClient({ + createVoidhashEffectSdk({ secretKey: " ", }) ).toThrow(VoidhashNodeConfigurationError); expect(() => - createVoidhashNodeEffectClient({ + createVoidhashEffectSdk({ baseUrl: "not a url", secretKey: "vh_sk_test", }) ).toThrow(VoidhashNodeConfigurationError); expect(() => - createVoidhashNodeEffectClient({ + createVoidhashEffectSdk({ headers: { "x-secret-key": "attempted_override", }, @@ -107,7 +109,7 @@ describe("@voidhash/node", () => { vi.stubGlobal("fetch", undefined); expect(() => - createVoidhashNodeClient({ + createVoidhashSdk({ secretKey: "vh_sk_test", }) ).toThrow(VoidhashNodeConfigurationError); @@ -123,7 +125,7 @@ describe("@voidhash/node", () => { }) ); - const client = createVoidhashNodeEffectClient({ + const client = createVoidhashEffectSdk({ headers: { "x-trace-id": "trace_123", }, @@ -155,7 +157,7 @@ describe("@voidhash/node", () => { ]) ); - const client = createVoidhashNodeClient({ + const client = createVoidhashSdk({ baseUrl: "https://api.voidhash.test", secretKey: "vh_sk_test", }); @@ -183,36 +185,36 @@ describe("@voidhash/node", () => { it("supports POST bodies with customers.createCustomer({ payload })", async () => { const { calls } = installFetchMock(() => createJsonResponse({ - appUserId: "user_123", + customerId: "customer_123", + distinctId: "user_123", email: "user@example.com", - id: "customer_123", name: "Taylor", }) ); - const client = createVoidhashNodeClient({ + const client = createVoidhashSdk({ baseUrl: "https://api.voidhash.test", secretKey: "vh_sk_test", }); const customer = await client.customers.createCustomer({ payload: { - appUserId: "user_123", + distinctId: "user_123", email: "user@example.com", name: "Taylor", }, }); expect(customer).toEqual({ - appUserId: "user_123", + customerId: "customer_123", + distinctId: "user_123", email: "user@example.com", - id: "customer_123", name: "Taylor", }); expect(calls[0]?.method).toBe("POST"); expect(calls[0]?.url).toBe("https://api.voidhash.test/api/v1/customers"); expect(JSON.parse(calls[0]?.body ?? "{}")).toEqual({ - appUserId: "user_123", + distinctId: "user_123", email: "user@example.com", name: "Taylor", }); @@ -236,7 +238,7 @@ describe("@voidhash/node", () => { }) ); - const client = createVoidhashNodeClient({ + const client = createVoidhashSdk({ baseUrl: "https://api.voidhash.test", secretKey: "vh_sk_test", }); @@ -273,7 +275,7 @@ describe("@voidhash/node", () => { it("supports DELETE requests with api_keys.deleteApiKey({ params })", async () => { const { calls } = installFetchMock(() => new Response(null, { status: 204 })); - const client = createVoidhashNodeClient({ + const client = createVoidhashSdk({ baseUrl: "https://api.voidhash.test", secretKey: "vh_sk_test", }); @@ -302,11 +304,11 @@ describe("@voidhash/node", () => { }) ); - const effectClient = createVoidhashNodeEffectClient({ + const effectClient = createVoidhashEffectSdk({ baseUrl: "https://api.voidhash.test", secretKey: "vh_sk_test", }); - const promiseClient = createVoidhashNodeClient({ + const promiseClient = createVoidhashSdk({ baseUrl: "https://api.voidhash.test", secretKey: "vh_sk_test", }); @@ -327,11 +329,11 @@ describe("@voidhash/node", () => { ) ); - const effectClient = createVoidhashNodeEffectClient({ + const effectClient = createVoidhashEffectSdk({ baseUrl: "https://api.voidhash.test", secretKey: "vh_sk_test", }); - const promiseClient = createVoidhashNodeClient({ + const promiseClient = createVoidhashSdk({ baseUrl: "https://api.voidhash.test", secretKey: "vh_sk_test", }); diff --git a/libraries/react-native/README.md b/libraries/react-native/README.md index 8f6423ee2..c4f3a65fb 100644 --- a/libraries/react-native/README.md +++ b/libraries/react-native/README.md @@ -45,7 +45,7 @@ When `unstable_swallowErrors: true`, the SDK logs warnings and does not reject f - `init()` - `end()` - `identify(...)` -- `signOut()` +- `reset()` - `restorePurchases()` - `flush()` - `iosPresentCodeRedemptionSheet()` diff --git a/libraries/react-native/package.json b/libraries/react-native/package.json index aabc40301..ce14c93fd 100644 --- a/libraries/react-native/package.json +++ b/libraries/react-native/package.json @@ -1,20 +1,26 @@ { "name": "@voidhash/react-native", - "version": "0.0.1", - "description": "React Native SDK for in-app purchases.", + "version": "0.0.1-alpha.1", + "private": false, + "description": "Voidhash React Native SDK for in-app purchases.", "keywords": [ + "voidhash", + "sdk", "nitro", - "react-native" + "react-native", + "expo", + "payments" ], "homepage": "https://voidhash.com/docs", "bugs": { - "url": "https://github.com/mrousavy/nitro/issues" + "url": "https://github.com/voidhashcom/voidhash/issues" }, "license": "MIT", - "author": "Marc Rousavy (https://github.com/mrousavy)", + "author": "Voidhash (https://voidhash.com)", "repository": { "type": "git", - "url": "git+https://github.com/mrousavy/nitro.git" + "url": "https://github.com/voidhashcom/voidhash", + "directory": "libraries/react-native" }, "source": "src/index", "files": [ @@ -40,6 +46,7 @@ "types": "build/index.d.ts", "react-native": "src/index", "publishConfig": { + "access": "public", "registry": "https://registry.npmjs.org/" }, "exports": { @@ -61,13 +68,12 @@ "specs": "pnpm run typescript && nitro-codegen --logLevel=\"debug\"" }, "dependencies": { - "neverthrow": "^8.2.0" + "@voidhash/api-spec": "workspace:*" }, "devDependencies": { "@types/jest": "^29.5.14", "@types/react": "~19.1.10", "@vitejs/plugin-react": "^4.6.0", - "@voidhash/api-spec": "workspace:*", "@voidhash/shared": "workspace:*", "expo": "54.0.33", "expo-constants": "~18.0.13", diff --git a/libraries/react-native/src/__tests__/core/client-effect.test.ts b/libraries/react-native/src/__tests__/core/client-effect.test.ts index 73c20d1a3..fbfaf6563 100644 --- a/libraries/react-native/src/__tests__/core/client-effect.test.ts +++ b/libraries/react-native/src/__tests__/core/client-effect.test.ts @@ -4,11 +4,12 @@ import { type AnalyticsIngestEvent, VoidhashEffectClient, } from "../../client-effect"; -import { ANONYMOUS_USER_ID_PREFIX } from "../../constants"; +import { ANONYMOUS_DISTINCT_ID_PREFIX } from "../../constants"; import { CacheManager } from "../../core/caching/cache-manager"; import { Product, SubscriptionProduct } from "../../core/entities/product"; import { Transaction } from "../../core/entities/transaction"; import { CustomerAttributeManager } from "../../core/identity/customer-attribute-manager"; +import { SDK_VERSION } from "../../core/constants"; import { createApiClientDouble, createEffectTestHarness, @@ -18,7 +19,7 @@ import { import { createTestSchema } from "../helpers/test-schema"; describe("VoidhashEffectClient", () => { - it("init with initial user id identifies user and syncs previous attributes", async () => { + it("init with a provided distinct id identifies the user and syncs previous attributes", async () => { const apiDouble = createApiClientDouble(); const paymentDouble = createPaymentAdapterDouble(); const cache = createInMemoryCacheAdapter(); @@ -31,7 +32,7 @@ describe("VoidhashEffectClient", () => { try { await harness.runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => manager.set("appUserId", "cached-before-init")) + Effect.flatMap(CacheManager, (manager) => manager.set("distinctId", "cached-before-init")) ); await harness.runtime.runPromise( Effect.flatMap(CustomerAttributeManager, (manager) => @@ -44,29 +45,29 @@ describe("VoidhashEffectClient", () => { const initializedClient = await harness.runtime.runPromise( VoidhashEffectClient.makeUnitializedClient().init({ - initialAppUserId: "user-after-init", + distinctId: "user-after-init", schema, }) ); expect(initializedClient).toHaveProperty("getProducts"); expect(apiDouble.state.syncCustomerAttributesCalls).toHaveLength(2); - expect(apiDouble.state.syncCustomerAttributesCalls[0]?.headers["x-app-user-id"]).toBe( + expect(apiDouble.state.syncCustomerAttributesCalls[0]?.headers["x-distinct-id"]).toBe( "cached-before-init" ); expect(apiDouble.state.identifyCalls).toHaveLength(1); - expect(apiDouble.state.identifyCalls[0]?.headers["x-app-user-id"]).toBe( + expect(apiDouble.state.identifyCalls[0]?.headers["x-distinct-id"]).toBe( "cached-before-init" ); expect(apiDouble.state.identifyCalls[0]?.payload).toMatchObject({ - appUserId: "user-after-init", + distinctId: "user-after-init", }); } finally { await harness.runtime.dispose(); } }); - it("init without initial user id prefetches customer", async () => { + it("init without a provided distinct id prefetches customer", async () => { const apiDouble = createApiClientDouble(); const paymentDouble = createPaymentAdapterDouble(); const cache = createInMemoryCacheAdapter(); @@ -87,10 +88,10 @@ describe("VoidhashEffectClient", () => { expect(apiDouble.state.identifyCalls).toHaveLength(0); expect(apiDouble.state.syncCustomerAttributesCalls).toHaveLength(1); expect(apiDouble.state.getCustomerCalls).toHaveLength(1); - const appUserId = String( - apiDouble.state.getCustomerCalls[0]?.headers["x-app-user-id"] + const distinctId = String( + apiDouble.state.getCustomerCalls[0]?.headers["x-distinct-id"] ); - expect(appUserId.startsWith(ANONYMOUS_USER_ID_PREFIX)).toBe(true); + expect(distinctId.startsWith(ANONYMOUS_DISTINCT_ID_PREFIX)).toBe(true); } finally { await harness.runtime.dispose(); } @@ -164,7 +165,7 @@ describe("VoidhashEffectClient", () => { try { await harness.runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => manager.set("appUserId", "feature-user")) + Effect.flatMap(CacheManager, (manager) => manager.set("distinctId", "feature-user")) ); const first = await harness.runtime.runPromise( @@ -455,7 +456,7 @@ describe("VoidhashEffectClient", () => { try { await harness.runtime.runPromise( Effect.flatMap(CacheManager, (manager) => - manager.set("appUserId", "analytics-user") + manager.set("distinctId", "analytics-user") ) ); await harness.runtime.runPromise( @@ -464,15 +465,32 @@ describe("VoidhashEffectClient", () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock.mock.calls[0]?.[0]).toBe( - "https://i.api.voidhash.test/v1/events" + "https://i.api.voidhash.test/batch" ); const request = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined; expect(request?.method).toBe("POST"); expect(request?.headers).toEqual({ "content-type": "application/json", - "x-app-user-id": "analytics-user", - "x-publishable-key": "pk_analytics", + }); + expect(JSON.parse(String(request?.body))).toMatchObject({ + events: [ + { + distinct_id: "analytics-user", + event: "cta-button-clicked", + properties: { + button_name: "Get Started", + }, + request: { + sdk_name: "react-native", + sdk_version: SDK_VERSION, + }, + session_id: "sess_1", + timestamp: "2026-01-01T00:00:00.000Z", + uuid: "evt_1", + }, + ], + token: "pk_analytics", }); } finally { global.fetch = originalFetch; @@ -507,36 +525,23 @@ describe("VoidhashEffectClient", () => { ); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:8083/v1/events"); + expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:8083/batch"); } finally { global.fetch = originalFetch; await harness.runtime.dispose(); } }); - it("retries failed analytics delivery up to 3 times", async () => { + it("does not inline retry failed analytics delivery", async () => { const originalFetch = global.fetch; const fetchMock = jest .fn() .mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: "Internal Server Error", - }) - .mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: "Internal Server Error", - }) - .mockResolvedValueOnce({ + headers: new Headers(), + json: async () => ({ error: "try again" }), ok: false, status: 503, statusText: "Service Unavailable", - }) - .mockResolvedValueOnce({ - ok: true, - status: 202, - statusText: "Accepted", }); global.fetch = fetchMock as unknown as typeof global.fetch; @@ -553,11 +558,10 @@ describe("VoidhashEffectClient", () => { const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); try { - await harness.runtime.runPromise( + await expect(harness.runtime.runPromise( initializedClient.sendAnalyticsEvents(analyticsEvents) - ); - - expect(fetchMock).toHaveBeenCalledTimes(4); + )).rejects.toThrow("Analytics ingest request failed: 503 Service Unavailable"); + expect(fetchMock).toHaveBeenCalledTimes(1); } finally { global.fetch = originalFetch; await harness.runtime.dispose(); @@ -671,13 +675,21 @@ describe("VoidhashEffectClient", () => { } }); - it("keeps batch in queue when flush fails", async () => { + it("keeps batch in queue when flush is retryably rate limited", async () => { const originalFetch = global.fetch; - const fetchMock = jest.fn() - .mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }) - .mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }) - .mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }) - .mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }); + const fetchMock = jest.fn().mockResolvedValueOnce({ + headers: new Headers({ + "retry-after": "2", + }), + json: async () => ({ + code: "rate_limited", + error: "request rate limit exceeded", + retry_after_ms: 2000, + }), + ok: false, + status: 429, + statusText: "Too Many Requests", + }); global.fetch = fetchMock as unknown as typeof global.fetch; const schema = createTestSchema(); @@ -696,12 +708,75 @@ describe("VoidhashEffectClient", () => { harness.runtime.runSync(initializedClient.capture("event-1")); harness.runtime.runSync(initializedClient.capture("event-2")); - await expect( - harness.runtime.runPromise(initializedClient.flush()) - ).rejects.toThrow(); - + await harness.runtime.runPromise(initializedClient.flush()); expect(initializedClient.getAnalyticsQueueLength()).toBe(2); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { + global.fetch = originalFetch; + await harness.runtime.dispose(); + } + }); + + it("retries a rate-limited batch after Retry-After elapses", async () => { + jest.useFakeTimers(); + + const originalFetch = global.fetch; + const fetchMock = jest + .fn() + .mockResolvedValueOnce({ + headers: new Headers({ + "retry-after": "2", + }), + json: async () => ({ + code: "rate_limited", + error: "request rate limit exceeded", + retry_after_ms: 2000, + }), + ok: false, + status: 429, + statusText: "Too Many Requests", + }) + .mockResolvedValueOnce({ + headers: new Headers(), + json: async () => ({ + accepted: 1, + rejected: 0, + request_id: "req_after_backoff", + }), + ok: true, + status: 202, + statusText: "Accepted", + }); + global.fetch = fetchMock as unknown as typeof global.fetch; + + const schema = createTestSchema(); + const apiDouble = createApiClientDouble(); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + ingestUrl: "http://localhost:8083", + paymentAdapter: paymentDouble.paymentAdapter, + }); + const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + + try { + harness.runtime.runSync(initializedClient.capture("event-1")); + + await harness.runtime.runPromise(initializedClient.flush()); + expect(initializedClient.getAnalyticsQueueLength()).toBe(1); + + await harness.runtime.runPromise(initializedClient.flush()); + expect(fetchMock).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(2000); + await harness.runtime.runPromise(initializedClient.flush()); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(initializedClient.getAnalyticsQueueLength()).toBe(0); } finally { + jest.useRealTimers(); global.fetch = originalFetch; await harness.runtime.dispose(); } diff --git a/libraries/react-native/src/__tests__/core/customer-info-manager.test.ts b/libraries/react-native/src/__tests__/core/customer-info-manager.test.ts index 813a92f6a..db0ea3713 100644 --- a/libraries/react-native/src/__tests__/core/customer-info-manager.test.ts +++ b/libraries/react-native/src/__tests__/core/customer-info-manager.test.ts @@ -61,7 +61,7 @@ describe("CustomerInfoManager", () => { const fetchedEvents: string[] = []; const remove = harness.eventBus.on("customer-fetched", (customer) => { - fetchedEvents.push(customer.appUserId); + fetchedEvents.push(customer.distinctId); }); try { @@ -76,9 +76,9 @@ describe("CustomerInfoManager", () => { ) ); - expect(result.appUserId).toBe("fetched-user"); + expect(result.distinctId).toBe("fetched-user"); expect(apiDouble.state.getCustomerCalls).toHaveLength(1); - expect(cached?.value.appUserId).toBe("fetched-user"); + expect(cached?.value.distinctId).toBe("fetched-user"); expect(fetchedEvents).toEqual(["fetched-user"]); } finally { remove(); diff --git a/libraries/react-native/src/__tests__/core/identity-manager.test.ts b/libraries/react-native/src/__tests__/core/identity-manager.test.ts index 9e9435b52..5d16ff174 100644 --- a/libraries/react-native/src/__tests__/core/identity-manager.test.ts +++ b/libraries/react-native/src/__tests__/core/identity-manager.test.ts @@ -1,6 +1,6 @@ import { Effect } from "effect"; -import { ANONYMOUS_USER_ID_PREFIX } from "../../constants"; +import { ANONYMOUS_DISTINCT_ID_PREFIX } from "../../constants"; import { CacheManager } from "../../core/caching/cache-manager"; import { CustomerAttributeManager } from "../../core/identity/customer-attribute-manager"; import { CustomerInfoManager } from "../../core/identity/customer-info-manager"; @@ -13,7 +13,7 @@ import { } from "../helpers/effect-test-harness"; describe("IdentityManager", () => { - it("uses cached app user id when present", async () => { + it("uses the cached distinct id when present", async () => { const apiDouble = createApiClientDouble(); const paymentDouble = createPaymentAdapterDouble(); const cache = createInMemoryCacheAdapter(); @@ -25,20 +25,20 @@ describe("IdentityManager", () => { try { await harness.runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => manager.set("appUserId", "cached-user")) + Effect.flatMap(CacheManager, (manager) => manager.set("distinctId", "cached-user")) ); - const appUserId = await harness.runtime.runPromise( - Effect.flatMap(IdentityManager, (manager) => manager.getAppUserId()) + const distinctId = await harness.runtime.runPromise( + Effect.flatMap(IdentityManager, (manager) => manager.getDistinctId()) ); - expect(appUserId).toBe("cached-user"); + expect(distinctId).toBe("cached-user"); } finally { await harness.runtime.dispose(); } }); - it("generates and persists anonymous app user id when cache is empty", async () => { + it("generates and persists an anonymous distinct id when the cache is empty", async () => { const apiDouble = createApiClientDouble(); const paymentDouble = createPaymentAdapterDouble(); const cache = createInMemoryCacheAdapter(); @@ -49,21 +49,21 @@ describe("IdentityManager", () => { }); try { - const appUserId = await harness.runtime.runPromise( - Effect.flatMap(IdentityManager, (manager) => manager.getAppUserId()) + const distinctId = await harness.runtime.runPromise( + Effect.flatMap(IdentityManager, (manager) => manager.getDistinctId()) ); const cached = await harness.runtime.runPromise( - Effect.flatMap(IdentityManager, (manager) => manager.getAppUserIdFromCache()) + Effect.flatMap(IdentityManager, (manager) => manager.getDistinctIdFromCache()) ); - expect(appUserId.startsWith(ANONYMOUS_USER_ID_PREFIX)).toBe(true); - expect(cached).toBe(appUserId); + expect(distinctId.startsWith(ANONYMOUS_DISTINCT_ID_PREFIX)).toBe(true); + expect(cached).toBe(distinctId); } finally { await harness.runtime.dispose(); } }); - it("identify syncs previous user attributes, updates cache and emits events", async () => { + it("identify syncs previous traits, updates cache and emits events", async () => { const apiDouble = createApiClientDouble(); const paymentDouble = createPaymentAdapterDouble(); const cache = createInMemoryCacheAdapter(); @@ -80,14 +80,14 @@ describe("IdentityManager", () => { identifiedEvents.push("customer-identified"); }); const removeFetched = harness.eventBus.on("customer-fetched", (customer) => { - fetchedEvents.push(customer.appUserId); + fetchedEvents.push(customer.distinctId); }); try { await harness.runtime.runPromise( Effect.flatMap(CacheManager, (manager) => Effect.all([ - manager.set("appUserId", "old-user"), + manager.set("distinctId", "old-user"), manager.set("customer-attributes:old-user", { email: "old@voidhash.test", name: "Old User", @@ -105,8 +105,8 @@ describe("IdentityManager", () => { ) ); - const cachedAppUserId = await harness.runtime.runPromise( - Effect.flatMap(IdentityManager, (manager) => manager.getAppUserIdFromCache()) + const cachedDistinctId = await harness.runtime.runPromise( + Effect.flatMap(IdentityManager, (manager) => manager.getDistinctIdFromCache()) ); const cachedCustomer = await harness.runtime.runPromise( Effect.flatMap(CustomerInfoManager, (manager) => @@ -115,20 +115,20 @@ describe("IdentityManager", () => { ); expect(apiDouble.state.syncCustomerAttributesCalls).toHaveLength(1); - expect(apiDouble.state.syncCustomerAttributesCalls[0]?.headers["x-app-user-id"]).toBe( + expect(apiDouble.state.syncCustomerAttributesCalls[0]?.headers["x-distinct-id"]).toBe( "old-user" ); expect(apiDouble.state.identifyCalls).toHaveLength(1); expect(apiDouble.state.identifyCalls[0]?.payload).toMatchObject({ - appUserId: "new-user", + distinctId: "new-user", email: "new@voidhash.test", name: "New User", }); - expect(apiDouble.state.identifyCalls[0]?.headers["x-app-user-id"]).toBe("old-user"); + expect(apiDouble.state.identifyCalls[0]?.headers["x-distinct-id"]).toBe("old-user"); - expect(cachedAppUserId).toBe("new-user"); - expect(cachedCustomer?.value.appUserId).toBe("new-user"); + expect(cachedDistinctId).toBe("new-user"); + expect(cachedCustomer?.value.distinctId).toBe("new-user"); expect(identifiedEvents).toEqual(["customer-identified"]); expect(fetchedEvents).toEqual(["new-user"]); } finally { @@ -138,7 +138,7 @@ describe("IdentityManager", () => { } }); - it("signOut syncs attributes, clears cache and emits signed out event", async () => { + it("reset syncs attributes, clears cache and emits signed out event", async () => { const apiDouble = createApiClientDouble(); const paymentDouble = createPaymentAdapterDouble(); const cache = createInMemoryCacheAdapter(); @@ -157,7 +157,7 @@ describe("IdentityManager", () => { await harness.runtime.runPromise( Effect.flatMap(CacheManager, (manager) => Effect.all([ - manager.set("appUserId", "signed-in-user"), + manager.set("distinctId", "signed-in-user"), manager.set("customer:some-user", { id: "some-user" }), ]) ) @@ -172,21 +172,21 @@ describe("IdentityManager", () => { ); await harness.runtime.runPromise( - Effect.flatMap(IdentityManager, (manager) => manager.signOut()) + Effect.flatMap(IdentityManager, (manager) => manager.reset()) ); - const appUserIdFromCache = await harness.runtime.runPromise( - Effect.flatMap(IdentityManager, (manager) => manager.getAppUserIdFromCache()) + const distinctIdFromCache = await harness.runtime.runPromise( + Effect.flatMap(IdentityManager, (manager) => manager.getDistinctIdFromCache()) ); const cacheKeys = await harness.runtime.runPromise( Effect.flatMap(CacheManager, (manager) => manager.getCacheKeys()) ); expect(apiDouble.state.syncCustomerAttributesCalls).toHaveLength(1); - expect(apiDouble.state.syncCustomerAttributesCalls[0]?.headers["x-app-user-id"]).toBe( + expect(apiDouble.state.syncCustomerAttributesCalls[0]?.headers["x-distinct-id"]).toBe( "signed-in-user" ); - expect(appUserIdFromCache).toBeNull(); + expect(distinctIdFromCache).toBeNull(); expect(cacheKeys).toEqual([]); expect(signOutEvents).toEqual(["customer-signed-out"]); } finally { diff --git a/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts b/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts index 1227475f7..1bfd5b0f8 100644 --- a/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts +++ b/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts @@ -44,10 +44,10 @@ export interface ApiClientDoubleOptions { syncTransactionShouldFail?: boolean; } -export function createSdkCustomer(appUserId: string) { +export function createSdkCustomer(distinctId: string) { return { - appUserId, - customerId: `customer-${appUserId}`, + distinctId, + customerId: `customer-${distinctId}`, email: null, name: null, } as SdkCustomer; @@ -81,16 +81,16 @@ export function createApiClientDouble(options: ApiClientDoubleOptions = {}) { }, getCustomer: (request: ApiSdkCall) => { state.getCustomerCalls.push(request); - const appUserId = String(request.headers["x-app-user-id"]); + const distinctId = String(request.headers["x-distinct-id"]); return Effect.succeed( - options.getCustomerResult ?? createSdkCustomer(appUserId) + options.getCustomerResult ?? createSdkCustomer(distinctId) ); }, identify: (request: ApiSdkCall) => { state.identifyCalls.push(request); - const appUserId = String(request.payload?.appUserId ?? "identified-user"); + const distinctId = String(request.payload?.distinctId ?? "identified-user"); return Effect.succeed( - options.identifyResult ?? createSdkCustomer(appUserId) + options.identifyResult ?? createSdkCustomer(distinctId) ); }, syncCustomerAttributes: (request: ApiSdkCall) => { diff --git a/libraries/react-native/src/client-effect.ts b/libraries/react-native/src/client-effect.ts index 40de0ed71..17dfdee61 100644 --- a/libraries/react-native/src/client-effect.ts +++ b/libraries/react-native/src/client-effect.ts @@ -1,4 +1,8 @@ -import { Effect, Schedule } from "effect"; +import type { + CaptureAcceptedResponse, + CaptureErrorResponse, +} from "@voidhash/api-spec/event-capture"; +import { Cause, Effect } from "effect"; import { SDK_VERSION } from "./core/constants"; import { CacheManager } from "./core/caching/cache-manager"; @@ -10,7 +14,6 @@ import { CustomerInfoManager } from "./core/identity/customer-info-manager"; import { IdentityManager } from "./core/identity/identity-manager"; import { ApiClient } from "./core/networking/api-client"; import { PaymentAdapter } from "./core/payment-adapters/payment-adapter"; -import { PlatformProvider } from "./core/platform/platform-provider"; import type { ExtractSchemaProductDefinitions, ExtractSchemaProductKeys, @@ -22,29 +25,18 @@ import { extractProductDefinitions } from "./core/schema/utils"; import { SdkConfiguration } from "./core/sdk-configuration"; import { getCommonSdkHeaders } from "./core/utils/get-common-sdk-headers"; import { UnsupportedPlatformError } from "./errors"; +import { AnalyticsIngestEvent, AnalyticsSendFailure, QueuedAnalyticsEvent } from "./core/analytics/types"; +import { createQueuedAnalyticsEvent, getAnalyticsStandardizedProperties, mapQueuedAnalyticsEventToIngestEvent } from "./core/analytics/utils"; +import { getNonce } from "./core/utils/crypto"; const PROCESSED_TRANSACTION_TTL_MS = 1000 * 60 * 30; -const ANALYTICS_RETRY_BASE_MS = 200; const ANALYTICS_BATCH_SIZE = 20; const ANALYTICS_FLUSH_INTERVAL_MS = 5000; +const MAX_ANALYTICS_RETRY_DELAY_MS = 30_000; +const RETRYABLE_ANALYTICS_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]); -const generateFallbackNonce = () => - `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; - -const getNonce = () => { - const cryptoObject = globalThis.crypto as { randomUUID?: () => string } | undefined; - return cryptoObject?.randomUUID?.() ?? generateFallbackNonce(); -}; -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null; -interface QueuedAnalyticsEvent { - readonly eventName: string; - readonly eventTimestamp: string; - readonly id: string; - readonly properties: Record; -} interface AppReleaseInfo { readonly appBuild: string | null; @@ -79,35 +71,50 @@ const getReactNativeAppState = (): ReactNativeAppState | null => { } }; -const toNullableString = (value: unknown) => - typeof value === "string" ? value : null; +const toNullableString = (value: unknown): string | null => + value !== null && value !== undefined ? String(value) : null; -const toAppReleaseInfo = (value: unknown) => { - if (!isRecord(value)) return null; +const toAppReleaseInfo = (value: AppReleaseInfo | undefined | null): AppReleaseInfo | null => { + if (!value) return null; return { - appBuild: toNullableString(value.appBuild), - appVersion: toNullableString(value.appVersion), + appBuild: value.appBuild, + appVersion: value.appVersion, }; }; -export interface AnalyticsIngestEvent { - /** Shared metadata attached to every event (for example app, device, or SDK context). */ - readonly context: Record; - /** Unique identifier for this event instance. */ - readonly event_id: string; - /** Canonical event name used for analytics processing. */ - readonly event_name: string; - /** Event timestamp in string form (typically ISO-8601). */ - readonly event_ts: string; - /** Event-specific payload fields for this event name. */ - readonly properties: Record; - /** Identifier that groups events belonging to the same user session. */ - readonly session_id: string; -} + + +const getAnalyticsRetryDelayMs = (attempts: number) => + Math.min(1000 * 2 ** Math.max(attempts - 1, 0), MAX_ANALYTICS_RETRY_DELAY_MS); + +const parseRetryAfterMs = (value: string | null): number | undefined => { + if (!value) { + return undefined; + } + + const retryAfterSeconds = Number(value); + if (!Number.isNaN(retryAfterSeconds) && retryAfterSeconds >= 0) { + return Math.ceil(retryAfterSeconds * 1000); + } + + const retryAt = Date.parse(value); + if (Number.isNaN(retryAt)) { + return undefined; + } + + return Math.max(retryAt - Date.now(), 0); +}; + +const getRetryAfterMsFromResponseBody = ( + data: CaptureAcceptedResponse | CaptureErrorResponse | undefined, +): number | undefined => + data && "retry_after_ms" in data && typeof data.retry_after_ms === "number" + ? data.retry_after_ms + : undefined; const makeUnitializedClient = () => ({ init: (initOptions: { - initialAppUserId?: string; + distinctId?: string; schema: TSchema; }) => Effect.gen(function* init() { @@ -115,30 +122,30 @@ const makeUnitializedClient = () => ({ const customerAttributeManager = yield* CustomerAttributeManager; const customerInfoManager = yield* CustomerInfoManager; - if (initOptions.initialAppUserId) { - // Identify as the user which ID was passed during SDK initialization - yield* Effect.logDebug("Initializing with initial user id", { - appUserId: initOptions.initialAppUserId, + if (initOptions.distinctId) { + // Identify as the distinct id provided during SDK initialization. + yield* Effect.logDebug("Initializing with provided distinct id", { + distinctId: initOptions.distinctId, }); // Sync customer attributes before identify to not lose historical customer data - const appUserId = yield* identityManager.getAppUserIdFromCache(); - if (appUserId) { - yield* customerAttributeManager.syncCustomerAttributes(appUserId); + const distinctId = yield* identityManager.getDistinctIdFromCache(); + if (distinctId) { + yield* customerAttributeManager.syncCustomerAttributes(distinctId); } - yield* identityManager.identify(initOptions.initialAppUserId, {}); + yield* identityManager.identify(initOptions.distinctId, {}); } else { - // If no user ID was passed during SDK initialization, fetch the last identified customer from the server - const appUserId = yield* identityManager.getAppUserId(); - yield* Effect.logDebug("Initializing without initial user id", { - appUserId, + // If no distinct id was passed during SDK initialization, fetch the current customer in the background. + const distinctId = yield* identityManager.getDistinctId(); + yield* Effect.logDebug("Initializing without provided distinct id", { + distinctId, }); - yield* customerAttributeManager.syncCustomerAttributes(appUserId); + yield* customerAttributeManager.syncCustomerAttributes(distinctId); // We don't need the result immediately. We do this to pre-fetch fresh customer data in the background. - yield* customerInfoManager.getCustomer(appUserId, "fetch"); + yield* customerInfoManager.getCustomer(distinctId, "fetch"); } // Return the initialized client @@ -148,72 +155,6 @@ const makeUnitializedClient = () => ({ }), }); -const getAnalyticsStandardizedProperties = () => { - let cached: Record | null = null; - - const fallbackProperties = { - $app_build: null, - $app_name: null, - $app_version: null, - $bundle_id: null, - $device_brand: null, - $device_name: null, - $locale: null, - $platform: "unknown", - $platform_version: null, - $sdk: "react-native", - $sdk_version: SDK_VERSION, - } satisfies Record; - - return () => - Effect.gen(function* () { - if (cached) return cached; - - const platformProvider = yield* PlatformProvider; - const props = { - $app_build: platformProvider.appBuild ?? null, - $app_name: platformProvider.appName ?? platformProvider.bundleId ?? null, - $app_version: platformProvider.appVersion ?? null, - $bundle_id: platformProvider.bundleId ?? null, - $device_brand: platformProvider.deviceBrand ?? null, - $device_name: platformProvider.deviceName ?? null, - $locale: platformProvider.locales[0]?.languageTag ?? null, - $platform: platformProvider.platform ?? "unknown", - $platform_version: platformProvider.systemVersion ?? null, - $sdk: "react-native", - $sdk_version: SDK_VERSION, - } satisfies Record; - - if (!isRecord(props)) { - cached = fallbackProperties; - return fallbackProperties; - } - - cached = props; - return props; - }).pipe( - Effect.orElseSucceed(() => { - cached = fallbackProperties; - return fallbackProperties; - }) - ); -}; - -const mapQueuedAnalyticsEventToIngestEvent = ( - event: QueuedAnalyticsEvent, - standardizedProperties: Record, - sessionId: string -) => ({ - context: {}, - event_id: event.id, - event_name: event.eventName, - event_ts: event.eventTimestamp, - properties: { - ...event.properties, - ...standardizedProperties, - }, - session_id: sessionId, -}); const makeInitializedClient = (options: { schema: TSchema; @@ -234,12 +175,32 @@ const makeInitializedClient = (options: { } }; + const getNextAnalyticsFlushDelayMs = () => { + if (analyticsQueue.length === 0) { + return null; + } + + const now = Date.now(); + const hasDueEvents = analyticsQueue.some((event) => event.availableAt <= now); + if (hasDueEvents) { + return ANALYTICS_FLUSH_INTERVAL_MS; + } + + const nextAvailableAt = Math.min(...analyticsQueue.map((event) => event.availableAt)); + return Math.max(nextAvailableAt - now, 0); + }; + const scheduleFlushTimer = () => { if (analyticsFlushTimer || analyticsQueue.length === 0) return; + const delayMs = getNextAnalyticsFlushDelayMs(); + if (delayMs === null) { + return; + } + analyticsFlushTimer = setTimeout(() => { analyticsFlushTimer = null; triggerFlushCallback?.(); - }, ANALYTICS_FLUSH_INTERVAL_MS); + }, delayMs); }; const sendAnalyticsEventsImpl = (events: ReadonlyArray) => @@ -248,7 +209,7 @@ const makeInitializedClient = (options: { const identityManager = yield* IdentityManager; const sdkConfiguration = yield* SdkConfiguration; - const appUserId = yield* identityManager.getAppUserId(); + const distinctId = yield* identityManager.getDistinctId(); const ingestEventsUrl = resolveIngestEventsUrl({ baseUrl: sdkConfiguration.baseUrl, ingestUrl: sdkConfiguration.ingestUrl, @@ -257,31 +218,182 @@ const makeInitializedClient = (options: { const response = yield* Effect.tryPromise({ try: () => fetch(ingestEventsUrl, { - body: JSON.stringify({ events }), + body: JSON.stringify({ + events: events.map((event) => ({ + context: event.context, + distinct_id: distinctId, + event: event.event_name, + properties: event.properties, + request: { + sdk_name: "react-native", + sdk_version: SDK_VERSION, + }, + session_id: event.session_id, + timestamp: event.event_ts, + uuid: event.event_id, + })), + sent_at: new Date().toISOString(), + token: sdkConfiguration.publishableKey, + }), headers: { "content-type": "application/json", - "x-app-user-id": appUserId, - "x-publishable-key": sdkConfiguration.publishableKey, }, method: "POST", }), catch: (cause) => - cause instanceof Error ? cause : new Error(String(cause)), + new AnalyticsSendFailure({ + cause, + message: "Analytics request failed", + retryable: true, + }), }); + const data = (yield* Effect.tryPromise({ + try: () => response.json() as Promise, + catch: (cause) => cause, + }).pipe( + Effect.orElseSucceed(() => undefined), + )) as CaptureAcceptedResponse | CaptureErrorResponse | undefined; + + if (response.status === 202) { + return; + } + + if (response.status === 413) { + return yield* Effect.fail( + new AnalyticsSendFailure({ + message: `Analytics ingest request failed: ${response.status} ${response.statusText}`, + retryable: false, + status: response.status, + }) + ); + } + + if (RETRYABLE_ANALYTICS_STATUS_CODES.has(response.status)) { + return yield* Effect.fail( + new AnalyticsSendFailure({ + message: `Analytics ingest request failed: ${response.status} ${response.statusText}`, + retryAfterMs: + parseRetryAfterMs(response.headers.get("retry-after")) ?? + getRetryAfterMsFromResponseBody(data), + retryable: true, + status: response.status, + }) + ); + } + if (!response.ok) { return yield* Effect.fail( - new Error( - `Analytics ingest request failed: ${response.status} ${response.statusText}` - ) + new AnalyticsSendFailure({ + message: `Analytics ingest request failed: ${response.status} ${response.statusText}`, + retryable: false, + status: response.status, + }) ); } - }).pipe( - Effect.retry({ - schedule: Schedule.exponential(ANALYTICS_RETRY_BASE_MS), - times: 3, - }) - ); + }); + + const buildQueuedAnalyticsBatchIds = (events: ReadonlyArray) => + new Set(events.map((event) => event.id)); + + const dropQueuedAnalyticsBatch = (events: ReadonlyArray) => { + const ids = buildQueuedAnalyticsBatchIds(events); + for (let index = analyticsQueue.length - 1; index >= 0; index -= 1) { + if (ids.has(analyticsQueue[index]!.id)) { + analyticsQueue.splice(index, 1); + } + } + }; + + const postponeQueuedAnalyticsBatch = ( + events: ReadonlyArray, + nextAvailableAt: number, + ) => { + const ids = buildQueuedAnalyticsBatchIds(events); + for (let index = 0; index < analyticsQueue.length; index += 1) { + const queuedEvent = analyticsQueue[index]!; + if (!ids.has(queuedEvent.id)) { + continue; + } + + analyticsQueue[index] = { + ...queuedEvent, + attempts: queuedEvent.attempts + 1, + availableAt: nextAvailableAt, + }; + } + }; + + const getDueQueuedAnalyticsBatch = () => { + const now = Date.now(); + const queuedBatch: QueuedAnalyticsEvent[] = []; + + for (const event of analyticsQueue) { + if (event.availableAt > now) { + break; + } + + queuedBatch.push(event); + if (queuedBatch.length >= ANALYTICS_BATCH_SIZE) { + break; + } + } + + return queuedBatch; + }; + + const processQueuedAnalyticsBatch = ( + queuedBatch: ReadonlyArray, + standardizedProperties: Record, + ): Effect.Effect => + Effect.gen(function* () { + const ingestBatch = queuedBatch.map((event) => + mapQueuedAnalyticsEventToIngestEvent(event, standardizedProperties, analyticsSessionId), + ); + + const sendResult = yield* Effect.exit(sendAnalyticsEventsImpl(ingestBatch)); + if (sendResult._tag === "Success") { + dropQueuedAnalyticsBatch(queuedBatch); + return; + } + + const failure = Cause.squash(sendResult.cause); + if (!(failure instanceof AnalyticsSendFailure)) { + return yield* Effect.fail( + new AnalyticsSendFailure({ + cause: failure, + message: failure instanceof Error ? failure.message : String(failure), + retryable: false, + }), + ); + } + + if (failure.status === 413 && queuedBatch.length > 1) { + const midpoint = Math.ceil(queuedBatch.length / 2); + yield* processQueuedAnalyticsBatch(queuedBatch.slice(0, midpoint), standardizedProperties); + yield* processQueuedAnalyticsBatch(queuedBatch.slice(midpoint), standardizedProperties); + return; + } + + if (failure.status === 413 && queuedBatch.length === 1) { + dropQueuedAnalyticsBatch(queuedBatch); + yield* Effect.logWarning("Dropping analytics event after 413 response", { + eventId: queuedBatch[0]?.id, + }); + return; + } + + if (!failure.retryable) { + dropQueuedAnalyticsBatch(queuedBatch); + yield* Effect.logWarning("Dropping analytics batch after non-retryable response", { + eventIds: queuedBatch.map((event) => event.id), + status: failure.status, + }); + return; + } + + return yield* Effect.fail(failure); + }); const processObservedTransaction = (transaction: Transaction) => Effect.gen(function* processObservedTransaction() { @@ -313,7 +425,7 @@ const makeInitializedClient = (options: { const sdkConfiguration = yield* SdkConfiguration; const commonHeaders = yield* getCommonSdkHeaders(); - const appUserId = yield* identityManager.getAppUserId(); + const distinctId = yield* identityManager.getDistinctId(); if (transaction.platform === "android" && !transaction.purchaseToken) { yield* Effect.logWarning( "Skipping observed Android transaction without purchase token", @@ -327,7 +439,7 @@ const makeInitializedClient = (options: { yield* apiClient.sdk.syncTransaction({ headers: { ...commonHeaders, - "x-app-user-id": appUserId, + "x-distinct-id": distinctId, }, payload: mapTransactionToSyncPayload(transaction), }); @@ -404,11 +516,11 @@ const makeInitializedClient = (options: { } const commonHeaders = yield* getCommonSdkHeaders(); - const appUserId = yield* identityManager.getAppUserId(); + const distinctId = yield* identityManager.getDistinctId(); const result = yield* apiClient.sdk.evaluateFeatureFlags({ headers: { ...commonHeaders, - "x-app-user-id": appUserId, + "x-distinct-id": distinctId, }, payload: { flagKeys }, }); @@ -430,12 +542,12 @@ const makeInitializedClient = (options: { const identityManager = yield* IdentityManager; const commonHeaders = yield* getCommonSdkHeaders(); - const appUserId = yield* identityManager.getAppUserId(); + const distinctId = yield* identityManager.getDistinctId(); return yield* apiClient.sdk.resolvePaywall({ headers: { ...commonHeaders, - "x-app-user-id": appUserId, + "x-distinct-id": distinctId, }, payload: { locationSlug }, }); @@ -446,15 +558,21 @@ const makeInitializedClient = (options: { const identityManager = yield* IdentityManager; const customerInfoManager = yield* CustomerInfoManager; - const appUserId = yield* identityManager.getAppUserId(); + const distinctId = yield* identityManager.getDistinctId(); const customer = yield* customerInfoManager.getCustomer( - appUserId, + distinctId, forceFetch ? "fetch" : "fetch-while-stale" ); return customer; }), + getDistinctId: () => + Effect.gen(function* getDistinctId() { + const identityManager = yield* IdentityManager; + return yield* identityManager.getDistinctId(); + }), + getProducts: () => Effect.gen(function* getProducts() { const productDefinitions = extractProductDefinitions(options.schema); @@ -463,7 +581,7 @@ const makeInitializedClient = (options: { }), identify: ( - appUserId: string, + distinctId: string, options: { email?: string; name?: string; @@ -471,7 +589,7 @@ const makeInitializedClient = (options: { ) => Effect.gen(function* identify() { const identityManager = yield* IdentityManager; - return yield* identityManager.identify(appUserId, options); + return yield* identityManager.identify(distinctId, options); }), iosPresentCodeRedemptionSheet: () => @@ -525,8 +643,8 @@ const makeInitializedClient = (options: { yield* reconcileObservedTransactions(); - const appUserId = yield* identityManager.getAppUserId(); - yield* customerInfoManager.getCustomer(appUserId, "fetch"); + const distinctId = yield* identityManager.getDistinctId(); + yield* customerInfoManager.getCustomer(distinctId, "fetch"); }), reconcileObservedTransactions: () => reconcileObservedTransactions(), @@ -537,12 +655,7 @@ const makeInitializedClient = (options: { Effect.sync(() => { const normalized = eventName.trim(); if (!normalized) return; - analyticsQueue.push({ - eventName: normalized, - eventTimestamp: new Date().toISOString(), - id: getNonce(), - properties, - }); + analyticsQueue.push(createQueuedAnalyticsEvent(normalized, properties)); if (analyticsQueue.length >= ANALYTICS_BATCH_SIZE) { clearFlushTimer(); triggerFlushCallback?.(); @@ -559,14 +672,28 @@ const makeInitializedClient = (options: { const standardizedProperties = yield* getStandardizedProperties(); while (analyticsQueue.length > 0) { - const queuedBatch = analyticsQueue.splice(0, ANALYTICS_BATCH_SIZE); - const ingestBatch = queuedBatch.map((event) => - mapQueuedAnalyticsEventToIngestEvent(event, standardizedProperties, analyticsSessionId) - ); + const queuedBatch = getDueQueuedAnalyticsBatch(); + if (queuedBatch.length === 0) { + scheduleFlushTimer(); + return; + } - const sendResult = yield* Effect.exit(sendAnalyticsEventsImpl(ingestBatch)); + const sendResult = yield* Effect.exit( + processQueuedAnalyticsBatch(queuedBatch, standardizedProperties), + ); if (sendResult._tag === "Failure") { - analyticsQueue.unshift(...queuedBatch); + const failure = Cause.squash(sendResult.cause); + if (failure instanceof AnalyticsSendFailure && failure.retryable) { + postponeQueuedAnalyticsBatch( + queuedBatch, + Date.now() + + (failure.retryAfterMs ?? + getAnalyticsRetryDelayMs((queuedBatch[0]?.attempts ?? 0) + 1)), + ); + scheduleFlushTimer(); + return; + } + yield* Effect.failCause(sendResult.cause); } } @@ -588,12 +715,7 @@ const makeInitializedClient = (options: { for (const event of events) { const normalized = event.eventName.trim(); if (!normalized) continue; - analyticsQueue.push({ - eventName: normalized, - eventTimestamp: new Date().toISOString(), - id: getNonce(), - properties: event.properties, - }); + analyticsQueue.push(createQueuedAnalyticsEvent(normalized, event.properties)); } }), @@ -613,42 +735,22 @@ const makeInitializedClient = (options: { const previousAppRelease = toAppReleaseInfo(cachedRelease?.value); if (!previousAppRelease) { - analyticsQueue.push({ - eventName: "app_installed", - eventTimestamp: new Date().toISOString(), - id: getNonce(), - properties: {}, - }); + analyticsQueue.push(createQueuedAnalyticsEvent("app_installed", {})); } else if ( previousAppRelease.appBuild !== currentAppRelease.appBuild || previousAppRelease.appVersion !== currentAppRelease.appVersion ) { - analyticsQueue.push({ - eventName: "app_updated", - eventTimestamp: new Date().toISOString(), - id: getNonce(), - properties: {}, - }); + analyticsQueue.push(createQueuedAnalyticsEvent("app_updated", {})); } - analyticsQueue.push({ - eventName: "app_opened", - eventTimestamp: new Date().toISOString(), - id: getNonce(), - properties: {}, - }); + analyticsQueue.push(createQueuedAnalyticsEvent("app_opened", {})); yield* cacheManager.set( ANALYTICS_LAST_SEEN_APP_RELEASE_STORAGE_KEY, currentAppRelease ); } catch { - analyticsQueue.push({ - eventName: "app_opened", - eventTimestamp: new Date().toISOString(), - id: getNonce(), - properties: {}, - }); + analyticsQueue.push(createQueuedAnalyticsEvent("app_opened", {})); } }), @@ -685,10 +787,16 @@ const makeInitializedClient = (options: { sendAnalyticsEvents: (events: ReadonlyArray) => sendAnalyticsEventsImpl(events), + reset: () => + Effect.gen(function* reset() { + const identityManager = yield* IdentityManager; + return yield* identityManager.reset(); + }), + signOut: () => Effect.gen(function* signOut() { const identityManager = yield* IdentityManager; - return yield* identityManager.signOut(); + return yield* identityManager.reset(); }), startTransactionObserver: ( @@ -806,7 +914,7 @@ const resolveIngestEventsUrl = (options: { const baseUrl = options.ingestUrl ? new URL(options.ingestUrl) : buildDefaultIngestBaseUrl(options.baseUrl); - return new URL("/v1/events", baseUrl).toString(); + return new URL("/batch", baseUrl).toString(); }; const buildDefaultIngestBaseUrl = (apiBaseUrl: string) => { diff --git a/libraries/react-native/src/client-react-native.ts b/libraries/react-native/src/client-react-native.ts index 42d0ac389..736219377 100644 --- a/libraries/react-native/src/client-react-native.ts +++ b/libraries/react-native/src/client-react-native.ts @@ -23,8 +23,8 @@ export function createVoidhashClient( ) { const baseUrl = options.baseUrl || "https://api.voidhash.com"; const debug = options.debug ?? false; + const distinctId = options.distinctId ?? null; const ingestUrl = options.ingestUrl; - const initialAppUserId = options.userId ?? null; const readOnly = options.readOnly ?? false; const unstableSwallowErrors = options.unstable_swallowErrors ?? false; const scheme = @@ -41,7 +41,7 @@ export function createVoidhashClient( const platform = RNPlatform.OS === "ios" ? "ios" : "android"; const client = new VoidhashClient( - initialAppUserId, + distinctId, scheme, schema, baseUrl, diff --git a/libraries/react-native/src/client.tsx b/libraries/react-native/src/client.tsx index a3f9c6672..63b44e830 100644 --- a/libraries/react-native/src/client.tsx +++ b/libraries/react-native/src/client.tsx @@ -20,16 +20,17 @@ import type { } from "./core/schema"; import { SdkConfiguration } from "./core/sdk-configuration"; import { ReadOnlyModePurchaseNotAllowedError, VoidhashError } from "./errors"; +import { AnalyticsService } from "./core/analytics/service"; export interface VoidhashClientOptions { baseUrl?: string; debug?: boolean; + distinctId?: string; ingestUrl?: string; readOnly?: boolean; schema: TSchema; scheme?: string; unstable_swallowErrors?: boolean; - userId?: string; } const CreateEffectRuntime = ( @@ -44,6 +45,7 @@ const CreateEffectRuntime = ( ManagedRuntime.make( pipe( CustomerAttributeManager.Default, + Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(CustomerInfoManager.Default), Layer.provideMerge(IdentityManager.Default), Layer.provideMerge(CacheManager.Default), @@ -89,7 +91,7 @@ export class VoidhashClient { private analyticsFlushInFlight: Promise | null = null; private appLifecycleSubscription: { remove: () => void } | null = null; private preInitAnalyticsBuffer: Array<{ eventName: string; properties: Record }> = []; - private initialAppUserId: string | null; + private initialDistinctId: string | null; private readOnly: boolean; private scheme: string; private schema: TSchema; @@ -102,7 +104,7 @@ export class VoidhashClient { private initializedClient?: InitializedEffectClient; constructor( - initialAppUserId: string | null, + initialDistinctId: string | null, scheme: string, schema: TSchema, baseUrl: string, @@ -114,7 +116,7 @@ export class VoidhashClient { platform: Exclude, debug = false ) { - this.initialAppUserId = initialAppUserId; + this.initialDistinctId = initialDistinctId; this.readOnly = readOnly; this.scheme = scheme; this.schema = schema; @@ -155,7 +157,7 @@ export class VoidhashClient { await this.runSideEffect("init", async () => { const initializedClient = await this.runEffect( this.unitializedClient.init({ - initialAppUserId: this.initialAppUserId ?? undefined, + distinctId: this.initialDistinctId ?? undefined, schema: this.schema, }), "FAILED_TO_INITIALIZE_VOIDHASH_CLIENT" @@ -238,12 +240,16 @@ export class VoidhashClient { return this.runEffect(this.initializedClient!.getCurrentCustomer(forceFetch), "FAILED_TO_GET_CURRENT_CUSTOMER"); } + async getDistinctId() { + this.ensureInitialized(); + return this.runEffect(this.initializedClient!.getDistinctId(), "FAILED_TO_GET_DISTINCT_ID"); + } + /** - * Identifies the user. - * @param appUserId - Id used to identify the user. Make sure it is unique and hard to guess. + * Identifies the user by switching the current distinct id. */ async identify( - appUserId: string, + externalUserId: string, options: { email?: string; name?: string; @@ -251,20 +257,27 @@ export class VoidhashClient { ) { await this.runSideEffect("identify", async () => { this.ensureInitialized(); - await this.runEffect(this.initializedClient!.identify(appUserId, options), "FAILED_TO_IDENTIFY"); + await this.runEffect( + this.initializedClient!.identify(externalUserId, options), + "FAILED_TO_IDENTIFY" + ); }); } /** - * Signs out the user. + * Resets the current identity to a fresh anonymous distinct id. */ - async signOut() { - await this.runSideEffect("signOut", async () => { + async reset() { + await this.runSideEffect("reset", async () => { this.ensureInitialized(); - await this.runEffect(this.initializedClient!.signOut(), "FAILED_TO_SIGN_OUT"); + await this.runEffect(this.initializedClient!.reset(), "FAILED_TO_RESET"); }); } + async signOut() { + return this.reset(); + } + /** * Returns feature flag evaluation results. * @param flagKeys - Optional array of specific flag keys to evaluate. If omitted, evaluates all flags. diff --git a/libraries/react-native/src/constants.ts b/libraries/react-native/src/constants.ts index e7446a7a6..271cfa2d8 100644 --- a/libraries/react-native/src/constants.ts +++ b/libraries/react-native/src/constants.ts @@ -1 +1 @@ -export const ANONYMOUS_USER_ID_PREFIX = "vh:anon:"; +export const ANONYMOUS_DISTINCT_ID_PREFIX = "vh:anon:"; diff --git a/libraries/react-native/src/core/analytics/constants.ts b/libraries/react-native/src/core/analytics/constants.ts new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/react-native/src/core/analytics/service.ts b/libraries/react-native/src/core/analytics/service.ts new file mode 100644 index 000000000..8e164ef84 --- /dev/null +++ b/libraries/react-native/src/core/analytics/service.ts @@ -0,0 +1,13 @@ +import { Effect, Layer, ServiceMap } from "effect"; + + +export class AnalyticsService extends ServiceMap.Service()("voidhash-react-native/AnalyticsService", { + make: Effect.gen(function* () { + + // const config = yield* Config; + // return { log: (msg: string) => Effect.log(`[${config.prefix}] ${msg}`) }; + }), +}) { + // Build the layer yourself from the make effect + static readonly layer = Layer.effect(this, this.make); +} \ No newline at end of file diff --git a/libraries/react-native/src/core/analytics/types.ts b/libraries/react-native/src/core/analytics/types.ts new file mode 100644 index 000000000..d49f313f5 --- /dev/null +++ b/libraries/react-native/src/core/analytics/types.ts @@ -0,0 +1,43 @@ +export interface QueuedAnalyticsEvent { + readonly attempts: number; + readonly availableAt: number; + readonly eventName: string; + readonly eventTimestamp: string; + readonly id: string; + readonly properties: Record; +} + +export interface AnalyticsIngestEvent { + /** Shared metadata attached to every event (for example app, device, or SDK context). */ + readonly context: Record; + /** Unique identifier for this event instance. */ + readonly event_id: string; + /** Canonical event name used for analytics processing. */ + readonly event_name: string; + /** Event timestamp in string form (typically ISO-8601). */ + readonly event_ts: string; + /** Event-specific payload fields for this event name. */ + readonly properties: Record; + /** Identifier that groups events belonging to the same user session. */ + readonly session_id: string; +} + +export class AnalyticsSendFailure extends Error { + readonly retryAfterMs?: number; + readonly retryable: boolean; + readonly status?: number; + + constructor(input: { + readonly message: string; + readonly retryable: boolean; + readonly retryAfterMs?: number; + readonly status?: number; + readonly cause?: unknown; + }) { + super(input.message, input.cause ? { cause: input.cause } : undefined); + this.name = "AnalyticsSendFailure"; + this.retryAfterMs = input.retryAfterMs; + this.retryable = input.retryable; + this.status = input.status; + } +} diff --git a/libraries/react-native/src/core/analytics/utils.ts b/libraries/react-native/src/core/analytics/utils.ts new file mode 100644 index 000000000..531b66a41 --- /dev/null +++ b/libraries/react-native/src/core/analytics/utils.ts @@ -0,0 +1,77 @@ +import { Effect } from "effect"; +import { SDK_VERSION } from "../constants"; +import { getNonce } from "../utils/crypto"; +import { QueuedAnalyticsEvent } from "./types"; +import { PlatformProvider } from "../platform/platform-provider"; + +export const createQueuedAnalyticsEvent = ( + eventName: string, + properties: Record, +): QueuedAnalyticsEvent => ({ + attempts: 0, + availableAt: Date.now(), + eventName, + eventTimestamp: new Date().toISOString(), + id: getNonce(), + properties, +}); + +export const getAnalyticsStandardizedProperties = () => { + let cached: Record | null = null; + + const fallbackProperties = { + $app_build: null, + $app_name: null, + $app_version: null, + $bundle_id: null, + $device_brand: null, + $device_name: null, + $locale: null, + $platform: "unknown", + $platform_version: null, + $sdk: "react-native", + $sdk_version: SDK_VERSION, + } satisfies Record; + + return () => + Effect.gen(function* () { + if (cached) return cached; + const platformProvider = yield* PlatformProvider; + const props = { + $app_build: platformProvider.appBuild ?? null, + $app_name: platformProvider.appName ?? platformProvider.bundleId ?? null, + $app_version: platformProvider.appVersion ?? null, + $bundle_id: platformProvider.bundleId ?? null, + $device_brand: platformProvider.deviceBrand ?? null, + $device_name: platformProvider.deviceName ?? null, + $locale: platformProvider.locales[0]?.languageTag ?? null, + $platform: platformProvider.platform ?? "unknown", + $platform_version: platformProvider.systemVersion ?? null, + $sdk: "react-native", + $sdk_version: SDK_VERSION, + }; + cached = props; + return props; + }).pipe( + Effect.orElseSucceed(() => { + cached = fallbackProperties; + return fallbackProperties; + }) + ); +}; + +export const mapQueuedAnalyticsEventToIngestEvent = ( + event: QueuedAnalyticsEvent, + standardizedProperties: Record, + sessionId: string +) => ({ + context: {}, + event_id: event.id, + event_name: event.eventName, + event_ts: event.eventTimestamp, + properties: { + ...event.properties, + ...standardizedProperties, + }, + session_id: sessionId, +}); diff --git a/libraries/react-native/src/core/identity/customer-attribute-manager.ts b/libraries/react-native/src/core/identity/customer-attribute-manager.ts index 2f90b73c4..7ce1771b5 100644 --- a/libraries/react-native/src/core/identity/customer-attribute-manager.ts +++ b/libraries/react-native/src/core/identity/customer-attribute-manager.ts @@ -13,37 +13,37 @@ const make = Effect.gen(function* effect() { const cacheManager = yield* CacheManager; const apiClient = yield* ApiClient; - const getCustomerAttributes = (appUserId: string) => + const getCustomerAttributes = (distinctId: string) => cacheManager .get( - generateCustomerAttributesCacheKey(appUserId) + generateCustomerAttributesCacheKey(distinctId) ) .pipe(Effect.map((attributes) => attributes?.value ?? null)); const setCustomerAttributes = ( - appUserId: string, + distinctId: string, attributes: CustomerAttributes ) => cacheManager.set( - generateCustomerAttributesCacheKey(appUserId), + generateCustomerAttributesCacheKey(distinctId), attributes ); - const syncCustomerAttributes = (appUserId: string) => + const syncCustomerAttributes = (distinctId: string) => Effect.gen(function* syncCustomerAttributes() { - let attributes = yield* getCustomerAttributes(appUserId); + let attributes = yield* getCustomerAttributes(distinctId); if (!attributes) { attributes = { email: undefined, name: undefined, }; - yield* setCustomerAttributes(appUserId, attributes); + yield* setCustomerAttributes(distinctId, attributes); } const commonHeaders = yield* getCommonSdkHeaders(); yield* apiClient.sdk.syncCustomerAttributes({ headers: { ...commonHeaders, - "x-app-user-id": appUserId, + "x-distinct-id": distinctId, }, payload: { email: attributes.email, @@ -52,8 +52,8 @@ const make = Effect.gen(function* effect() { }); }); - const generateCustomerAttributesCacheKey = (appUserId: string) => - `customer-attributes:${appUserId}`; + const generateCustomerAttributesCacheKey = (distinctId: string) => + `customer-attributes:${distinctId}`; return { getCustomerAttributes, diff --git a/libraries/react-native/src/core/identity/customer-info-manager.ts b/libraries/react-native/src/core/identity/customer-info-manager.ts index 4549db98a..b70ce1f6a 100644 --- a/libraries/react-native/src/core/identity/customer-info-manager.ts +++ b/libraries/react-native/src/core/identity/customer-info-manager.ts @@ -11,51 +11,51 @@ const make = Effect.gen(function* effect() { const apiClient = yield* ApiClient; const eventBus = yield* EventBusProvider; - const generateCustomerCacheKey = (appUserId: string) => - `customer:${appUserId}`; + const generateCustomerCacheKey = (distinctId: string) => + `customer:${distinctId}`; - const getCustomerFromCache = (appUserId: string) => - cacheManager.get(generateCustomerCacheKey(appUserId)); + const getCustomerFromCache = (distinctId: string) => + cacheManager.get(generateCustomerCacheKey(distinctId)); - const cache = (appUserId: string, customer: SdkCustomer) => - cacheManager.set(generateCustomerCacheKey(appUserId), customer, { + const cache = (distinctId: string, customer: SdkCustomer) => + cacheManager.set(generateCustomerCacheKey(distinctId), customer, { ttl: 1000 * 60 * 60 * 24 * 2, // 2 days staleTime: 1000 * 60 * 5, // 5 minutes }); - const resetCache = (appUserId: string) => - cacheManager.delete(generateCustomerCacheKey(appUserId)); + const resetCache = (distinctId: string) => + cacheManager.delete(generateCustomerCacheKey(distinctId)); - const getCustomerFromServerAndCache = (appUserId: string) => + const getCustomerFromServerAndCache = (distinctId: string) => Effect.gen(function* getCustomerFromServerAndCache() { const commonHeaders = yield* getCommonSdkHeaders(); const result = yield* apiClient.sdk.getCustomer({ headers: { ...commonHeaders, - "x-app-user-id": appUserId, + "x-distinct-id": distinctId, }, }); eventBus.emit("customer-fetched", result); - yield* cache(appUserId, result); + yield* cache(distinctId, result); return result; }); const getCustomer = ( - appUserId: string, + distinctId: string, cachePolicy: "cache" | "fetch" | "fetch-while-stale" ) => Effect.gen(function* getCustomer() { if (cachePolicy === "cache") { - const customerFromCache = yield* getCustomerFromCache(appUserId); + const customerFromCache = yield* getCustomerFromCache(distinctId); return customerFromCache?.value ?? null; } if (cachePolicy === "fetch") { - return yield* getCustomerFromServerAndCache(appUserId); + return yield* getCustomerFromServerAndCache(distinctId); } // fetch-while-stale policy - const customerFromCache = yield* getCustomerFromCache(appUserId); + const customerFromCache = yield* getCustomerFromCache(distinctId); if ( customerFromCache && !customerFromCache.isStale && @@ -65,7 +65,7 @@ const make = Effect.gen(function* effect() { return customerFromCache.value; } - return yield* getCustomerFromServerAndCache(appUserId); + return yield* getCustomerFromServerAndCache(distinctId); }); return { diff --git a/libraries/react-native/src/core/identity/identity-manager.ts b/libraries/react-native/src/core/identity/identity-manager.ts index f5e35c77b..d05e9e6e2 100644 --- a/libraries/react-native/src/core/identity/identity-manager.ts +++ b/libraries/react-native/src/core/identity/identity-manager.ts @@ -1,6 +1,6 @@ import { Effect, Layer, ServiceMap } from "effect"; -import { ANONYMOUS_USER_ID_PREFIX } from "../../constants"; +import { ANONYMOUS_DISTINCT_ID_PREFIX } from "../../constants"; import { CacheManager } from "../caching/cache-manager"; import { EventBusProvider } from "../event-bus"; import { ApiClient } from "../networking/api-client"; @@ -8,7 +8,7 @@ import { getCommonSdkHeaders } from "../utils/get-common-sdk-headers"; import { CustomerAttributeManager } from "./customer-attribute-manager"; import { CustomerInfoManager } from "./customer-info-manager"; -const CACHE_KEY = "appUserId"; +const CACHE_KEY = "distinctId"; const make = Effect.gen(function* effect() { const cacheManager = yield* CacheManager; @@ -18,89 +18,88 @@ const make = Effect.gen(function* effect() { const apiClient = yield* ApiClient; /** - * Returns the app user id. If no app user id is cached, a new anonymous user id is generated and cached. - * @returns The app user id. + * Returns the current distinct id. If none is cached, a new anonymous distinct id is generated and cached. */ - const getAppUserId = () => - Effect.gen(function* getAppUserId() { - const appUserId = yield* getAppUserIdFromCache(); - if (appUserId) { - yield* Effect.logDebug(`Using cached app user id: ${appUserId}`); - return appUserId; + const getDistinctId = () => + Effect.gen(function* getDistinctId() { + const distinctId = yield* getDistinctIdFromCache(); + if (distinctId) { + yield* Effect.logDebug(`Using cached distinct id: ${distinctId}`); + return distinctId; } - const anonymousUserId = generateAnonymousUserId(); - yield* setAppUserIdInCache(anonymousUserId); - return anonymousUserId; + const anonymousDistinctId = generateAnonymousDistinctId(); + yield* setDistinctIdInCache(anonymousDistinctId); + return anonymousDistinctId; }); /** - * Identifies the customer. It makes a request to the server to identify the customer and caches the app user id. - * @param appUserId - The app user id. + * Identifies the customer by switching the current distinct id. * @param options - The options. */ const identify = ( - appUserId: string, + distinctId: string, options: { email?: string; name?: string; } ) => Effect.gen(function* identify() { - const currentAppUserId = yield* getAppUserId(); + const currentDistinctId = yield* getDistinctId(); yield* customerAttributeManager.syncCustomerAttributes( - currentAppUserId + currentDistinctId ); const commonHeaders = yield* getCommonSdkHeaders(); const identifyRequest = yield* apiClient.sdk.identify({ headers: { ...commonHeaders, - "x-app-user-id": currentAppUserId, + "x-distinct-id": currentDistinctId, }, payload: { - appUserId, + distinctId, email: options.email, name: options.name, }, }); yield* Effect.all([ - setAppUserIdInCache(appUserId), - customerInfoManager.cache(appUserId, identifyRequest), + setDistinctIdInCache(distinctId), + customerInfoManager.cache(distinctId, identifyRequest), ]); eventBus.emit("customer-identified"); eventBus.emit("customer-fetched", { ...identifyRequest, - appUserId, + distinctId, }); }); - const signOut = () => - Effect.gen(function* signOut() { - const currentAppUserId = yield* getAppUserId(); + const reset = () => + Effect.gen(function* reset() { + const currentDistinctId = yield* getDistinctId(); yield* customerAttributeManager.syncCustomerAttributes( - currentAppUserId + currentDistinctId ); yield* cacheManager.clear(); eventBus.emit("customer-signed-out"); }); // Helpers - const generateAnonymousUserId = () => - `${ANONYMOUS_USER_ID_PREFIX}${Math.random().toString(36).slice(2, 15)}`; - const getAppUserIdFromCache = () => + const generateAnonymousDistinctId = () => + `${ANONYMOUS_DISTINCT_ID_PREFIX}${Math.random().toString(36).slice(2, 15)}`; + const getDistinctIdFromCache = () => cacheManager .get(CACHE_KEY) - .pipe(Effect.map((appUserId) => appUserId?.value ?? null)); - const setAppUserIdInCache = (appUserId: string) => - cacheManager.set(CACHE_KEY, appUserId); + .pipe(Effect.map((distinctId) => distinctId?.value ?? null)); + const setDistinctIdInCache = (distinctId: string) => + cacheManager.set(CACHE_KEY, distinctId); return { - getAppUserId, - getAppUserIdFromCache, + getDistinctId, + getDistinctIdFromCache, identify, - signOut, + reset, + signOut: reset, } as const; }); diff --git a/libraries/react-native/src/core/testing/client.ts b/libraries/react-native/src/core/testing/client.ts index ff62f1a69..c75bc743a 100644 --- a/libraries/react-native/src/core/testing/client.ts +++ b/libraries/react-native/src/core/testing/client.ts @@ -16,7 +16,7 @@ // import { TestPlatformProvider } from './platform-provider'; // type CreateVoidhashTestClientOptions = { -// initialAppUserId?: string; +// initialDistinctId?: string; // schema: VoidhashSchema; // platformInfo?: Partial; // eventBus?: EventBus; @@ -29,7 +29,7 @@ // }; // export function createVoidhashTestClient({ -// initialAppUserId, +// initialDistinctId, // schema, // platformInfo, // cacheManager: cacheManagerOverride, @@ -72,7 +72,7 @@ // ); // const paymentAdapter = paymentAdapterOverride ?? new TestPaymentAdapter(); // const voidhashClient = new VoidhashClient( -// initialAppUserId ?? null, +// initialDistinctId ?? null, // scheme, // logger, // cacheManager, diff --git a/libraries/react-native/src/core/types.ts b/libraries/react-native/src/core/types.ts index 1f454d49f..ffc902763 100644 --- a/libraries/react-native/src/core/types.ts +++ b/libraries/react-native/src/core/types.ts @@ -61,7 +61,7 @@ export interface CustomerResponse { customerId: string; name: null; email: null; - appUserId: null; + distinctId: null; } export interface PaywallProduct { diff --git a/libraries/react-native/src/core/utils/crypto.ts b/libraries/react-native/src/core/utils/crypto.ts new file mode 100644 index 000000000..c9675a33a --- /dev/null +++ b/libraries/react-native/src/core/utils/crypto.ts @@ -0,0 +1,7 @@ +export const generateFallbackNonce = () => + `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; + +export const getNonce = () => { + const cryptoObject = globalThis.crypto as { randomUUID?: () => string } | undefined; + return cryptoObject?.randomUUID?.() ?? generateFallbackNonce(); +}; diff --git a/libraries/react-native/src/core/utils/get-common-sdk-headers.ts b/libraries/react-native/src/core/utils/get-common-sdk-headers.ts index d3a2c4a71..1ccfd36ac 100644 --- a/libraries/react-native/src/core/utils/get-common-sdk-headers.ts +++ b/libraries/react-native/src/core/utils/get-common-sdk-headers.ts @@ -15,7 +15,7 @@ const getNonce = () => { }; export const getCommonSdkHeaders = (): Effect.Effect< - Omit, + Omit, never, PlatformProvider | SdkConfiguration | IdentityManager > => diff --git a/libraries/web/package.json b/libraries/web/package.json index 19cbcf99d..39d3eb042 100644 --- a/libraries/web/package.json +++ b/libraries/web/package.json @@ -1,7 +1,22 @@ { "name": "@voidhash/web", "version": "0.0.1-alpha.1", + "private": false, "description": "Browser-first Voidhash SDK.", + "keywords": [ + "voidhash", + "sdk", + "web", + "browser", + "react", + "payments" + ], + "homepage": "https://voidhash.com/docs", + "bugs": { + "url": "https://github.com/voidhashcom/voidhash/issues" + }, + "license": "MIT", + "author": "Voidhash (https://voidhash.com)", "repository": { "type": "git", "url": "https://github.com/voidhashcom/voidhash", @@ -11,6 +26,14 @@ "files": [ "dist" ], + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "sideEffects": false, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "exports": { ".": { "import": { @@ -40,9 +63,7 @@ "test:watch": "vitest -c vitest.unit.mts" }, "dependencies": { - "@effect/platform": "catalog:", - "@voidhash/api-spec": "workspace:*", - "effect": "catalog:" + "@voidhash/api-spec": "workspace:*" }, "devDependencies": { "@types/react": "catalog:", @@ -58,7 +79,9 @@ "vitest": "^3.2.4" }, "peerDependencies": { - "react": "*" + "react": "*", + "@effect/platform": "catalog:", + "effect": "catalog:" }, "peerDependenciesMeta": { "react": { diff --git a/libraries/web/src/client-effect.ts b/libraries/web/src/client-effect.ts index 3b741c781..6ab648ae8 100644 --- a/libraries/web/src/client-effect.ts +++ b/libraries/web/src/client-effect.ts @@ -1,3 +1,6 @@ +import { Cause, Effect, Exit, Layer, ManagedRuntime, pipe } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; + import { VoidhashConfigurationError } from "./errors"; import type { AnalyticsFlushResult, @@ -6,24 +9,26 @@ import type { VoidhashTrackOptions, VoidhashTraits, } from "./types"; -import { createAnalyticsEvent } from "./core/analytics/analytics-context"; -import { AnalyticsDispatcher } from "./core/analytics/analytics-dispatcher"; -import { AnalyticsQueue } from "./core/analytics/analytics-queue"; +import { AnalyticsService } from "./core/analytics/analytics-service"; import { CacheManager } from "./core/caching/cache-manager"; -import { LocalStorageCacheAdapter } from "./core/caching/adapters/local-storage-cache"; -import { MemoryCacheAdapter } from "./core/caching/adapters/memory-cache"; -import { EventBus } from "./core/event-bus"; +import { createBrowserCacheAdapterLayer } from "./core/caching/adapters/browser-cache-adapter"; +import { type EventBus, EventBusProvider } from "./core/event-bus"; import { FeatureFlagService } from "./core/feature-flags/feature-flag-service"; -import { AnalyticsHttpClient } from "./core/http/analytics-client"; -import { SdkApiClient } from "./core/http/sdk-api-client"; import { IdentityManager } from "./core/identity/identity-manager"; -import { BrowserPlatformProvider } from "./core/platform/browser-platform-provider"; +import { ApiClient } from "./core/networking/api-client"; +import { EventCaptureApiClient } from "./core/networking/event-capture-api-client"; +import { + BrowserPlatformProviderLayer, +} from "./core/platform/platform-provider"; +import { SdkConfiguration } from "./core/sdk-configuration"; const DEFAULT_BASE_URL = "https://api.voidhash.com"; const assertPositiveInteger = (name: string, value: number) => { if (!Number.isInteger(value) || value < 1) { - throw new VoidhashConfigurationError(`${name} must be a positive integer.`); + throw new VoidhashConfigurationError( + `${name} must be a positive integer.` + ); } }; @@ -81,240 +86,122 @@ export const resolveVoidhashConfig = ( refreshOnVisibility: options.featureFlags?.refreshOnVisibility ?? true, ttlMs, }, - initialAppUserId: options.initialAppUserId, + distinctId: options.distinctId, observerMode: options.observerMode ?? false, publishableKey: options.publishableKey, }; }; -export class VoidhashClientEffect { - private readonly analyticsDispatcher: AnalyticsDispatcher; - private readonly analyticsHttpClient: AnalyticsHttpClient; - private readonly analyticsQueue: AnalyticsQueue; - private readonly cache: CacheManager; - private readonly eventBus: EventBus; - private readonly featureFlags: FeatureFlagService; - private readonly identityManager: IdentityManager; - private listeners: Array<() => void> = []; - private readonly platform: BrowserPlatformProvider; - private readonly sdkApiClient: SdkApiClient; - - constructor(private readonly config: ResolvedVoidhashConfig) { - this.platform = new BrowserPlatformProvider(); - this.eventBus = new EventBus(); - this.cache = new CacheManager( - `@voidhash/web:${this.config.publishableKey}:${this.config.baseUrl}`, - new MemoryCacheAdapter(), - LocalStorageCacheAdapter.create() - ); - this.sdkApiClient = new SdkApiClient( - this.config.baseUrl, - this.config.publishableKey, - this.config.observerMode, - this.platform - ); - this.analyticsHttpClient = new AnalyticsHttpClient( - this.config.analytics.baseUrl, - this.config.publishableKey - ); - this.identityManager = new IdentityManager( - this.cache, - this.sdkApiClient, - this.eventBus, - this.platform - ); - this.featureFlags = new FeatureFlagService( - this.cache, - this.sdkApiClient, - this.eventBus, - this.config.featureFlags.ttlMs, - async () => this.identityManager.getAppUserId() - ); - this.analyticsQueue = new AnalyticsQueue( - this.cache, - this.config.analytics.maxQueueSize - ); - this.analyticsDispatcher = new AnalyticsDispatcher( - this.analyticsQueue, - this.analyticsHttpClient, - { - flushIntervalMs: this.config.analytics.flushIntervalMs, - maxBatchBytes: this.config.analytics.maxBatchBytes, - maxBatchSize: this.config.analytics.maxBatchSize, - }, - this.eventBus - ); - } - - async destroy() { - this.detachBrowserListeners(); - const flushResult = this.config.analytics.enabled - ? await this.analyticsDispatcher.flush({ force: true }).catch(() => null) - : null; - this.analyticsDispatcher.stop(); - await this.sdkApiClient.destroy(); - return flushResult; - } - - getAppUserId() { - return this.identityManager.getAppUserId(); - } - - getEventBus() { - return this.eventBus; - } - - getFeatureVariant(key: string) { - return this.featureFlags.getVariant(key); - } - - isFeatureEnabled(key: string) { - return this.featureFlags.isEnabled(key); - } - - async flushAnalytics(): Promise { - if (!this.config.analytics.enabled) { - return null; - } - - return this.analyticsDispatcher.flush({ force: true }); - } - - async getFeatureFlags(keys?: string[]) { - return this.featureFlags.getFeatureFlags(keys); - } - - async identify(appUserId: string, traits?: VoidhashTraits) { - await this.identityManager.identify(appUserId, traits); - await this.featureFlags.clearCachedFlags(); - await this.featureFlags.refreshTrackedKeySets(); - } - - async initialize() { - const appUserId = await this.identityManager.initialize( - this.config.initialAppUserId - ); - - if (this.config.analytics.enabled) { - this.analyticsDispatcher.start(); - } - - this.attachBrowserListeners(); - - if (this.config.featureFlags.prefetchOnInit) { - await this.featureFlags.getFeatureFlags(); - } - - this.eventBus.emit("initialized", { appUserId }); - } - - async page( - pageName?: string, - properties?: Record, - options?: VoidhashTrackOptions - ) { - const pageProperties = pageName - ? { - ...properties, - page_name: pageName, - } - : properties; - - await this.track("page", pageProperties, options); - } - - async refreshFeatureFlags(keys?: string[]) { - return this.featureFlags.refreshFeatureFlags(keys); - } - - async resetIdentity() { - await this.identityManager.resetIdentity(); - await this.featureFlags.clearCachedFlags(); - await this.featureFlags.refreshTrackedKeySets(); - } - - async track( - eventName: string, - properties?: Record, - options?: VoidhashTrackOptions - ) { - if (!this.config.analytics.enabled) { - return; - } - - const appUserId = await this.identityManager.getAppUserId(); - if (!appUserId) { - return; - } +export const CreateEffectRuntime = ( + config: ResolvedVoidhashConfig, + eventBus: EventBus +) => + ManagedRuntime.make( + Layer.fresh( + pipe( + AnalyticsService.Default, + Layer.provideMerge(FeatureFlagService.Default), + Layer.provideMerge(IdentityManager.Default), + Layer.provideMerge(CacheManager.Default), + Layer.provideMerge(ApiClient.Default), + Layer.provideMerge(EventCaptureApiClient.Default), + Layer.provideMerge( + Layer.effect( + FetchHttpClient.Fetch, + Effect.sync(() => globalThis.fetch) + ).pipe(Layer.provideMerge(FetchHttpClient.layer)) + ), + Layer.provideMerge(createBrowserCacheAdapterLayer()), + Layer.provideMerge(BrowserPlatformProviderLayer), + Layer.provideMerge(Layer.succeed(EventBusProvider, eventBus)), + Layer.provideMerge(Layer.succeed(SdkConfiguration, config)) + ) + ) + ); - const event = createAnalyticsEvent( - this.platform, +// Effects that run against the runtime +export const initializeEffect = (initialDistinctId?: string) => + Effect.gen(function* initialize() { + const identityManager = yield* IdentityManager; + return yield* identityManager.initialize(initialDistinctId); + }); + +export const identifyEffect = ( + distinctId: string, + traits?: VoidhashTraits +) => + Effect.gen(function* identify() { + const identityManager = yield* IdentityManager; + const featureFlags = yield* FeatureFlagService; + yield* identityManager.identify(distinctId, traits); + yield* featureFlags.clearCachedFlags(); + yield* featureFlags.refreshTrackedKeySets(); + }); + +export const resetEffect = () => + Effect.gen(function* reset() { + const identityManager = yield* IdentityManager; + const featureFlags = yield* FeatureFlagService; + yield* identityManager.reset(); + yield* featureFlags.clearCachedFlags(); + yield* featureFlags.refreshTrackedKeySets(); + }); + +export const trackEffect = ( + eventName: string, + properties?: Record, + options?: VoidhashTrackOptions +) => + Effect.gen(function* track() { + const analyticsService = yield* AnalyticsService; + const queueLength = yield* analyticsService.enqueue( eventName, properties, options ); - const droppedCount = await this.analyticsQueue.enqueue({ - appUserId, - id: event.event_id, - payload: event, - }); - - if (droppedCount > 0) { - this.eventBus.emit("error", { - message: `Dropped ${droppedCount} analytics event(s) because the queue is full.`, - source: "analytics", - }); - } - - const queueSize = await this.analyticsQueue.size(); - if (queueSize >= this.config.analytics.maxBatchSize) { - await this.analyticsDispatcher.flush(); - } - } - - private attachBrowserListeners() { - if (typeof window === "undefined") { - return; - } - - const onlineHandler = () => { - if (this.config.featureFlags.refreshOnOnline) { - void this.featureFlags.refreshTrackedKeySets(); - } - }; - const pageHideHandler = () => { - if (this.config.analytics.enabled) { - void this.analyticsDispatcher.flush({ force: true, keepalive: true }); - } - }; - const visibilityHandler = () => { - if ( - this.config.featureFlags.refreshOnVisibility && - typeof document !== "undefined" && - document.visibilityState === "visible" - ) { - void this.featureFlags.refreshTrackedKeySets(); - } - }; - - window.addEventListener("online", onlineHandler); - window.addEventListener("pagehide", pageHideHandler); - if (typeof document !== "undefined") { - document.addEventListener("visibilitychange", visibilityHandler); - } - - this.listeners.push(() => window.removeEventListener("online", onlineHandler)); - this.listeners.push(() => window.removeEventListener("pagehide", pageHideHandler)); - if (typeof document !== "undefined") { - this.listeners.push(() => - document.removeEventListener("visibilitychange", visibilityHandler) - ); + if (queueLength !== undefined && queueLength >= 20) { + yield* analyticsService.flush(); } - } - - private detachBrowserListeners() { - for (const cleanup of this.listeners.splice(0)) { - cleanup(); - } - } -} + }); + +export const getFeatureFlagsEffect = (keys?: string[]) => + Effect.gen(function* getFeatureFlags() { + const featureFlags = yield* FeatureFlagService; + return yield* featureFlags.getFeatureFlags(keys); + }); + +export const refreshFeatureFlagsEffect = (keys?: string[]) => + Effect.gen(function* refreshFeatureFlags() { + const featureFlags = yield* FeatureFlagService; + return yield* featureFlags.refreshFeatureFlags(keys); + }); + +export const refreshTrackedKeySetsEffect = () => + Effect.gen(function* refreshTrackedKeySets() { + const featureFlags = yield* FeatureFlagService; + yield* featureFlags.refreshTrackedKeySets(); + }); + +export const flushAnalyticsEffect = () => + Effect.gen(function* flushAnalytics() { + const analyticsService = yield* AnalyticsService; + return yield* analyticsService.flush(); + }); + +export const startAnalyticsEffect = () => + Effect.gen(function* startAnalytics() { + const analyticsService = yield* AnalyticsService; + analyticsService.start(); + }); + +export const stopAnalyticsEffect = () => + Effect.gen(function* stopAnalytics() { + const analyticsService = yield* AnalyticsService; + analyticsService.stop(); + }); + +export const flushAnalyticsKeepaliveEffect = () => + Effect.gen(function* flushKeepalive() { + const analyticsService = yield* AnalyticsService; + return yield* analyticsService.flush({ keepalive: true }); + }); diff --git a/libraries/web/src/client.ts b/libraries/web/src/client.ts index c149cdce6..a9e2d4858 100644 --- a/libraries/web/src/client.ts +++ b/libraries/web/src/client.ts @@ -1,5 +1,24 @@ -import { VoidhashDestroyedError, VoidhashNotInitializedError } from "./errors"; -import { VoidhashClientEffect, resolveVoidhashConfig } from "./client-effect"; +import { Cause, Effect, Exit } from "effect"; + +import { VoidhashDestroyedError, VoidhashError, VoidhashNotInitializedError } from "./errors"; +import { + CreateEffectRuntime, + flushAnalyticsEffect, + flushAnalyticsKeepaliveEffect, + getFeatureFlagsEffect, + identifyEffect, + initializeEffect, + refreshFeatureFlagsEffect, + refreshTrackedKeySetsEffect, + resetEffect, + resolveVoidhashConfig, + startAnalyticsEffect, + stopAnalyticsEffect, + trackEffect, +} from "./client-effect"; +import { EventBus } from "./core/event-bus"; +import { FeatureFlagService } from "./core/feature-flags/feature-flag-service"; +import { IdentityManager } from "./core/identity/identity-manager"; import type { AnalyticsFlushResult, FeatureFlagsResult, @@ -12,13 +31,104 @@ import type { type ClientState = "destroyed" | "idle" | "initializing" | "ready"; +const toError = (errorCode: string, cause: unknown) => { + const error = + cause instanceof Error ? cause : new Error(String(cause)); + return new VoidhashError(`${errorCode}: ${error.message}`, { + cause: error, + }); +}; + export class VoidhashWebClient { - private readonly effect: VoidhashClientEffect; + private readonly eventBus: EventBus; + private readonly runtime: ReturnType; private state: ClientState = "idle"; private initializePromise: Promise | null = null; + private listeners: Array<() => void> = []; + private inFlightFlush: Promise | null = null; + + // Service references for sync access (set during init) + private featureFlagService: InstanceType | null = null; + private identityManagerService: InstanceType | null = null; constructor(private readonly options: VoidhashClientOptions) { - this.effect = new VoidhashClientEffect(resolveVoidhashConfig(options)); + const config = resolveVoidhashConfig(options); + this.eventBus = new EventBus(); + this.runtime = CreateEffectRuntime(config, this.eventBus); + } + + // biome-ignore lint/suspicious/noExplicitAny: Effect requires service type parameter + private async runEffect(effect: Effect.Effect, errorCode: string): Promise { + const result = await this.runtime.runPromiseExit(effect); + if (Exit.isSuccess(result)) return result.value; + throw toError(errorCode, Cause.squash(result.cause)); + } + + async initialize() { + if (this.state === "destroyed") { + throw new VoidhashDestroyedError(); + } + + if (this.state === "ready") { + return; + } + + if (this.initializePromise) { + return this.initializePromise; + } + + this.state = "initializing"; + const config = resolveVoidhashConfig(this.options); + + this.initializePromise = (async () => { + const distinctId = await this.runEffect( + initializeEffect(config.distinctId), + "FAILED_TO_INITIALIZE" + ); + + // Grab service references for sync access + this.featureFlagService = await this.runEffect( + Effect.gen(function* () { return yield* FeatureFlagService; }), + "FAILED_TO_INITIALIZE" + ); + this.identityManagerService = await this.runEffect( + Effect.gen(function* () { return yield* IdentityManager; }), + "FAILED_TO_INITIALIZE" + ); + + if (config.analytics.enabled) { + await this.runEffect(startAnalyticsEffect(), "FAILED_TO_START_ANALYTICS"); + + // Handle scheduled flush events from the analytics service timer + this.eventBus.on("analytics-flush-needed", () => { + void this.flushAnalyticsInternal().catch((error) => { + this.eventBus.emit("error", { + error, + message: "Scheduled analytics flush failed.", + source: "analytics", + }); + }); + }); + } + + this.attachBrowserListeners(config); + + if (config.featureFlags.prefetchOnInit) { + await this.runEffect( + getFeatureFlagsEffect(), + "FAILED_TO_PREFETCH_FLAGS" + ); + } + + this.eventBus.emit("initialized", { distinctId }); + this.state = "ready"; + })(); + + try { + await this.initializePromise; + } finally { + this.initializePromise = null; + } } async destroy() { @@ -30,76 +140,78 @@ export class VoidhashWebClient { await this.initializePromise; } - await this.effect.destroy(); + this.detachBrowserListeners(); + + const config = resolveVoidhashConfig(this.options); + if (config.analytics.enabled) { + try { + await this.flushAnalyticsInternal(); + } catch { + // Best effort + } + await this.runEffect(stopAnalyticsEffect(), "FAILED_TO_STOP_ANALYTICS"); + } + + await this.runtime.dispose(); this.state = "destroyed"; } - getAppUserId() { + getDistinctId() { if (this.state !== "ready") { return null; } - - return this.effect.getAppUserId(); + return this.identityManagerService?.getDistinctId() ?? null; } - async getFeatureFlags(keys?: string[]) { + isFeatureEnabled(key: string) { this.ensureReady(); - return this.effect.getFeatureFlags(keys); + return this.featureFlagService!.isEnabled(key); } getFeatureVariant(key: string) { this.ensureReady(); - return this.effect.getFeatureVariant(key); + return this.featureFlagService!.getVariant(key); } - async identify(appUserId: string, traits?: VoidhashTraits) { + async getFeatureFlags(keys?: string[]) { this.ensureReady(); - await this.effect.identify(appUserId, traits); + return this.runEffect( + getFeatureFlagsEffect(keys), + "FAILED_TO_GET_FEATURE_FLAGS" + ); } - async initialize() { - if (this.state === "destroyed") { - throw new VoidhashDestroyedError(); - } - - if (this.state === "ready") { - return; - } - - if (this.initializePromise) { - return this.initializePromise; - } - - this.state = "initializing"; - this.initializePromise = this.effect - .initialize() - .then(() => { - this.state = "ready"; - }) - .finally(() => { - this.initializePromise = null; - }); - - return this.initializePromise; + async refreshFeatureFlags(keys?: string[]) { + this.ensureReady(); + return this.runEffect( + refreshFeatureFlagsEffect(keys), + "FAILED_TO_REFRESH_FEATURE_FLAGS" + ); } - isFeatureEnabled(key: string) { + async identify(externalUserId: string, traits?: VoidhashTraits) { this.ensureReady(); - return this.effect.isFeatureEnabled(key); + await this.runEffect( + identifyEffect(externalUserId, traits), + "FAILED_TO_IDENTIFY" + ); } - off( - eventName: TEvent, - handler: (payload: VoidhashEventMap[TEvent]) => void - ) { - this.effect.getEventBus().off(eventName, handler); + async reset() { + this.ensureReady(); + await this.runEffect(resetEffect(), "FAILED_TO_RESET"); } - on( - eventName: TEvent, - handler: (payload: VoidhashEventMap[TEvent]) => void + async track( + eventName: string, + properties?: Record, + options?: VoidhashTrackOptions ) { - return this.effect.getEventBus().on(eventName, handler); + this.ensureReady(); + await this.runEffect( + trackEffect(eventName, properties, options), + "FAILED_TO_TRACK" + ); } async page( @@ -108,31 +220,42 @@ export class VoidhashWebClient { options?: VoidhashTrackOptions ) { this.ensureReady(); - await this.effect.page(pageName, properties, options); + const pageProperties = pageName + ? { ...properties, page_name: pageName } + : properties; + await this.track("page", pageProperties, options); } - async refreshFeatureFlags(keys?: string[]) { + async flushAnalytics(): Promise { this.ensureReady(); - return this.effect.refreshFeatureFlags(keys); + return this.flushAnalyticsInternal(); } - async resetIdentity() { - this.ensureReady(); - await this.effect.resetIdentity(); + on( + eventName: TEvent, + handler: (payload: VoidhashEventMap[TEvent]) => void + ) { + return this.eventBus.on(eventName, handler); } - async track( - eventName: string, - properties?: Record, - options?: VoidhashTrackOptions + off( + eventName: TEvent, + handler: (payload: VoidhashEventMap[TEvent]) => void ) { - this.ensureReady(); - await this.effect.track(eventName, properties, options); + this.eventBus.off(eventName, handler); } - async flushAnalytics(): Promise { - this.ensureReady(); - return this.effect.flushAnalytics(); + private async flushAnalyticsInternal(): Promise { + if (this.inFlightFlush) return this.inFlightFlush; + + this.inFlightFlush = this.runEffect( + flushAnalyticsEffect(), + "FAILED_TO_FLUSH_ANALYTICS" + ).finally(() => { + this.inFlightFlush = null; + }); + + return this.inFlightFlush; } private ensureReady() { @@ -144,6 +267,65 @@ export class VoidhashWebClient { throw new VoidhashNotInitializedError(); } } + + private attachBrowserListeners(config: ReturnType) { + if (typeof window === "undefined") { + return; + } + + const onlineHandler = () => { + if (config.featureFlags.refreshOnOnline) { + void this.runEffect( + refreshTrackedKeySetsEffect(), + "FAILED_TO_REFRESH_FLAGS" + ).catch(() => {}); + } + }; + + const pageHideHandler = () => { + if (config.analytics.enabled) { + void this.runEffect( + flushAnalyticsKeepaliveEffect(), + "FAILED_TO_FLUSH" + ).catch(() => {}); + } + }; + + const visibilityHandler = () => { + if ( + config.featureFlags.refreshOnVisibility && + typeof document !== "undefined" && + document.visibilityState === "visible" + ) { + void this.runEffect( + refreshTrackedKeySetsEffect(), + "FAILED_TO_REFRESH_FLAGS" + ).catch(() => {}); + } + }; + + window.addEventListener("online", onlineHandler); + window.addEventListener("pagehide", pageHideHandler); + this.listeners.push(() => + window.removeEventListener("online", onlineHandler) + ); + this.listeners.push(() => + window.removeEventListener("pagehide", pageHideHandler) + ); + + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", visibilityHandler); + this.listeners.push(() => + document.removeEventListener("visibilitychange", visibilityHandler) + ); + } + } + + private detachBrowserListeners() { + for (const cleanup of this.listeners.splice(0)) { + cleanup(); + } + } } export const createVoidhashClient = (options: VoidhashClientOptions) => diff --git a/libraries/web/src/core/analytics/analytics-context.ts b/libraries/web/src/core/analytics/analytics-context.ts index 8bc99b0bc..6ab6d4d63 100644 --- a/libraries/web/src/core/analytics/analytics-context.ts +++ b/libraries/web/src/core/analytics/analytics-context.ts @@ -1,10 +1,54 @@ +import type { + EventContextField, + EventPropertiesField, +} from "@voidhash/api-spec/event-capture"; + import type { VoidhashTrackOptions } from "../../types"; import { BrowserPlatformProvider } from "../platform/browser-platform-provider"; import type { AnalyticsRequestEvent } from "./contracts"; -const trimUndefined = (entries: Record) => +const normalizeAnalyticsValue = ( + value: unknown +): EventContextField | EventPropertiesField | undefined => { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return value + .map((entry) => normalizeAnalyticsValue(entry)) + .filter((entry) => typeof entry !== "undefined"); + } + + if (typeof value === "object") { + return Object.fromEntries( + Object.entries(value).flatMap(([key, entry]) => { + const normalized = normalizeAnalyticsValue(entry); + return typeof normalized === "undefined" ? [] : [[key, normalized]]; + }) + ); + } + + return undefined; +}; + +const normalizeAnalyticsRecord = ( + entries: Record +): Record => Object.fromEntries( - Object.entries(entries).filter(([, value]) => typeof value !== "undefined") + Object.entries(entries).flatMap(([key, value]) => { + const normalized = normalizeAnalyticsValue(value); + return typeof normalized === "undefined" ? [] : [[key, normalized]]; + }) ); const createEventId = (platform: BrowserPlatformProvider) => @@ -12,14 +56,16 @@ const createEventId = (platform: BrowserPlatformProvider) => export const createAnalyticsEvent = ( platform: BrowserPlatformProvider, + distinctId: string, eventName: string, properties?: Record, options?: VoidhashTrackOptions ): AnalyticsRequestEvent => ({ - context: platform.buildAnalyticsContext(), - event_id: options?.eventId ?? createEventId(platform), - event_name: eventName, - event_ts: options?.timestamp ?? new Date().toISOString(), - properties: trimUndefined(properties ?? {}), + context: normalizeAnalyticsRecord(platform.buildAnalyticsContext()), + distinct_id: distinctId, + event: eventName, + properties: normalizeAnalyticsRecord(properties ?? {}), + timestamp: options?.timestamp ? new Date(options.timestamp) : new Date(), session_id: options?.sessionId, + uuid: options?.eventId ?? createEventId(platform), }); diff --git a/libraries/web/src/core/analytics/analytics-dispatcher.ts b/libraries/web/src/core/analytics/analytics-dispatcher.ts deleted file mode 100644 index f0243fdc1..000000000 --- a/libraries/web/src/core/analytics/analytics-dispatcher.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { VoidhashAnalyticsError } from "../../errors"; -import type { EventBus } from "../event-bus"; -import { AnalyticsHttpClient } from "../http/analytics-client"; -import { AnalyticsQueue } from "./analytics-queue"; -import type { - AnalyticsRequestEvent, - AnalyticsSendBatchResult, - QueuedAnalyticsEvent, -} from "./contracts"; - -const MAX_INGEST_BATCH_SIZE = 100; -const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]); - -const buildIdSet = (events: ReadonlyArray) => - new Set(events.map((event) => event.id)); - -const getBackoffMs = (attempts: number) => - Math.min(1000 * 2 ** Math.max(attempts - 1, 0), 30_000); - -export class AnalyticsDispatcher { - private flushIntervalId: ReturnType | null = null; - private inFlightFlush: Promise | null = null; - - constructor( - private readonly queue: AnalyticsQueue, - private readonly client: AnalyticsHttpClient, - private readonly config: { - readonly flushIntervalMs: number; - readonly maxBatchBytes: number; - readonly maxBatchSize: number; - }, - private readonly eventBus: EventBus - ) {} - - start() { - if (this.flushIntervalId) { - return; - } - - this.flushIntervalId = setInterval(() => { - void this.flush().catch((error) => { - this.eventBus.emit("error", { - error, - message: "Scheduled analytics flush failed.", - source: "analytics", - }); - }); - }, this.config.flushIntervalMs); - } - - stop() { - if (this.flushIntervalId) { - clearInterval(this.flushIntervalId); - this.flushIntervalId = null; - } - } - - async flush(options?: { force?: boolean; keepalive?: boolean }) { - if (this.inFlightFlush) { - return this.inFlightFlush; - } - - this.inFlightFlush = this.flushInternal(options).finally(() => { - this.inFlightFlush = null; - }); - - return this.inFlightFlush; - } - - private async flushInternal(options?: { force?: boolean; keepalive?: boolean }) { - const batch = await this.queue.peekBatch({ - ignoreAvailability: options?.force, - maxBatchBytes: this.config.maxBatchBytes, - maxBatchSize: Math.min(this.config.maxBatchSize, MAX_INGEST_BATCH_SIZE), - }); - - if (batch.length === 0) { - return null; - } - - return this.sendBatch(batch, options); - } - - private async sendBatch( - batch: ReadonlyArray, - options?: { keepalive?: boolean } - ): Promise { - if (batch.length === 0) { - return null; - } - - const distinctId = batch[0]?.appUserId; - if (!distinctId) { - return null; - } - - try { - const result = await this.client.send( - distinctId, - { events: batch.map((entry) => entry.payload as AnalyticsRequestEvent) }, - options - ); - const ids = buildIdSet(batch); - - if (result.status === 202) { - await this.queue.drop(ids); - const response = { - accepted: Number(result.data?.accepted ?? batch.length), - rejected: Number(result.data?.rejected ?? 0), - requestId: - typeof result.data?.request_id === "string" - ? result.data.request_id - : undefined, - }; - - this.eventBus.emit("analytics-flushed", response); - if (response.rejected > 0) { - this.eventBus.emit("analytics-partial-rejection", response); - } - - return response; - } - - if (result.status === 413) { - return this.handlePayloadTooLarge(batch, options); - } - - if (RETRYABLE_STATUS_CODES.has(result.status)) { - await this.queue.postpone( - ids, - Date.now() + getBackoffMs((batch[0]?.attempts ?? 0) + 1) - ); - return null; - } - - await this.queue.drop(ids); - this.eventBus.emit("error", { - message: `Dropping analytics batch after non-retryable ${result.status} response.`, - source: "analytics", - }); - return null; - } catch (error) { - if (error instanceof VoidhashAnalyticsError) { - const ids = buildIdSet(batch); - await this.queue.postpone( - ids, - Date.now() + getBackoffMs((batch[0]?.attempts ?? 0) + 1) - ); - return null; - } - - throw error; - } - } - - private async handlePayloadTooLarge( - batch: ReadonlyArray, - options?: { keepalive?: boolean } - ): Promise { - if (batch.length === 1) { - await this.queue.drop(buildIdSet(batch)); - this.eventBus.emit("error", { - message: "Dropping analytics event after 413 response.", - source: "analytics", - }); - return { - accepted: 0, - rejected: 1, - }; - } - - const midpoint = Math.ceil(batch.length / 2); - const first = await this.sendBatch(batch.slice(0, midpoint), options); - const second = await this.sendBatch(batch.slice(midpoint), options); - - if (!first && !second) { - return null; - } - - return { - accepted: (first?.accepted ?? 0) + (second?.accepted ?? 0), - rejected: (first?.rejected ?? 0) + (second?.rejected ?? 0), - requestId: second?.requestId ?? first?.requestId, - }; - } -} diff --git a/libraries/web/src/core/analytics/analytics-queue.ts b/libraries/web/src/core/analytics/analytics-queue.ts deleted file mode 100644 index 1919a5e0e..000000000 --- a/libraries/web/src/core/analytics/analytics-queue.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { CacheManager } from "../caching/cache-manager"; -import type { QueuedAnalyticsEvent } from "./contracts"; - -const QUEUE_KEY = "analytics:queue"; - -const estimateEventBytes = (event: QueuedAnalyticsEvent) => - new TextEncoder().encode(JSON.stringify(event.payload)).byteLength; - -export class AnalyticsQueue { - private events: QueuedAnalyticsEvent[] = []; - private isLoaded = false; - - constructor( - private readonly cache: CacheManager, - private readonly maxQueueSize: number - ) {} - - async drop(ids: ReadonlySet) { - await this.load(); - this.events = this.events.filter((event) => !ids.has(event.id)); - await this.persist(); - } - - async enqueue(event: Omit) { - await this.load(); - this.events.push({ - ...event, - attempts: 0, - availableAt: Date.now(), - }); - const droppedCount = Math.max(this.events.length - this.maxQueueSize, 0); - if (droppedCount > 0) { - this.events.splice(0, droppedCount); - } - await this.persist(); - return droppedCount; - } - - async peekBatch(input: { - ignoreAvailability?: boolean; - maxBatchBytes: number; - maxBatchSize: number; - now?: number; - }) { - await this.load(); - const now = input.now ?? Date.now(); - const dueEvents = this.events.filter((event) => - input.ignoreAvailability ? true : event.availableAt <= now - ); - - if (dueEvents.length === 0) { - return []; - } - - const firstAppUserId = dueEvents[0]?.appUserId; - const selected: QueuedAnalyticsEvent[] = []; - let totalBytes = 0; - - for (const event of dueEvents) { - if (event.appUserId !== firstAppUserId) { - break; - } - - const nextBytes = estimateEventBytes(event); - if (selected.length > 0 && totalBytes + nextBytes > input.maxBatchBytes) { - break; - } - - selected.push(event); - totalBytes += nextBytes; - - if (selected.length >= input.maxBatchSize) { - break; - } - } - - return selected; - } - - async postpone(ids: ReadonlySet, nextAvailableAt: number) { - await this.load(); - this.events = this.events.map((event) => - ids.has(event.id) - ? { - ...event, - attempts: event.attempts + 1, - availableAt: nextAvailableAt, - } - : event - ); - await this.persist(); - } - - async size() { - await this.load(); - return this.events.length; - } - - private async load() { - if (this.isLoaded) { - return; - } - - const cached = await this.cache.get(QUEUE_KEY); - this.events = cached?.value ?? []; - this.isLoaded = true; - } - - private async persist() { - await this.cache.set(QUEUE_KEY, this.events); - } -} diff --git a/libraries/web/src/core/analytics/analytics-service.ts b/libraries/web/src/core/analytics/analytics-service.ts new file mode 100644 index 000000000..dfbff832c --- /dev/null +++ b/libraries/web/src/core/analytics/analytics-service.ts @@ -0,0 +1,398 @@ +import { Effect, Layer, Schema, ServiceMap } from "effect"; +import { CaptureAcceptedResponse } from "@voidhash/api-spec/event-capture"; + +import type { AnalyticsFlushResult } from "../../types"; +import { CacheManager } from "../caching/cache-manager"; +import { EventBusProvider } from "../event-bus"; +import { IdentityManager } from "../identity/identity-manager"; +import { PlatformProvider } from "../platform/platform-provider"; +import { SdkConfiguration } from "../sdk-configuration"; +import { createAnalyticsEvent } from "./analytics-context"; +import type { + AnalyticsRequestEvent, + QueuedAnalyticsEvent, +} from "./contracts"; + +const QUEUE_KEY = "analytics:queue"; +const MAX_INGEST_BATCH_SIZE = 100; +const RETRYABLE_ERROR_CODES = new Set([ + "rate_limited", + "dependency_unavailable", + "internal_error", +]); +const getBackoffMs = (attempts: number) => + Math.min(1000 * 2 ** Math.max(attempts - 1, 0), 30_000); + +const estimateEventBytes = (event: QueuedAnalyticsEvent) => + new TextEncoder().encode(JSON.stringify(event.payload)).byteLength; + +const buildIdSet = (events: ReadonlyArray) => + new Set(events.map((event) => event.id)); + +const extractCaptureError = (input: { + data?: unknown; + status: number; +}): { code: string; retry_after_ms?: number } | null => { + if (input.data && typeof input.data === "object") { + const err = input.data as Record; + if (typeof err.code === "string") { + return { + code: err.code, + retry_after_ms: + typeof err.retry_after_ms === "number" ? err.retry_after_ms : undefined, + }; + } + } + + switch (input.status) { + case 400: + return { code: "invalid_request" }; + case 401: + return { code: "unauthorized" }; + case 413: + return { code: "payload_too_large" }; + case 429: + return { code: "rate_limited" }; + case 500: + return { code: "internal_error" }; + case 503: + return { code: "dependency_unavailable" }; + default: + return null; + } +}; + +const make = Effect.gen(function* effect() { + const cacheManager = yield* CacheManager; + const config = yield* SdkConfiguration; + const eventBus = yield* EventBusProvider; + const identityManager = yield* IdentityManager; + const platform = yield* PlatformProvider; + + // Mutable queue state + let events: QueuedAnalyticsEvent[] = []; + let isLoaded = false; + let flushIntervalId: ReturnType | null = null; + + // Queue persistence + const loadQueue = () => + Effect.gen(function* loadQueue() { + if (isLoaded) return; + const cached = + yield* cacheManager.get(QUEUE_KEY); + events = cached?.value ?? []; + isLoaded = true; + }); + + const persistQueue = () => cacheManager.set(QUEUE_KEY, events); + + // Queue operations + const dropEvents = (ids: ReadonlySet) => { + events = events.filter((event) => !ids.has(event.id)); + }; + + const postponeEvents = ( + ids: ReadonlySet, + nextAvailableAt: number + ) => { + events = events.map((event) => + ids.has(event.id) + ? { ...event, attempts: event.attempts + 1, availableAt: nextAvailableAt } + : event + ); + }; + + const peekBatch = (input: { + maxBatchBytes: number; + maxBatchSize: number; + }) => { + const now = Date.now(); + const dueEvents = events.filter((event) => event.availableAt <= now); + if (dueEvents.length === 0) return []; + + const firstDistinctId = dueEvents[0]?.payload.distinct_id; + const selected: QueuedAnalyticsEvent[] = []; + let totalBytes = 0; + + for (const event of dueEvents) { + if (event.payload.distinct_id !== firstDistinctId) break; + const nextBytes = estimateEventBytes(event); + if (selected.length > 0 && totalBytes + nextBytes > input.maxBatchBytes) break; + selected.push(event); + totalBytes += nextBytes; + if (selected.length >= input.maxBatchSize) break; + } + + return selected; + }; + + const sendBatchViaClient = ( + batchEvents: ReadonlyArray + ) => + Effect.tryPromise({ + try: async () => { + const response = await fetch(new URL("/batch", config.analytics.baseUrl), { + body: JSON.stringify({ + events: batchEvents, + sent_at: new Date().toISOString(), + token: config.publishableKey, + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + + let data: unknown; + try { + data = await response.json(); + } catch { + data = undefined; + } + + return { + data, + status: response.status, + }; + }, + catch: () => ({ _tag: "AnalyticsSendFailure" as const }), + }); + + // Raw fetch fallback — only used for keepalive on pagehide + const sendBatchKeepalive = ( + batchEvents: ReadonlyArray + ) => + Effect.tryPromise({ + try: () => + fetch(new URL("/batch", config.analytics.baseUrl), { + body: JSON.stringify({ + events: batchEvents, + sent_at: new Date().toISOString(), + token: config.publishableKey, + }), + headers: { "content-type": "application/json" }, + keepalive: true, + method: "POST", + }), + catch: () => ({ _tag: "KeepaliveSendFailure" as const }), + }); + + // Core flush logic + const sendBatch = ( + batch: ReadonlyArray, + options?: { keepalive?: boolean } + ): Effect.Effect => + Effect.gen(function* sendBatchEffect() { + if (batch.length === 0) return null; + + const distinctId = batch[0]?.payload.distinct_id; + if (!distinctId) return null; + + const batchPayloads = batch.map((entry) => entry.payload); + const ids = buildIdSet(batch); + + // keepalive sends use raw fetch (best-effort, fire-and-forget) + if (options?.keepalive) { + const result = yield* Effect.exit( + sendBatchKeepalive(batchPayloads) + ); + if (result._tag === "Success") { + dropEvents(ids); + yield* persistQueue(); + } + return null; + } + + const result = yield* Effect.exit( + sendBatchViaClient(batchPayloads) + ); + + if (result._tag === "Success") { + if (result.value.status === 202) { + const decodedResponse = yield* Effect.exit( + Schema.decodeUnknownEffect(CaptureAcceptedResponse)(result.value.data) + ); + if (decodedResponse._tag !== "Success") { + postponeEvents( + ids, + Date.now() + getBackoffMs((batch[0]?.attempts ?? 0) + 1) + ); + yield* persistQueue(); + return null; + } + + dropEvents(ids); + yield* persistQueue(); + const response = decodedResponse.value; + const flushResult: AnalyticsFlushResult = { + accepted: response.accepted, + rejected: response.rejected, + }; + eventBus.emit("analytics-flushed", flushResult); + if (flushResult.rejected > 0) { + eventBus.emit("analytics-partial-rejection", flushResult); + } + return flushResult; + } + + const error = extractCaptureError(result.value); + + if (error?.code === "payload_too_large") { + return yield* handlePayloadTooLarge(batch, options); + } + + if (error && RETRYABLE_ERROR_CODES.has(error.code)) { + postponeEvents( + ids, + Date.now() + + (error.retry_after_ms ?? getBackoffMs((batch[0]?.attempts ?? 0) + 1)) + ); + yield* persistQueue(); + return null; + } + + if (error) { + dropEvents(ids); + yield* persistQueue(); + eventBus.emit("error", { + message: `Dropping analytics batch after non-retryable ${error.code} response.`, + source: "analytics", + }); + return null; + } + } + + postponeEvents( + ids, + Date.now() + getBackoffMs((batch[0]?.attempts ?? 0) + 1) + ); + yield* persistQueue(); + return null; + }); + + const handlePayloadTooLarge = ( + batch: ReadonlyArray, + options?: { keepalive?: boolean } + ): Effect.Effect => + Effect.gen(function* handlePayloadTooLargeEffect() { + if (batch.length === 1) { + dropEvents(buildIdSet(batch)); + yield* persistQueue(); + eventBus.emit("error", { + message: "Dropping analytics event after 413 response.", + source: "analytics", + }); + return { accepted: 0, rejected: 1 } as AnalyticsFlushResult; + } + + const midpoint = Math.ceil(batch.length / 2); + const first = yield* sendBatch(batch.slice(0, midpoint), options); + const second = yield* sendBatch(batch.slice(midpoint), options); + + if (!first && !second) return null; + + return { + accepted: (first?.accepted ?? 0) + (second?.accepted ?? 0), + rejected: (first?.rejected ?? 0) + (second?.rejected ?? 0), + requestId: second?.requestId ?? first?.requestId, + } as AnalyticsFlushResult; + }); + + // Public API + const enqueue = ( + eventName: string, + properties?: Record, + options?: { + eventId?: string; + sessionId?: string; + timestamp?: string; + } + ) => + Effect.gen(function* enqueue() { + yield* loadQueue(); + + const distinctId = identityManager.getDistinctId(); + if (!distinctId) return; + + const event = createAnalyticsEvent( + platform, + distinctId, + eventName, + properties, + options + ); + + events.push({ + attempts: 0, + availableAt: Date.now(), + id: event.uuid, + payload: event, + }); + + const droppedCount = Math.max( + events.length - config.analytics.maxQueueSize, + 0 + ); + if (droppedCount > 0) { + events.splice(0, droppedCount); + eventBus.emit("error", { + message: `Dropped ${droppedCount} analytics event(s) because the queue is full.`, + source: "analytics", + }); + } + + yield* persistQueue(); + return events.length; + }); + + const flush = (options?: { + keepalive?: boolean; + }): Effect.Effect => + Effect.gen(function* flushEffect() { + yield* loadQueue(); + const batch = peekBatch({ + maxBatchBytes: config.analytics.maxBatchBytes, + maxBatchSize: Math.min( + config.analytics.maxBatchSize, + MAX_INGEST_BATCH_SIZE + ), + }); + if (batch.length === 0) return null; + return yield* sendBatch(batch, options); + }); + + const start = () => { + if (flushIntervalId) return; + flushIntervalId = setInterval(() => { + // Scheduled flushes need to be run through the runtime externally. + // The eventBus signals that a flush is needed; the client handles execution. + eventBus.emit("analytics-flush-needed", undefined); + }, config.analytics.flushIntervalMs); + }; + + const stop = () => { + if (flushIntervalId) { + clearInterval(flushIntervalId); + flushIntervalId = null; + } + }; + + const getQueueLength = () => + Effect.gen(function* getQueueLength() { + yield* loadQueue(); + return events.length; + }); + + return { + enqueue, + flush, + getQueueLength, + start, + stop, + } as const; +}); + +export class AnalyticsService extends ServiceMap.Service< + AnalyticsService, + Effect.Success +>()("web-voidhash/AnalyticsService") { + static Default = Layer.effect(AnalyticsService, make); +} diff --git a/libraries/web/src/core/analytics/contracts.ts b/libraries/web/src/core/analytics/contracts.ts index ad392a1af..f799958e7 100644 --- a/libraries/web/src/core/analytics/contracts.ts +++ b/libraries/web/src/core/analytics/contracts.ts @@ -1,20 +1,14 @@ -import type { AnalyticsFlushResult } from "../../types"; +import { + CaptureBatchRequest, + CaptureEvent, +} from "@voidhash/api-spec/event-capture"; -export interface AnalyticsRequestEvent { - readonly context?: Record; - readonly event_id: string; - readonly event_name: string; - readonly event_ts: string; - readonly properties?: Record; - readonly session_id?: string; -} +import type { AnalyticsFlushResult } from "../../types"; -export interface AnalyticsBatchRequest { - readonly events: ReadonlyArray; -} +export type AnalyticsRequestEvent = typeof CaptureEvent.Type; +export type AnalyticsBatchRequest = typeof CaptureBatchRequest.Type; export interface QueuedAnalyticsEvent { - readonly appUserId: string; readonly attempts: number; readonly availableAt: number; readonly id: string; @@ -23,6 +17,7 @@ export interface QueuedAnalyticsEvent { export interface AnalyticsTransportResult { readonly data?: Record; + readonly retryAfterMs?: number; readonly status: number; } diff --git a/libraries/web/src/core/caching/adapters/browser-cache-adapter.ts b/libraries/web/src/core/caching/adapters/browser-cache-adapter.ts new file mode 100644 index 000000000..d71544f2f --- /dev/null +++ b/libraries/web/src/core/caching/adapters/browser-cache-adapter.ts @@ -0,0 +1,104 @@ +import { Effect, Layer } from "effect"; + +import { CacheAdapter } from "../cache-adapter"; + +/** + * Dual-layer (memory + localStorage) browser cache adapter. + * Writes go to both layers (write-through). Reads check memory first, fall back to localStorage. + * Gracefully degrades when localStorage is unavailable. + */ +const makeBrowserCacheAdapter = () => { + const memoryStore = new Map(); + const localStorage = detectLocalStorage(); + + return { + get: (key: string) => + Effect.sync(() => { + const memoryValue = memoryStore.get(key); + if (memoryValue !== undefined) { + return memoryValue; + } + + if (localStorage) { + try { + const persistedValue = localStorage.getItem(key); + if (persistedValue !== null) { + memoryStore.set(key, persistedValue); + return persistedValue; + } + } catch { + // Graceful degradation + } + } + + return null; + }), + + set: (key: string, value: string) => + Effect.sync(() => { + memoryStore.set(key, value); + + if (localStorage) { + try { + localStorage.setItem(key, value); + } catch { + // Graceful degradation — storage may be full or unavailable + } + } + }), + + delete: (key: string) => + Effect.sync(() => { + memoryStore.delete(key); + + if (localStorage) { + try { + localStorage.removeItem(key); + } catch { + // Graceful degradation + } + } + }), + + keys: () => + Effect.sync(() => { + const keySet = new Set(memoryStore.keys()); + + if (localStorage) { + try { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key !== null) { + keySet.add(key); + } + } + } catch { + // Graceful degradation + } + } + + return [...keySet] as ReadonlyArray; + }), + }; +}; + +const detectLocalStorage = (): Storage | null => { + if (typeof window === "undefined" || !window.localStorage) { + return null; + } + + try { + const probeKey = "__voidhash_probe__"; + window.localStorage.setItem(probeKey, "1"); + window.localStorage.removeItem(probeKey); + return window.localStorage; + } catch { + return null; + } +}; + +export const createBrowserCacheAdapterLayer = () => + Layer.effect( + CacheAdapter, + Effect.sync(() => makeBrowserCacheAdapter()) + ); diff --git a/libraries/web/src/core/caching/adapters/local-storage-cache.ts b/libraries/web/src/core/caching/adapters/local-storage-cache.ts deleted file mode 100644 index 03345bc66..000000000 --- a/libraries/web/src/core/caching/adapters/local-storage-cache.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { VoidhashStorageError } from "../../../errors"; -import type { CacheAdapter } from "../cache-manager"; - -export class LocalStorageCacheAdapter implements CacheAdapter { - static create() { - if (typeof window === "undefined" || !window.localStorage) { - return null; - } - - try { - const probeKey = "__voidhash_probe__"; - window.localStorage.setItem(probeKey, "1"); - window.localStorage.removeItem(probeKey); - return new LocalStorageCacheAdapter(window.localStorage); - } catch { - return null; - } - } - - constructor(private readonly storage: Storage) {} - - async delete(key: string) { - try { - this.storage.removeItem(key); - } catch (error) { - throw new VoidhashStorageError("Failed to delete from localStorage.", { - cause: error, - }); - } - } - - async get(key: string) { - try { - return this.storage.getItem(key); - } catch (error) { - throw new VoidhashStorageError("Failed to read from localStorage.", { - cause: error, - }); - } - } - - async keys() { - try { - return Array.from({ length: this.storage.length }, (_, index) => - this.storage.key(index) - ).filter((key): key is string => typeof key === "string"); - } catch (error) { - throw new VoidhashStorageError("Failed to enumerate localStorage.", { - cause: error, - }); - } - } - - async set(key: string, value: string) { - try { - this.storage.setItem(key, value); - } catch (error) { - throw new VoidhashStorageError("Failed to write to localStorage.", { - cause: error, - }); - } - } -} diff --git a/libraries/web/src/core/caching/adapters/memory-cache.ts b/libraries/web/src/core/caching/adapters/memory-cache.ts deleted file mode 100644 index 4b6f56328..000000000 --- a/libraries/web/src/core/caching/adapters/memory-cache.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { CacheAdapter } from "../cache-manager"; - -export class MemoryCacheAdapter implements CacheAdapter { - private store = new Map(); - - async delete(key: string) { - this.store.delete(key); - } - - async get(key: string) { - return this.store.get(key) ?? null; - } - - async keys() { - return [...this.store.keys()]; - } - - async set(key: string, value: string) { - this.store.set(key, value); - } -} diff --git a/libraries/web/src/core/caching/cache-adapter.ts b/libraries/web/src/core/caching/cache-adapter.ts new file mode 100644 index 000000000..b3d01f5a1 --- /dev/null +++ b/libraries/web/src/core/caching/cache-adapter.ts @@ -0,0 +1,11 @@ +import { ServiceMap, type Effect } from "effect"; + +export class CacheAdapter extends ServiceMap.Service< + CacheAdapter, + { + readonly get: (key: string) => Effect.Effect; + readonly set: (key: string, value: string) => Effect.Effect; + readonly delete: (key: string) => Effect.Effect; + readonly keys: () => Effect.Effect>; + } +>()("web-voidhash/CacheAdapter") {} diff --git a/libraries/web/src/core/caching/cache-manager.ts b/libraries/web/src/core/caching/cache-manager.ts index d0d97c486..fa52806db 100644 --- a/libraries/web/src/core/caching/cache-manager.ts +++ b/libraries/web/src/core/caching/cache-manager.ts @@ -1,9 +1,7 @@ -export interface CacheAdapter { - delete(key: string): Promise; - get(key: string): Promise; - keys(): Promise>; - set(key: string, value: string): Promise; -} +import { Effect, Layer, ServiceMap } from "effect"; + +import { SdkConfiguration } from "../sdk-configuration"; +import { CacheAdapter } from "./cache-adapter"; interface CacheEnvelope { readonly createdAt: number; @@ -19,147 +17,139 @@ export interface CacheHit extends CacheEnvelope { const CACHE_INDEX_SUFFIX = "__keys__"; -export class CacheManager { - private memoryIndex = new Set(); - private readonly persistentIndexKey: string; - - constructor( - private readonly namespace: string, - private readonly memory: CacheAdapter, - private readonly persistent: CacheAdapter | null - ) { - this.persistentIndexKey = this.buildStorageKey(CACHE_INDEX_SUFFIX); - } - - async clearAll() { - const keys = await this.getCacheKeys(); - await Promise.all(keys.map((key) => this.delete(key))); - } - - async clearPrefix(prefix: string) { - const keys = await this.getCacheKeys(); - const matchedKeys = keys.filter((key) => key.startsWith(prefix)); - await Promise.all(matchedKeys.map((key) => this.delete(key))); - } - - async delete(key: string) { - const storageKey = this.buildStorageKey(key); - await this.memory.delete(storageKey); - if (this.persistent) { - await this.persistent.delete(storageKey); - } - this.memoryIndex.delete(storageKey); - await this.persistIndex(); - } - - async get(key: string): Promise | null> { - const storageKey = this.buildStorageKey(key); - const rawValue = - (await this.memory.get(storageKey)) ?? - (this.persistent ? await this.persistent.get(storageKey) : null); - - if (!rawValue) { - return null; - } - - const cachedValue = JSON.parse(rawValue) as CacheEnvelope; - const isExpired = - typeof cachedValue.expiresAt === "number" - ? cachedValue.expiresAt < Date.now() - : false; - const isStale = - typeof cachedValue.staleAt === "number" - ? cachedValue.staleAt < Date.now() - : false; - - if (isExpired) { - await this.delete(key); - return null; - } - - if (!(await this.memory.get(storageKey))) { - await this.memory.set(storageKey, rawValue); - } - - await this.rememberKey(storageKey); - - return { - ...cachedValue, - isExpired, - isStale, - }; - } - - async getCacheKeys() { - const storageKeys = await this.loadIndexedStorageKeys(); - const keys = new Set([ - ...this.memoryIndex, - ...storageKeys, - ]); - - return [...keys] - .filter((key) => key.startsWith(`${this.namespace}:`)) - .filter((key) => key !== this.persistentIndexKey) - .map((key) => key.slice(this.namespace.length + 1)); - } - - async set( +const make = Effect.gen(function* effect() { + const cache = yield* CacheAdapter; + const config = yield* SdkConfiguration; + + const namespace = `@voidhash/web:${config.publishableKey}:${config.baseUrl}`; + const persistentIndexKey = `${namespace}:${CACHE_INDEX_SUFFIX}`; + const memoryIndex = new Set(); + + const buildStorageKey = (key: string) => `${namespace}:${key}`; + + const loadIndexedStorageKeys = () => + Effect.gen(function* loadIndexedStorageKeys() { + const rawIndex = yield* cache.get(persistentIndexKey); + if (!rawIndex) { + return [] as string[]; + } + try { + return JSON.parse(rawIndex) as string[]; + } catch { + return [] as string[]; + } + }); + + const persistIndex = () => + Effect.gen(function* persistIndex() { + const serialized = JSON.stringify([...memoryIndex]); + yield* cache.set(persistentIndexKey, serialized); + }); + + const rememberKey = (storageKey: string) => + Effect.gen(function* rememberKey() { + if (storageKey === persistentIndexKey) { + return; + } + memoryIndex.add(storageKey); + yield* persistIndex(); + }); + + const get = (key: string) => + Effect.gen(function* get() { + const storageKey = buildStorageKey(key); + const rawValue = yield* cache.get(storageKey); + + if (!rawValue) { + return null as CacheHit | null; + } + + const cachedValue = JSON.parse(rawValue) as CacheEnvelope; + const isExpired = + typeof cachedValue.expiresAt === "number" + ? cachedValue.expiresAt < Date.now() + : false; + const isStale = + typeof cachedValue.staleAt === "number" + ? cachedValue.staleAt < Date.now() + : false; + + if (isExpired) { + yield* deleteValue(key); + return null as CacheHit | null; + } + + yield* rememberKey(storageKey); + + return { + ...cachedValue, + isExpired, + isStale, + } as CacheHit; + }); + + const setValue = ( key: string, value: T, options?: { staleTime?: number; ttl?: number } - ) { - const storageKey = this.buildStorageKey(key); - const envelope: CacheEnvelope = { - createdAt: Date.now(), - expiresAt: options?.ttl ? Date.now() + options.ttl : null, - staleAt: options?.staleTime ? Date.now() + options.staleTime : null, - value, - }; - const serialized = JSON.stringify(envelope); - - await this.memory.set(storageKey, serialized); - if (this.persistent) { - await this.persistent.set(storageKey, serialized); - } - - await this.rememberKey(storageKey); - } - - private buildStorageKey(key: string) { - return `${this.namespace}:${key}`; - } - - private async loadIndexedStorageKeys() { - if (!this.persistent) { - return []; - } - - const rawIndex = await this.persistent.get(this.persistentIndexKey); - if (!rawIndex) { - return []; - } - - try { - return JSON.parse(rawIndex) as string[]; - } catch { - return []; - } - } - - private async persistIndex() { - const serialized = JSON.stringify([...this.memoryIndex]); - await this.memory.set(this.persistentIndexKey, serialized); - if (this.persistent) { - await this.persistent.set(this.persistentIndexKey, serialized); - } - } - - private async rememberKey(storageKey: string) { - if (storageKey === this.persistentIndexKey) { - return; - } - - this.memoryIndex.add(storageKey); - await this.persistIndex(); - } + ) => + Effect.gen(function* setValue() { + const storageKey = buildStorageKey(key); + const envelope: CacheEnvelope = { + createdAt: Date.now(), + expiresAt: options?.ttl ? Date.now() + options.ttl : null, + staleAt: options?.staleTime ? Date.now() + options.staleTime : null, + value, + }; + const serialized = JSON.stringify(envelope); + yield* cache.set(storageKey, serialized); + yield* rememberKey(storageKey); + }); + + const deleteValue = (key: string) => + Effect.gen(function* deleteValue() { + const storageKey = buildStorageKey(key); + yield* cache.delete(storageKey); + memoryIndex.delete(storageKey); + yield* persistIndex(); + }); + + const clearAll = () => + Effect.gen(function* clearAll() { + const keys = yield* getCacheKeys(); + yield* Effect.all(keys.map((key) => deleteValue(key))); + }); + + const clearPrefix = (prefix: string) => + Effect.gen(function* clearPrefix() { + const keys = yield* getCacheKeys(); + const matched = keys.filter((key) => key.startsWith(prefix)); + yield* Effect.all(matched.map((key) => deleteValue(key))); + }); + + const getCacheKeys = () => + Effect.gen(function* getCacheKeys() { + const storageKeys = yield* loadIndexedStorageKeys(); + const keys = new Set([...memoryIndex, ...storageKeys]); + return [...keys] + .filter((key) => key.startsWith(`${namespace}:`)) + .filter((key) => key !== persistentIndexKey) + .map((key) => key.slice(namespace.length + 1)); + }); + + return { + clearAll, + clearPrefix, + delete: deleteValue, + get, + getCacheKeys, + set: setValue, + } as const; +}); + +export class CacheManager extends ServiceMap.Service< + CacheManager, + Effect.Success +>()("web-voidhash/CacheManager") { + static Default = Layer.effect(CacheManager, make); } diff --git a/libraries/web/src/core/constants.ts b/libraries/web/src/core/constants.ts new file mode 100644 index 000000000..e325acc6c --- /dev/null +++ b/libraries/web/src/core/constants.ts @@ -0,0 +1 @@ +export const SDK_VERSION = "0.0.1-alpha.1"; diff --git a/libraries/web/src/core/event-bus.ts b/libraries/web/src/core/event-bus.ts index 22da53101..b230d505e 100644 --- a/libraries/web/src/core/event-bus.ts +++ b/libraries/web/src/core/event-bus.ts @@ -1,3 +1,5 @@ +import { ServiceMap } from "effect"; + import type { VoidhashEventMap, VoidhashEventName } from "../types"; export class EventBus { @@ -6,6 +8,7 @@ export class EventBus { (payload: VoidhashEventMap[TEvent]) => void >; } = { + "analytics-flush-needed": new Set(), "analytics-flushed": new Set(), "analytics-partial-rejection": new Set(), error: new Set(), @@ -41,3 +44,8 @@ export class EventBus { }; } } + +export class EventBusProvider extends ServiceMap.Service< + EventBusProvider, + EventBus +>()("web-voidhash/EventBusProvider") {} diff --git a/libraries/web/src/core/feature-flags/feature-flag-service.ts b/libraries/web/src/core/feature-flags/feature-flag-service.ts index 1ab1aae63..1b8ec093b 100644 --- a/libraries/web/src/core/feature-flags/feature-flag-service.ts +++ b/libraries/web/src/core/feature-flags/feature-flag-service.ts @@ -1,100 +1,134 @@ -import type { FeatureFlagEntry, FeatureFlagsResult } from "../../types"; -import type { CacheManager } from "../caching/cache-manager"; -import type { EventBus } from "../event-bus"; -import { SdkApiClient } from "../http/sdk-api-client"; +import { Effect, Layer, ServiceMap } from "effect"; + +import type { + FeatureFlagEntry, + FeatureFlagsResult, +} from "../../types"; +import { CacheManager } from "../caching/cache-manager"; +import { EventBusProvider } from "../event-bus"; +import { IdentityManager } from "../identity/identity-manager"; +import { ApiClient } from "../networking/api-client"; +import { PlatformProvider } from "../platform/platform-provider"; +import { SdkConfiguration } from "../sdk-configuration"; const serializeKeys = (keys?: ReadonlyArray) => keys && keys.length > 0 ? [...keys].sort().join(",") : "all"; -export class FeatureFlagService { - private latestFlags = new Map(); - private trackedKeySets = new Set(); - - constructor( - private readonly cache: CacheManager, - private readonly sdkApi: SdkApiClient, - private readonly eventBus: EventBus, - private readonly ttlMs: number, - private readonly getAppUserId: () => Promise - ) {} - - async clearCachedFlags() { - this.latestFlags.clear(); - await this.cache.clearPrefix("feature-flags:"); - } - - getTrackedKeys() { - return [...this.trackedKeySets]; - } - - getVariant(key: string) { - return this.latestFlags.get(key) ?? null; - } - - isEnabled(key: string) { - return this.latestFlags.get(key)?.enabled ?? false; - } - - async refreshTrackedKeySets() { - if (this.trackedKeySets.size === 0) { - return; +const make = Effect.gen(function* effect() { + const cacheManager = yield* CacheManager; + const apiClient = yield* ApiClient; + const eventBus = yield* EventBusProvider; + const identityManager = yield* IdentityManager; + const platform = yield* PlatformProvider; + const config = yield* SdkConfiguration; + + const latestFlags = new Map(); + const trackedKeySets = new Set(); + + // biome-ignore lint/suspicious/noExplicitAny: SDK headers match the schema shape at runtime + const buildHeaders = (distinctId: string) => + ({ + ...platform.getSdkHeaders({ + observerMode: config.observerMode, + publishableKey: config.publishableKey, + }), + "x-distinct-id": distinctId, + }) as any; + + const buildCacheKey = (distinctId: string, keys?: ReadonlyArray) => + `feature-flags:${distinctId}:${serializeKeys(keys)}`; + + const rememberFlags = (flags: ReadonlyArray) => { + for (const flag of flags) { + latestFlags.set(flag.key, flag); } + }; - await Promise.all( - [...this.trackedKeySets].map((serializedKeys) => - this.refreshFeatureFlags( - serializedKeys === "all" ? undefined : serializedKeys.split(",") - ) - ) - ); - } - - async getFeatureFlags(keys?: ReadonlyArray) { - return this.getOrRefreshFeatureFlags(keys, false); - } + // Sync accessors (plain functions, not Effects) + const isEnabled = (key: string) => + latestFlags.get(key)?.enabled ?? false; - async refreshFeatureFlags(keys?: ReadonlyArray) { - return this.getOrRefreshFeatureFlags(keys, true); - } + const getVariant = (key: string) => + latestFlags.get(key) ?? null; - private cacheKey(appUserId: string, keys?: ReadonlyArray) { - return `feature-flags:${appUserId}:${serializeKeys(keys)}`; - } - - private async getOrRefreshFeatureFlags( + const getOrRefreshFeatureFlags = ( keys: ReadonlyArray | undefined, forceRefresh: boolean - ): Promise { - const appUserId = await this.getAppUserId(); - if (!appUserId) { - return { flags: [] }; - } - - const cacheKey = this.cacheKey(appUserId, keys); - const serializedKeys = serializeKeys(keys); - this.trackedKeySets.add(serializedKeys); + ) => + Effect.gen(function* getOrRefreshFeatureFlags() { + const distinctId = identityManager.getDistinctId(); + if (!distinctId) { + return { flags: [] } as FeatureFlagsResult; + } - if (!forceRefresh) { - const cached = await this.cache.get(cacheKey); - if (cached && !cached.isExpired && !cached.isStale) { - this.rememberFlags(cached.value.flags); - return cached.value; + const cacheKey = buildCacheKey(distinctId, keys); + const serializedKeys = serializeKeys(keys); + trackedKeySets.add(serializedKeys); + + if (!forceRefresh) { + const cached = + yield* cacheManager.get(cacheKey); + if (cached && !cached.isExpired && !cached.isStale) { + rememberFlags(cached.value.flags); + return cached.value; + } } - } - const result = await this.sdkApi.evaluateFeatureFlags(appUserId, keys); - this.rememberFlags(result.flags); - await this.cache.set(cacheKey, result, { ttl: this.ttlMs }); - this.eventBus.emit("feature-flags-updated", { - keys, - result, + const payload = keys + ? { flagKeys: [...keys] as string[] } + : {}; + const result = yield* apiClient.sdk.evaluateFeatureFlags({ + headers: buildHeaders(distinctId), + payload, + }); + + rememberFlags(result.flags); + yield* cacheManager.set(cacheKey, result, { + ttl: config.featureFlags.ttlMs, + }); + eventBus.emit("feature-flags-updated", { keys, result }); + return result; }); - return result; - } - private rememberFlags(flags: ReadonlyArray) { - for (const flag of flags) { - this.latestFlags.set(flag.key, flag); - } - } + const getFeatureFlags = (keys?: ReadonlyArray) => + getOrRefreshFeatureFlags(keys, false); + + const refreshFeatureFlags = (keys?: ReadonlyArray) => + getOrRefreshFeatureFlags(keys, true); + + const refreshTrackedKeySets = () => + Effect.gen(function* refreshTrackedKeySets() { + if (trackedKeySets.size === 0) return; + + yield* Effect.all( + [...trackedKeySets].map((serializedKeys) => + refreshFeatureFlags( + serializedKeys === "all" ? undefined : serializedKeys.split(",") + ) + ), + { concurrency: "unbounded" } + ); + }); + + const clearCachedFlags = () => + Effect.gen(function* clearCachedFlags() { + latestFlags.clear(); + yield* cacheManager.clearPrefix("feature-flags:"); + }); + + return { + clearCachedFlags, + getFeatureFlags, + getVariant, + isEnabled, + refreshFeatureFlags, + refreshTrackedKeySets, + } as const; +}); + +export class FeatureFlagService extends ServiceMap.Service< + FeatureFlagService, + Effect.Success +>()("web-voidhash/FeatureFlagService") { + static Default = Layer.effect(FeatureFlagService, make); } diff --git a/libraries/web/src/core/http/analytics-client.ts b/libraries/web/src/core/http/analytics-client.ts deleted file mode 100644 index 5d770de97..000000000 --- a/libraries/web/src/core/http/analytics-client.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { VoidhashAnalyticsError } from "../../errors"; -import type { - AnalyticsBatchRequest, - AnalyticsTransportResult, -} from "../analytics/contracts"; - -const DEFAULT_TIMEOUT_MS = 10_000; - -export class AnalyticsHttpClient { - constructor( - private readonly baseUrl: string, - private readonly publishableKey: string - ) {} - - async send( - distinctId: string, - request: AnalyticsBatchRequest, - options?: { keepalive?: boolean; timeoutMs?: number } - ): Promise { - const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const controller = - typeof AbortController !== "undefined" && !options?.keepalive - ? new AbortController() - : null; - const timeoutId = - controller && timeoutMs > 0 - ? setTimeout(() => controller.abort(), timeoutMs) - : null; - - try { - const response = await fetch(new URL("/v1/events", this.baseUrl), { - body: JSON.stringify(request), - headers: { - "content-type": "application/json", - "x-distinct-id": distinctId, - "x-publishable-key": this.publishableKey, - }, - keepalive: options?.keepalive ?? false, - method: "POST", - signal: controller?.signal, - }); - let data: Record | undefined; - - try { - data = (await response.json()) as Record; - } catch { - data = undefined; - } - - return { - data, - status: response.status, - }; - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - throw new VoidhashAnalyticsError("Analytics flush timed out.", { - cause: error, - }); - } - - throw new VoidhashAnalyticsError("Analytics request failed.", { - cause: error, - }); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } - } -} diff --git a/libraries/web/src/core/http/sdk-api-client.ts b/libraries/web/src/core/http/sdk-api-client.ts deleted file mode 100644 index 63deb7eef..000000000 --- a/libraries/web/src/core/http/sdk-api-client.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { VoidhashFeatureFlagsError, VoidhashIdentityError } from "../../errors"; -import type { FeatureFlagsResult, VoidhashTraits } from "../../types"; -import { BrowserPlatformProvider } from "../platform/browser-platform-provider"; - -type ApiClientRequest = { - readonly headers: Record; - readonly payload?: Record; -}; - -const trimUndefined = (record: Record) => - Object.fromEntries( - Object.entries(record).filter(([, value]) => typeof value !== "undefined") - ) as Record; - -export class SdkApiClient { - constructor( - private readonly baseUrl: string, - private readonly publishableKey: string, - private readonly observerMode: boolean, - private readonly platform = new BrowserPlatformProvider() - ) {} - - async destroy() {} - - async evaluateFeatureFlags( - appUserId: string, - flagKeys?: ReadonlyArray - ): Promise { - try { - return await this.request("/sdk/evaluate-flags", { - headers: this.buildHeaders(appUserId), - payload: trimUndefined({ - flagKeys, - }), - }); - } catch (error) { - throw new VoidhashFeatureFlagsError("Failed to fetch feature flags.", { - cause: error, - }); - } - } - - async identify( - currentAppUserId: string, - appUserId: string, - traits?: VoidhashTraits - ) { - try { - await this.request("/sdk/identify", { - headers: this.buildHeaders(currentAppUserId), - payload: trimUndefined({ - appUserId, - traits: this.normalizeTraits(traits), - }), - }); - } catch (error) { - throw new VoidhashIdentityError("Failed to identify app user.", { - cause: error, - }); - } - } - - async syncTraits(appUserId: string, traits?: VoidhashTraits) { - try { - await this.request("/sdk/sync-customer-attributes", { - headers: this.buildHeaders(appUserId), - payload: trimUndefined({ - traits: this.normalizeTraits(traits), - }), - }); - } catch (error) { - throw new VoidhashIdentityError("Failed to sync customer traits.", { - cause: error, - }); - } - } - - private buildHeaders(appUserId: string) { - return { - ...this.platform.getSdkHeaders({ - observerMode: this.observerMode, - publishableKey: this.publishableKey, - }), - "x-app-user-id": appUserId, - }; - } - - private normalizeTraits(traits?: VoidhashTraits) { - if (!traits || Object.keys(traits).length === 0) { - return undefined; - } - - return traits; - } - - private async request(path: string, input: ApiClientRequest) { - const controller = - typeof AbortController !== "undefined" ? new AbortController() : null; - const timeoutId = controller - ? setTimeout(() => controller.abort(), 10_000) - : null; - - try { - const response = await fetch(new URL(path, this.baseUrl), { - body: input.payload ? JSON.stringify(input.payload) : undefined, - headers: trimUndefined(input.headers), - method: input.payload ? "POST" : "GET", - signal: controller?.signal, - }); - - if (!response.ok) { - throw new Error(`SDK request failed with status ${response.status}.`); - } - - if (response.status === 204) { - return undefined as T; - } - - return (await response.json()) as T; - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } - } -} diff --git a/libraries/web/src/core/identity/identity-manager.ts b/libraries/web/src/core/identity/identity-manager.ts index 8bb94c4f1..0e8a10af6 100644 --- a/libraries/web/src/core/identity/identity-manager.ts +++ b/libraries/web/src/core/identity/identity-manager.ts @@ -1,85 +1,142 @@ -import { VoidhashIdentityError } from "../../errors"; -import type { EventBus } from "../event-bus"; -import { SdkApiClient } from "../http/sdk-api-client"; -import { BrowserPlatformProvider } from "../platform/browser-platform-provider"; -import type { CacheManager } from "../caching/cache-manager"; +import { Effect, Layer, ServiceMap } from "effect"; + import type { VoidhashTraits } from "../../types"; +import { CacheManager } from "../caching/cache-manager"; +import { EventBusProvider } from "../event-bus"; +import { ApiClient } from "../networking/api-client"; +import { PlatformProvider } from "../platform/platform-provider"; +import { SdkConfiguration } from "../sdk-configuration"; -const APP_USER_ID_KEY = "identity:app-user-id"; -const ANONYMOUS_USER_ID_PREFIX = "vh:anon:"; +const DISTINCT_ID_KEY = "identity:distinct-id"; +const ANONYMOUS_DISTINCT_ID_PREFIX = "vh:anon:"; -const buildTraitsKey = (appUserId: string) => `identity:traits:${appUserId}`; +const buildTraitsKey = (distinctId: string) => `identity:traits:${distinctId}`; -export class IdentityManager { - private currentAppUserId: string | null = null; +const normalizeTraits = (traits?: VoidhashTraits) => { + if (!traits || Object.keys(traits).length === 0) { + return undefined; + } + return traits; +}; - constructor( - private readonly cache: CacheManager, - private readonly sdkApi: SdkApiClient, - private readonly eventBus: EventBus, - private readonly platform = new BrowserPlatformProvider() - ) {} +const make = Effect.gen(function* effect() { + const cacheManager = yield* CacheManager; + const apiClient = yield* ApiClient; + const eventBus = yield* EventBusProvider; + const platform = yield* PlatformProvider; + const config = yield* SdkConfiguration; - getAppUserId() { - return this.currentAppUserId; - } + let currentDistinctId: string | null = null; - async identify(appUserId: string, traits?: VoidhashTraits) { - const currentAppUserId = await this.requireAppUserId(); - await this.syncTraits(currentAppUserId); - await this.sdkApi.identify(currentAppUserId, appUserId, traits); - await this.cache.set(APP_USER_ID_KEY, appUserId); - await this.cache.set(buildTraitsKey(appUserId), traits ?? {}); - this.currentAppUserId = appUserId; - this.eventBus.emit("identity-changed", { - appUserId, - previousAppUserId: currentAppUserId, + const getDistinctId = () => currentDistinctId; + + const getSdkHeaders = () => + platform.getSdkHeaders({ + observerMode: config.observerMode, + publishableKey: config.publishableKey, }); - } - async initialize(initialAppUserId?: string) { - const cachedAppUserId = await this.cache.get(APP_USER_ID_KEY); - this.currentAppUserId = - initialAppUserId ?? - cachedAppUserId?.value ?? - `${ANONYMOUS_USER_ID_PREFIX}${this.platform.randomId()}`; + // biome-ignore lint/suspicious/noExplicitAny: SDK headers match the schema shape at runtime + const buildHeaders = (distinctId: string) => + ({ + ...getSdkHeaders(), + "x-distinct-id": distinctId, + }) as any; - await this.cache.set(APP_USER_ID_KEY, this.currentAppUserId); + const syncTraits = (distinctId?: string) => + Effect.gen(function* syncTraits() { + const resolvedDistinctId = distinctId ?? currentDistinctId; + if (!resolvedDistinctId) return; - if (initialAppUserId && cachedAppUserId?.value && cachedAppUserId.value !== initialAppUserId) { - await this.identify(initialAppUserId); - return this.currentAppUserId; - } + const cached = yield* cacheManager.get( + buildTraitsKey(resolvedDistinctId) + ); - return this.currentAppUserId; - } + const traits = normalizeTraits(cached?.value); + yield* apiClient.sdk.syncCustomerAttributes({ + headers: buildHeaders(resolvedDistinctId), + payload: traits ? { traits } : {}, + }); + }); + + const initialize = (initialDistinctId?: string) => + Effect.gen(function* initialize() { + const cached = yield* cacheManager.get(DISTINCT_ID_KEY); + currentDistinctId = + initialDistinctId ?? + cached?.value ?? + `${ANONYMOUS_DISTINCT_ID_PREFIX}${platform.randomId()}`; + + yield* cacheManager.set(DISTINCT_ID_KEY, currentDistinctId); + + if ( + initialDistinctId && + cached?.value && + cached.value !== initialDistinctId + ) { + yield* identify(initialDistinctId); + return currentDistinctId; + } - async resetIdentity() { - const currentAppUserId = await this.requireAppUserId(); - await this.syncTraits(currentAppUserId); - const nextAnonymousId = `${ANONYMOUS_USER_ID_PREFIX}${this.platform.randomId()}`; - await this.cache.set(APP_USER_ID_KEY, nextAnonymousId); - await this.cache.set(buildTraitsKey(nextAnonymousId), {}); - this.currentAppUserId = nextAnonymousId; - this.eventBus.emit("identity-changed", { - appUserId: nextAnonymousId, - previousAppUserId: currentAppUserId, + return currentDistinctId; }); - } - async syncTraits(appUserId?: string) { - const resolvedAppUserId = appUserId ?? (await this.requireAppUserId()); - const cachedTraits = await this.cache.get( - buildTraitsKey(resolvedAppUserId) - ); - await this.sdkApi.syncTraits(resolvedAppUserId, cachedTraits?.value); - } + const identify = (distinctId: string, traits?: VoidhashTraits) => + Effect.gen(function* identify() { + if (!currentDistinctId) { + throw new Error("Distinct id has not been initialized."); + } - private async requireAppUserId() { - if (!this.currentAppUserId) { - throw new VoidhashIdentityError("App user id has not been initialized."); - } + const previousDistinctId = currentDistinctId; + yield* syncTraits(previousDistinctId); - return this.currentAppUserId; - } + const normalizedTraits = normalizeTraits(traits); + yield* apiClient.sdk.identify({ + headers: buildHeaders(previousDistinctId), + payload: normalizedTraits + ? { distinctId, traits: normalizedTraits } + : { distinctId }, + }); + + yield* cacheManager.set(DISTINCT_ID_KEY, distinctId); + yield* cacheManager.set(buildTraitsKey(distinctId), traits ?? {}); + currentDistinctId = distinctId; + eventBus.emit("identity-changed", { + distinctId, + previousDistinctId, + }); + }); + + const reset = () => + Effect.gen(function* reset() { + if (!currentDistinctId) { + throw new Error("Distinct id has not been initialized."); + } + + const previousDistinctId = currentDistinctId; + yield* syncTraits(previousDistinctId); + const nextAnonymousId = `${ANONYMOUS_DISTINCT_ID_PREFIX}${platform.randomId()}`; + yield* cacheManager.set(DISTINCT_ID_KEY, nextAnonymousId); + yield* cacheManager.set(buildTraitsKey(nextAnonymousId), {}); + currentDistinctId = nextAnonymousId; + eventBus.emit("identity-changed", { + distinctId: nextAnonymousId, + previousDistinctId, + }); + }); + + return { + getDistinctId, + identify, + initialize, + reset, + syncTraits, + } as const; +}); + +export class IdentityManager extends ServiceMap.Service< + IdentityManager, + Effect.Success +>()("web-voidhash/IdentityManager") { + static Default = Layer.effect(IdentityManager, make); } diff --git a/libraries/web/src/core/networking/api-client.ts b/libraries/web/src/core/networking/api-client.ts new file mode 100644 index 000000000..556300dca --- /dev/null +++ b/libraries/web/src/core/networking/api-client.ts @@ -0,0 +1,21 @@ +import { VoidhashV1Api } from "@voidhash/api-spec"; +import { Effect, Layer, ServiceMap } from "effect"; +import { HttpApiClient } from "effect/unstable/httpapi"; + +import { SdkConfiguration } from "../sdk-configuration"; +import { normalizeGeneratedClient } from "./normalize-generated-client"; + +const make = Effect.gen(function* effect() { + const config = yield* SdkConfiguration; + const rawClient = yield* HttpApiClient.make(VoidhashV1Api, { + baseUrl: config.baseUrl, + }); + return normalizeGeneratedClient(rawClient); +}); + +export class ApiClient extends ServiceMap.Service< + ApiClient, + Effect.Success +>()("web-voidhash/ApiClient") { + static Default = Layer.effect(ApiClient, make); +} diff --git a/libraries/web/src/core/networking/event-capture-api-client.ts b/libraries/web/src/core/networking/event-capture-api-client.ts new file mode 100644 index 000000000..a68b77fda --- /dev/null +++ b/libraries/web/src/core/networking/event-capture-api-client.ts @@ -0,0 +1,22 @@ +import { EventCaptureApi } from "@voidhash/api-spec/event-capture"; +import { Effect, Layer, ServiceMap } from "effect"; +import { HttpApiClient } from "effect/unstable/httpapi"; + +import { SdkConfiguration } from "../sdk-configuration"; +import { normalizeGeneratedClient } from "./normalize-generated-client"; +import { toJsonCompatibleApi } from "./json-compatible-api"; + +const make = Effect.gen(function* effect() { + const config = yield* SdkConfiguration; + const rawClient = yield* HttpApiClient.make(toJsonCompatibleApi(EventCaptureApi), { + baseUrl: config.analytics.baseUrl, + }); + return normalizeGeneratedClient(rawClient); +}); + +export class EventCaptureApiClient extends ServiceMap.Service< + EventCaptureApiClient, + Effect.Success +>()("web-voidhash/EventCaptureApiClient") { + static Default = Layer.effect(EventCaptureApiClient, make); +} diff --git a/libraries/web/src/core/networking/json-compatible-api.ts b/libraries/web/src/core/networking/json-compatible-api.ts new file mode 100644 index 000000000..837e9e28b --- /dev/null +++ b/libraries/web/src/core/networking/json-compatible-api.ts @@ -0,0 +1,57 @@ +import { Schema } from "effect"; +import type { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; + +const cloneWithPrototype = ( + value: T, + properties: Record +): T => + Object.assign(Object.create(Object.getPrototypeOf(value)), value, properties) as T; + +const mapPayloadSchemas = ( + payload: HttpApiEndpoint.AnyWithProps["payload"] +): HttpApiEndpoint.AnyWithProps["payload"] => + new Map( + Array.from(payload.entries(), ([contentType, value]) => [ + contentType, + { + ...value, + schemas: value.schemas.map((schema) => Schema.toCodecJson(schema)) as typeof value.schemas, + }, + ]) + ); + +const mapEndpoint = ( + endpoint: TEndpoint +): TEndpoint => + cloneWithPrototype(endpoint, { + error: new Set( + Array.from(endpoint.error, (schema) => Schema.toCodecJson(schema)) + ) as TEndpoint["error"], + headers: endpoint.headers ? Schema.toCodecJson(endpoint.headers) : undefined, + params: endpoint.params ? Schema.toCodecJson(endpoint.params) : undefined, + payload: mapPayloadSchemas(endpoint.payload) as TEndpoint["payload"], + query: endpoint.query ? Schema.toCodecJson(endpoint.query) : undefined, + success: new Set( + Array.from(endpoint.success, (schema) => Schema.toCodecJson(schema)) + ) as TEndpoint["success"], + }); + +const mapGroup = (group: TGroup): TGroup => + cloneWithPrototype(group, { + endpoints: Object.fromEntries( + Object.entries(group.endpoints).map(([endpointName, endpoint]) => [ + endpointName, + mapEndpoint(endpoint as HttpApiEndpoint.AnyWithProps), + ]) + ) as TGroup["endpoints"], + }); + +export const toJsonCompatibleApi = (api: TApi): TApi => + cloneWithPrototype(api, { + groups: Object.fromEntries( + Object.entries((api as TApi & { groups: Record }).groups).map(([groupName, group]) => [ + groupName, + mapGroup(group as HttpApiGroup.AnyWithProps), + ]) + ), + }); diff --git a/libraries/web/src/core/networking/normalize-generated-client.ts b/libraries/web/src/core/networking/normalize-generated-client.ts new file mode 100644 index 000000000..f574065ff --- /dev/null +++ b/libraries/web/src/core/networking/normalize-generated-client.ts @@ -0,0 +1,121 @@ +import { VoidhashV1Api } from "@voidhash/api-spec"; +import { Effect, Schema } from "effect"; +import { HttpApi, HttpApiEndpoint } from "effect/unstable/httpapi"; + +type RequestPartName = "headers" | "params" | "payload" | "query"; + +type EndpointNormalizers = Partial< + Record Effect.Effect> +>; + +const endpointNormalizers = new Map(); + +const endpointKey = (group: string, endpoint: string) => `${group}.${endpoint}`; + +const getPayloadSchema = (endpoint: HttpApiEndpoint.AnyWithProps) => { + const schemas = Array.from(endpoint.payload.values()).flatMap((value) => value.schemas); + + if (schemas.length === 0) { + return undefined; + } + + return schemas.length === 1 ? schemas[0] : Schema.Union(schemas); +}; + +HttpApi.reflect(VoidhashV1Api, { + onGroup: () => {}, + onEndpoint: ({ endpoint, group }) => { + const normalizers: EndpointNormalizers = {}; + + if (endpoint.params) { + normalizers.params = Schema.decodeUnknownEffect(endpoint.params); + } + + if (endpoint.query) { + normalizers.query = Schema.decodeUnknownEffect(endpoint.query); + } + + if (endpoint.headers) { + normalizers.headers = Schema.decodeUnknownEffect(endpoint.headers); + } + + const payloadSchema = getPayloadSchema(endpoint); + if (payloadSchema) { + normalizers.payload = Schema.decodeUnknownEffect(payloadSchema); + } + + endpointNormalizers.set(endpointKey(group.identifier, endpoint.name), normalizers); + }, +}); + +const normalizeRequest = ( + normalizers: EndpointNormalizers, + request: Record | undefined +) => + Effect.gen(function* normalizeRequestEffect() { + if (request === undefined) { + return undefined; + } + + const normalized = { + ...request, + }; + + if (normalizers.params && "params" in request) { + normalized.params = yield* normalizers.params(request.params); + } + + if (normalizers.query && "query" in request) { + normalized.query = yield* normalizers.query(request.query); + } + + if (normalizers.headers && "headers" in request) { + normalized.headers = yield* normalizers.headers(request.headers); + } + + if (normalizers.payload && "payload" in request) { + normalized.payload = yield* normalizers.payload(request.payload); + } + + return normalized; + }); + +// biome-ignore lint/suspicious/noExplicitAny: Generic client normalization requires dynamic typing +export const normalizeGeneratedClient = >( + client: T +): T => + Object.fromEntries( + Object.entries(client).map(([groupName, groupValue]) => { + if (!groupValue || typeof groupValue !== "object") { + return [groupName, groupValue]; + } + + const normalizedGroup = Object.fromEntries( + Object.entries(groupValue).map(([endpointName, endpoint]) => { + if (typeof endpoint !== "function") { + return [endpointName, endpoint]; + } + + const normalizers = endpointNormalizers.get(endpointKey(groupName, endpointName)); + + if (!normalizers) { + return [endpointName, endpoint]; + } + + return [ + endpointName, + (request?: Record) => + Effect.flatMap(normalizeRequest(normalizers, request), (normalized) => + Reflect.apply( + endpoint as (request?: unknown) => Effect.Effect, + groupValue, + [normalized] + ) + ), + ]; + }) + ); + + return [groupName, normalizedGroup]; + }) + ) as T; diff --git a/libraries/web/src/core/platform/browser-platform-provider.ts b/libraries/web/src/core/platform/browser-platform-provider.ts index eb5c44c2e..67275c93d 100644 --- a/libraries/web/src/core/platform/browser-platform-provider.ts +++ b/libraries/web/src/core/platform/browser-platform-provider.ts @@ -1,4 +1,4 @@ -const SDK_VERSION = "0.0.1-alpha.1"; +import { SDK_VERSION } from "../constants"; const trimUndefined = (entries: Record) => Object.fromEntries( diff --git a/libraries/web/src/core/platform/platform-provider.ts b/libraries/web/src/core/platform/platform-provider.ts new file mode 100644 index 000000000..3b096b14d --- /dev/null +++ b/libraries/web/src/core/platform/platform-provider.ts @@ -0,0 +1,13 @@ +import { Layer, ServiceMap } from "effect"; + +import { BrowserPlatformProvider } from "./browser-platform-provider"; + +export class PlatformProvider extends ServiceMap.Service< + PlatformProvider, + BrowserPlatformProvider +>()("web-voidhash/PlatformProvider") {} + +export const BrowserPlatformProviderLayer = Layer.succeed( + PlatformProvider, + new BrowserPlatformProvider() +); diff --git a/libraries/web/src/core/sdk-configuration.ts b/libraries/web/src/core/sdk-configuration.ts new file mode 100644 index 000000000..c5247af9b --- /dev/null +++ b/libraries/web/src/core/sdk-configuration.ts @@ -0,0 +1,8 @@ +import { ServiceMap } from "effect"; + +import type { ResolvedVoidhashConfig } from "../types"; + +export class SdkConfiguration extends ServiceMap.Service< + SdkConfiguration, + ResolvedVoidhashConfig +>()("web-voidhash/SdkConfiguration") {} diff --git a/libraries/web/src/react/hooks/use-feature-flags.ts b/libraries/web/src/react/hooks/use-feature-flags.ts index a9448a146..1dd979e84 100644 --- a/libraries/web/src/react/hooks/use-feature-flags.ts +++ b/libraries/web/src/react/hooks/use-feature-flags.ts @@ -7,7 +7,7 @@ const serializeKeys = (keys?: ReadonlyArray) => keys && keys.length > 0 ? [...keys].sort().join(",") : "all"; export const useFeatureFlags = (keys?: string[]) => { - const { appUserId, client, isInitialized } = useVoidhash(); + const { client, distinctId, isInitialized } = useVoidhash(); const [data, setData] = React.useState({ flags: [] }); const [error, setError] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); @@ -72,7 +72,7 @@ export const useFeatureFlags = (keys?: string[]) => { return () => { isMounted = false; }; - }, [appUserId, client, isInitialized, resolvedKeys, updateData]); + }, [client, distinctId, isInitialized, resolvedKeys, updateData]); React.useEffect(() => { return client.on("feature-flags-updated", (event) => { diff --git a/libraries/web/src/react/provider.tsx b/libraries/web/src/react/provider.tsx index d6f9eab43..45e741642 100644 --- a/libraries/web/src/react/provider.tsx +++ b/libraries/web/src/react/provider.tsx @@ -21,8 +21,8 @@ interface ProviderWithConfig extends ProviderBaseProps { } export interface VoidhashReactContextValue { - readonly appUserId: string | null; readonly client: VoidhashWebClient; + readonly distinctId: string | null; readonly isInitialized: boolean; } @@ -43,27 +43,27 @@ export function VoidhashProvider(props: ProviderWithClient | ProviderWithConfig) throw new Error("VoidhashProvider failed to create a client instance."); } const [isInitialized, setIsInitialized] = useState(false); - const [appUserId, setAppUserId] = useState(null); + const [distinctId, setDistinctId] = useState(null); useEffect(() => { let isMounted = true; - const removeInitialized = client.on("initialized", ({ appUserId }) => { + const removeInitialized = client.on("initialized", ({ distinctId }) => { if (!isMounted) { return; } setIsInitialized(true); - setAppUserId(appUserId); + setDistinctId(distinctId); }); - const removeIdentityChanged = client.on("identity-changed", ({ appUserId }) => { + const removeIdentityChanged = client.on("identity-changed", ({ distinctId }) => { if (isMounted) { - setAppUserId(appUserId); + setDistinctId(distinctId); } }); void client.initialize().then(() => { if (isMounted) { setIsInitialized(true); - setAppUserId(client.getAppUserId()); + setDistinctId(client.getDistinctId()); } }); @@ -77,11 +77,11 @@ export function VoidhashProvider(props: ProviderWithClient | ProviderWithConfig) const value = useMemo( () => ({ - appUserId, client, + distinctId, isInitialized, }), - [appUserId, client, isInitialized] + [client, distinctId, isInitialized] ); return ( diff --git a/libraries/web/src/types.ts b/libraries/web/src/types.ts index 9bfe9effc..69d43e257 100644 --- a/libraries/web/src/types.ts +++ b/libraries/web/src/types.ts @@ -33,8 +33,8 @@ export interface VoidhashAnalyticsOptions { export interface VoidhashClientOptions { readonly analytics?: VoidhashAnalyticsOptions; readonly baseUrl?: string; + readonly distinctId?: string; readonly featureFlags?: VoidhashFeatureFlagsOptions; - readonly initialAppUserId?: string; readonly observerMode?: boolean; readonly publishableKey: string; } @@ -52,12 +52,12 @@ export interface AnalyticsFlushResult { } export interface InitializedEvent { - readonly appUserId: string; + readonly distinctId: string; } export interface IdentityChangedEvent { - readonly appUserId: string; - readonly previousAppUserId: string | null; + readonly distinctId: string; + readonly previousDistinctId: string | null; } export interface FeatureFlagsUpdatedEvent { @@ -81,6 +81,7 @@ export interface VoidhashErrorEvent { } export interface VoidhashEventMap { + readonly "analytics-flush-needed": undefined; readonly "analytics-flushed": AnalyticsFlushedEvent; readonly "analytics-partial-rejection": AnalyticsPartialRejectionEvent; readonly error: VoidhashErrorEvent; @@ -108,7 +109,7 @@ export interface ResolvedVoidhashConfig { readonly refreshOnVisibility: boolean; readonly ttlMs: number; }; - readonly initialAppUserId?: string; + readonly distinctId?: string; readonly observerMode: boolean; readonly publishableKey: string; } diff --git a/libraries/web/tests/analytics.test.ts b/libraries/web/tests/analytics.test.ts index 43d51d81c..824d78430 100644 --- a/libraries/web/tests/analytics.test.ts +++ b/libraries/web/tests/analytics.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createVoidhashClient } from "../src/index"; -import { createJsonResponse } from "./helpers"; +import { createJsonResponse, installFetchMock } from "./helpers"; describe("analytics delivery", () => { beforeEach(() => { @@ -13,27 +13,33 @@ describe("analytics delivery", () => { }); it("retries a retryable analytics failure on the next flush", async () => { + vi.useFakeTimers(); + let analyticsAttempts = 0; - vi.stubGlobal("fetch", vi.fn(async (input: URL | RequestInfo) => { - const url = input.toString(); - if (url.endsWith("/v1/events")) { + installFetchMock((call) => { + if (call.url.endsWith("/batch")) { analyticsAttempts += 1; if (analyticsAttempts === 1) { - return createJsonResponse({ error: "try again" }, 503); + return createJsonResponse( + { + code: "dependency_unavailable", + error: "try again", + }, + 503 + ); } return createJsonResponse( { accepted: 1, rejected: 0, - request_id: "req_retry", }, 202 ); } return createJsonResponse({}); - })); + }); const client = createVoidhashClient({ analytics: { flushIntervalMs: 60_000, @@ -44,38 +50,47 @@ describe("analytics delivery", () => { await client.initialize(); await client.track("purchase_started"); - expect(await client.flushAnalytics()).toBeNull(); - expect(await client.flushAnalytics()).toEqual({ - accepted: 1, - rejected: 0, - requestId: "req_retry", - }); + try { + expect(await client.flushAnalytics()).toBeNull(); + expect(await client.flushAnalytics()).toBeNull(); - await client.destroy(); + vi.advanceTimersByTime(1_000); + expect(await client.flushAnalytics()).toEqual({ + accepted: 1, + rejected: 0, + }); + } finally { + vi.useRealTimers(); + await client.destroy(); + } }); it("splits batches when the ingest service returns 413", async () => { let analyticsAttempts = 0; - vi.stubGlobal("fetch", vi.fn(async (input: URL | RequestInfo) => { - const url = input.toString(); - if (url.endsWith("/v1/events")) { + installFetchMock((call) => { + if (call.url.endsWith("/batch")) { analyticsAttempts += 1; if (analyticsAttempts === 1) { - return createJsonResponse({ error: "payload too large" }, 413); + return createJsonResponse( + { + code: "payload_too_large", + error: "payload too large", + }, + 413 + ); } return createJsonResponse( { accepted: 1, rejected: 0, - request_id: `req_${analyticsAttempts}`, }, 202 ); } return createJsonResponse({}); - })); + }); const client = createVoidhashClient({ analytics: { flushIntervalMs: 60_000, @@ -92,10 +107,67 @@ describe("analytics delivery", () => { expect(await client.flushAnalytics()).toEqual({ accepted: 2, rejected: 0, - requestId: "req_3", }); expect(analyticsAttempts).toBe(3); await client.destroy(); }); + + it("honors Retry-After before retrying a rate-limited batch", async () => { + vi.useFakeTimers(); + + let analyticsAttempts = 0; + installFetchMock((call) => { + if (call.url.endsWith("/batch")) { + analyticsAttempts += 1; + if (analyticsAttempts === 1) { + return createJsonResponse( + { + code: "rate_limited", + error: "request rate limit exceeded", + retry_after_ms: 2_000, + }, + 429, + { + "retry-after": "2", + } + ); + } + + return createJsonResponse( + { + accepted: 1, + rejected: 0, + }, + 202 + ); + } + + return createJsonResponse({}); + }); + const client = createVoidhashClient({ + analytics: { + flushIntervalMs: 60_000, + }, + publishableKey: "vh_pk_test", + }); + + try { + await client.initialize(); + await client.track("purchase_started"); + + expect(await client.flushAnalytics()).toBeNull(); + expect(await client.flushAnalytics()).toBeNull(); + + vi.advanceTimersByTime(2_000); + expect(await client.flushAnalytics()).toEqual({ + accepted: 1, + rejected: 0, + }); + expect(analyticsAttempts).toBe(2); + } finally { + vi.useRealTimers(); + await client.destroy(); + } + }); }); diff --git a/libraries/web/tests/client.test.ts b/libraries/web/tests/client.test.ts index a112bb07d..a9041604a 100644 --- a/libraries/web/tests/client.test.ts +++ b/libraries/web/tests/client.test.ts @@ -40,12 +40,11 @@ describe("VoidhashWebClient", () => { }); } - if (call.url.endsWith("/v1/events")) { + if (call.url.endsWith("/batch")) { return createJsonResponse( { accepted: 1, rejected: 0, - request_id: "req_1", }, 202 ); @@ -66,23 +65,37 @@ describe("VoidhashWebClient", () => { }); await client.initialize(); - const appUserId = client.getAppUserId(); + const distinctId = client.getDistinctId(); const flags = await client.getFeatureFlags(["new-nav"]); await client.track("checkout_started", { source: "pricing_page" }); const flushResult = await client.flushAnalytics(); - expect(appUserId).toMatch(/^vh:anon:/); + expect(distinctId).toMatch(/^vh:anon:/); expect(flags.flags[0]?.key).toBe("new-nav"); expect(client.isFeatureEnabled("new-nav")).toBe(true); expect(flushResult).toEqual({ accepted: 1, rejected: 0, - requestId: "req_1", }); - const analyticsCall = calls.find((call) => call.url.includes("/v1/events")); - expect(analyticsCall?.url).toBe("https://i.voidhash.test/v1/events"); - expect(analyticsCall?.headers["x-distinct-id"]).toBe(appUserId); + const analyticsCall = calls.find((call) => call.url.includes("/batch")); + expect(analyticsCall?.url).toBe("https://i.voidhash.test/batch"); + expect(analyticsCall?.headers).toMatchObject({ + "content-type": "application/json", + }); + expect(JSON.parse(analyticsCall?.body ?? "{}")).toMatchObject({ + events: [ + { + distinct_id: distinctId, + event: "checkout_started", + properties: { + source: "pricing_page", + }, + uuid: expect.stringMatching(/^evt_/), + }, + ], + token: "vh_pk_test", + }); await client.destroy(); }); @@ -91,7 +104,19 @@ describe("VoidhashWebClient", () => { const { calls } = installFetchMock((call) => { if (call.url.endsWith("/sdk/identify")) { return createJsonResponse({ - appUserId: "user_123", + customerId: "customer_123", + distinctId: "user_123", + email: null, + name: null, + }); + } + + if (call.url.endsWith("/sdk/sync-customer-attributes")) { + return createJsonResponse({ + customerId: "customer_sync", + distinctId: "synced", + email: null, + name: null, }); } @@ -105,25 +130,25 @@ describe("VoidhashWebClient", () => { }); await client.initialize(); - const initialAppUserId = client.getAppUserId(); + const initialDistinctId = client.getDistinctId(); await client.identify("user_123", { companyId: "acme", plan: "pro" }); - await client.resetIdentity(); + await client.reset(); const syncCalls = calls.filter((call) => call.url.endsWith("/sdk/sync-customer-attributes") ); const identifyCall = calls.find((call) => call.url.endsWith("/sdk/identify")); - expect(syncCalls[0]?.headers["x-app-user-id"]).toBe(initialAppUserId); + expect(syncCalls[0]?.headers["x-distinct-id"]).toBe(initialDistinctId); expect(JSON.parse(identifyCall?.body ?? "{}")).toEqual({ - appUserId: "user_123", + distinctId: "user_123", traits: { companyId: "acme", plan: "pro", }, }); - expect(syncCalls[1]?.headers["x-app-user-id"]).toBe("user_123"); - expect(client.getAppUserId()).toMatch(/^vh:anon:/); + expect(syncCalls[1]?.headers["x-distinct-id"]).toBe("user_123"); + expect(client.getDistinctId()).toMatch(/^vh:anon:/); await client.destroy(); }); diff --git a/libraries/web/tests/helpers.ts b/libraries/web/tests/helpers.ts index c22e2e44e..33f6f6aef 100644 --- a/libraries/web/tests/helpers.ts +++ b/libraries/web/tests/helpers.ts @@ -9,11 +9,13 @@ export interface FetchCall { export const createJsonResponse = ( body: Record, - status = 200 + status = 200, + headers?: Record ) => new Response(JSON.stringify(body), { headers: { "content-type": "application/json", + ...headers, }, status, }); @@ -29,17 +31,38 @@ export const installFetchMock = ( ) => { const calls: FetchCall[] = []; const fetchMock = vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => { - const headers = new Headers(init?.headers); + let url: string; + let method: string; + let headers: Headers; + let bodyStr: string | undefined; + + if (input instanceof Request) { + url = input.url; + method = input.method; + headers = new Headers(input.headers); + try { + bodyStr = await input.clone().text(); + } catch { + bodyStr = undefined; + } + } else { + url = input.toString(); + method = init?.method ?? "GET"; + headers = new Headers(init?.headers); + if (typeof init?.body === "string") { + bodyStr = init.body; + } else if (init?.body && ArrayBuffer.isView(init.body)) { + bodyStr = new TextDecoder().decode(init.body); + } else { + bodyStr = undefined; + } + } + const call: FetchCall = { - body: - typeof init?.body === "string" - ? init.body - : init?.body instanceof Uint8Array - ? new TextDecoder().decode(init.body) - : undefined, + body: bodyStr, headers: Object.fromEntries(headers.entries()), - method: init?.method ?? "GET", - url: input.toString(), + method, + url, }; calls.push(call); diff --git a/libraries/web/tests/react.test.tsx b/libraries/web/tests/react.test.tsx index 377d628b7..c91534d50 100644 --- a/libraries/web/tests/react.test.tsx +++ b/libraries/web/tests/react.test.tsx @@ -49,13 +49,13 @@ describe("react integration", () => { const root = createRoot(container); function TestComponent() { - const { appUserId, isInitialized } = useVoidhash(); + const { distinctId, isInitialized } = useVoidhash(); const flags = useFeatureFlags(["new-nav"]); return (
{String(isInitialized)} - {appUserId ?? ""} + {distinctId ?? ""} {String(flags.isEnabled("new-nav"))}
); @@ -85,7 +85,7 @@ describe("react integration", () => { "true" ); expect( - container.querySelector('[data-testid="app-user-id"]')?.textContent + container.querySelector('[data-testid="distinct-id"]')?.textContent ).toMatch(/^vh:anon:/); expect(container.querySelector('[data-testid="flag"]')?.textContent).toBe( "true" diff --git a/packages/api-spec/package.json b/packages/api-spec/package.json index 10d685a93..ba4d3fb97 100644 --- a/packages/api-spec/package.json +++ b/packages/api-spec/package.json @@ -15,6 +15,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./event-capture": "./src/event-capture.ts", "./errors": "./src/errors/index.ts" }, "scripts": { diff --git a/packages/api-spec/src/api.ts b/packages/api-spec/src/api.ts index 446a7d7a8..db1caeb07 100644 --- a/packages/api-spec/src/api.ts +++ b/packages/api-spec/src/api.ts @@ -139,8 +139,8 @@ export const VoidhashV1Api = HttpApi.make("VoidhashV1Api") }) ) .add( - HttpApiEndpoint.get("byAppUserId", "/by-app-user-id/:appUserId", { - params: { appUserId: Schema.String }, + HttpApiEndpoint.get("byDistinctId", "/by-distinct-id/:distinctId", { + params: { distinctId: Schema.String }, success: Customer, error: [ActionForbiddenError, CustomerNotFoundError, CustomerServiceError], }) diff --git a/packages/api-spec/src/auth.ts b/packages/api-spec/src/auth.ts index 8ad068bc0..18c58878f 100644 --- a/packages/api-spec/src/auth.ts +++ b/packages/api-spec/src/auth.ts @@ -23,7 +23,7 @@ const ApiSessionOrganizationsSchema = Schema.Array(ApiSessionOrganizationSchema) const ApiSessionProjectsSchema = Schema.Array(ApiSessionProjectSchema); export const ApiSessionCustomerSchema = Schema.Struct({ - appUserId: Schema.String, + distinctId: Schema.String, }); export const ApiSessionUserSchema = Schema.Struct({ diff --git a/packages/api-spec/src/errors/customer.ts b/packages/api-spec/src/errors/customer.ts index 113f31f8c..1b8c252d6 100644 --- a/packages/api-spec/src/errors/customer.ts +++ b/packages/api-spec/src/errors/customer.ts @@ -17,7 +17,7 @@ export class CustomerNotFoundError extends Schema.TaggedErrorClass()( "SdkCustomerAlreadyIdentifiedError", { - appUserId: Schema.String, + distinctId: Schema.String, }, { httpApiStatus: 409 } ) { - toString(): string { - return `The following customer was already identified: ${this.appUserId}`; + override toString(): string { + return `The following customer was already identified: ${this.distinctId}`; } } diff --git a/packages/api-spec/src/event-capture.ts b/packages/api-spec/src/event-capture.ts new file mode 100644 index 000000000..1ad1012db --- /dev/null +++ b/packages/api-spec/src/event-capture.ts @@ -0,0 +1,184 @@ +import { Schema } from "effect"; +import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; + +const CaptureErrorResponseFields = { + error: Schema.NonEmptyString, +}; + +// export { CaptureAcceptedResponse, CaptureBatchRequest, CaptureErrorCode, CaptureErrorResponse }; +// export { CaptureEvent, CaptureSdkRequestMetadata }; +export type EventPropertiesFieldPrimitive = string | number | boolean | null; +export type EventPropertiesField = + | EventPropertiesFieldPrimitive + | ReadonlyArray + | { + readonly [key: string]: EventPropertiesField; + }; + +export const EventPropertiesField: Schema.Codec = Schema.Union([ + Schema.String, + Schema.Finite, + Schema.Boolean, + Schema.Null, + Schema.Array( + Schema.suspend((): Schema.Codec => EventPropertiesField), + ), + Schema.Record( + Schema.String, + Schema.suspend((): Schema.Codec => EventPropertiesField), + ), +]); +export const EventPropertiesSchema = Schema.Record(Schema.String, EventPropertiesField); + + +export type EventContextFieldPrimitive = string | number | boolean | null; +export type EventContextField = + | EventContextFieldPrimitive + | ReadonlyArray + | { + readonly [key: string]: EventContextField; + }; +export const EventContextField: Schema.Codec = Schema.Union([ + Schema.String, + Schema.Finite, + Schema.Boolean, + Schema.Null, + Schema.Array( + Schema.suspend((): Schema.Codec => EventContextField), + ), + Schema.Record( + Schema.String, + Schema.suspend((): Schema.Codec => EventContextField), + ), +]); +export const EventContextSchema = Schema.Record(Schema.String, EventContextField); + +export const CaptureEvent = Schema.Struct({ + uuid: Schema.NonEmptyString, + event: Schema.NonEmptyString, + context: EventContextSchema, + properties: EventPropertiesSchema, + distinct_id: Schema.NonEmptyString, + session_id: Schema.optional(Schema.NonEmptyString), + timestamp: Schema.optional(Schema.DateValid), +}); + +export const CaptureSingleRequest = Schema.Struct({ + ...CaptureEvent.fields, + sent_at: Schema.DateValid, + token: Schema.NonEmptyString, +}); + +export const CaptureBatchRequest = Schema.Struct({ + events: Schema.NonEmptyArray(CaptureEvent), + sent_at: Schema.DateValid, + token: Schema.NonEmptyString, +}); + +export class CaptureAcceptedResponse extends Schema.Class( + "CaptureAcceptedResponse", +)({ + accepted: Schema.Int, + rejected: Schema.Int, +}) { + httpApiStatus = 202; +} +// export const CaptureAcceptedApiResponse = CaptureAcceptedResponse.pipe(HttpApiSchema.status(202)); + +export class CaptureInvalidRequestError extends Schema.TaggedErrorClass()( + "CaptureInvalidRequestError", + { + ...CaptureErrorResponseFields, + code: Schema.Literal("invalid_request"), + }, + { httpApiStatus: 400 }, +) {} + +export class CaptureUnauthorizedError extends Schema.TaggedErrorClass()( + "CaptureUnauthorizedError", + { + ...CaptureErrorResponseFields, + code: Schema.Literal("unauthorized"), + }, + { httpApiStatus: 401 }, +) {} + +export class CapturePayloadTooLargeError extends Schema.TaggedErrorClass()( + "CapturePayloadTooLargeError", + { + ...CaptureErrorResponseFields, + code: Schema.Literal("payload_too_large"), + }, + { httpApiStatus: 413 }, +) {} + +export class CaptureRateLimitedError extends Schema.TaggedErrorClass()( + "CaptureRateLimitedError", + { + ...CaptureErrorResponseFields, + code: Schema.Literal("rate_limited"), + retry_after_ms: Schema.optional(Schema.Int), + }, + { httpApiStatus: 429 }, +) {} + +export class CaptureDependencyUnavailableError extends Schema.TaggedErrorClass()( + "CaptureDependencyUnavailableError", + { + ...CaptureErrorResponseFields, + code: Schema.Literal("dependency_unavailable"), + }, + { httpApiStatus: 503 }, +) {} + +export class CaptureInternalServerError extends Schema.TaggedErrorClass()( + "CaptureInternalServerError", + { + ...CaptureErrorResponseFields, + code: Schema.Literal("internal_error"), + }, + { httpApiStatus: 500 }, +) {} + +export type CaptureErrorResponse = + | CaptureInvalidRequestError + | CaptureUnauthorizedError + | CapturePayloadTooLargeError + | CaptureRateLimitedError + | CaptureDependencyUnavailableError + | CaptureInternalServerError; + +export type CaptureErrorCode = CaptureErrorResponse["code"]; + + +export const EventCaptureApi = HttpApi.make("EventCaptureApi").add( + HttpApiGroup.make("event_capture") + .add( + HttpApiEndpoint.post("capture", "/capture", { + error: [ + CaptureInvalidRequestError, + CaptureUnauthorizedError, + CapturePayloadTooLargeError, + CaptureRateLimitedError, + CaptureDependencyUnavailableError, + CaptureInternalServerError, + ], + payload: CaptureSingleRequest, + success: CaptureAcceptedResponse, + }), + ) + .add( + HttpApiEndpoint.post("batch", "/batch", { + error: [ + CaptureInvalidRequestError, + CaptureUnauthorizedError, + CapturePayloadTooLargeError, + CaptureRateLimitedError, + CaptureDependencyUnavailableError, + CaptureInternalServerError, + ], + payload: CaptureBatchRequest, + success: CaptureAcceptedResponse, + }), + ), +); diff --git a/packages/api-spec/src/schema.ts b/packages/api-spec/src/schema.ts index 53b02dbcf..c43cc6b65 100644 --- a/packages/api-spec/src/schema.ts +++ b/packages/api-spec/src/schema.ts @@ -3,7 +3,7 @@ import { Schema } from "effect"; import { ChangesetSchema } from "./changeset"; export const PublishableKeyAuthHeaders = Schema.Struct({ - "x-app-user-id": Schema.String, + "x-distinct-id": Schema.String, "x-publishable-key": Schema.String, }); @@ -85,23 +85,23 @@ export const ApiKeyIdParam = Schema.String; // ======================================================== export class Customer extends Schema.Class("Customer")({ - appUserId: Schema.String, + customerId: Schema.String, + distinctId: Schema.String, email: Schema.NullOr(Schema.String), - id: Schema.String, name: Schema.NullOr(Schema.String), }) {} export class CreateCustomerBody extends Schema.Class( "CreateCustomerBody" )({ - appUserId: Schema.String, + distinctId: Schema.String, email: Schema.optional(Schema.String), name: Schema.optional(Schema.String), }) {} export const CustomerIdParam = Schema.String; -export const AppUserIdParam = Schema.String; +export const DistinctIdParam = Schema.String; // ======================================================== // Organizations @@ -277,7 +277,7 @@ const SdkTraits = Schema.Record(Schema.String, SdkTraitValue); export class SdkIdentifyBody extends Schema.Class( "SdkIdentifyBody" )({ - appUserId: Schema.String, + distinctId: Schema.String, email: Schema.optional(Schema.String), name: Schema.optional(Schema.String), traits: Schema.optional(SdkTraits), @@ -309,8 +309,8 @@ export class SdkSyncTransactionResponse extends Schema.Class("SdkCustomer")({ - appUserId: Schema.String, customerId: Schema.String, + distinctId: Schema.String, email: Schema.NullOr(Schema.String), name: Schema.NullOr(Schema.String), }) {} diff --git a/packages/shared/src/auth.ts b/packages/shared/src/auth.ts index 84a3e6349..9056ee2a1 100644 --- a/packages/shared/src/auth.ts +++ b/packages/shared/src/auth.ts @@ -19,7 +19,7 @@ const SessionOrganizationsSchema = Schema.Array(SessionOrganizationSchema); const SessionProjectsSchema = Schema.Array(SessionProjectSchema); export const SessionCustomerSchema = Schema.Struct({ - appUserId: Schema.String, + distinctId: Schema.String, }); export const SessionUserSchema = Schema.Struct({ @@ -118,7 +118,7 @@ export type PublishableKeySession = typeof PublishableKeySessionSchema.Type; // readonly method: 'publishable-key'; // readonly name: string; // readonly customer: { -// readonly appUserId: string; +// readonly distinctId: string; // }; // readonly user: null; // readonly cookie: null; From fdef6c1b3ac9e49246719c08101de08bba837fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Tue, 24 Mar 2026 11:53:41 +0100 Subject: [PATCH 007/129] fix: package-lock --- pnpm-lock.yaml | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f9656ef2..b24257888 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,12 +260,12 @@ importers: '@react-native-async-storage/async-storage': specifier: ^1.24.0 || ^2.0.0 version: 2.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) + '@voidhash/api-spec': + specifier: workspace:* + version: link:../../packages/api-spec effect: specifier: 4.0.0-beta.23 version: 4.0.0-beta.23 - neverthrow: - specifier: ^8.2.0 - version: 8.2.0 devDependencies: '@types/jest': specifier: ^29.5.14 @@ -276,9 +276,6 @@ importers: '@vitejs/plugin-react': specifier: ^4.6.0 version: 4.7.0(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@voidhash/api-spec': - specifier: workspace:* - version: link:../../packages/api-spec '@voidhash/shared': specifier: workspace:* version: link:../../packages/shared @@ -6125,10 +6122,6 @@ packages: nested-error-stacks@2.0.1: resolution: {integrity: sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==} - neverthrow@8.2.0: - resolution: {integrity: sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==} - engines: {node: '>=18'} - next@16.0.0: resolution: {integrity: sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==} engines: {node: '>=20.9.0'} @@ -15764,10 +15757,6 @@ snapshots: nested-error-stacks@2.0.1: {} - neverthrow@8.2.0: - optionalDependencies: - '@rollup/rollup-linux-x64-gnu': 4.54.0 - next@16.0.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 16.0.0 From 73d606d93b7aaa7826faeef11c055b61fcb804b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Tue, 24 Mar 2026 13:30:49 +0100 Subject: [PATCH 008/129] publish from their directory directly --- .../workflows/publish-libraries-canary-preview.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-libraries-canary-preview.yml b/.github/workflows/publish-libraries-canary-preview.yml index da29ee67e..f515aac97 100644 --- a/.github/workflows/publish-libraries-canary-preview.yml +++ b/.github/workflows/publish-libraries-canary-preview.yml @@ -99,21 +99,25 @@ jobs: run: pnpm --filter @voidhash/react-native build - name: Publish api-spec canary + working-directory: packages/api-spec env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: pnpm --filter @voidhash/api-spec publish --tag canary --access public --no-git-checks + run: npm publish --tag canary --access public - name: Publish node canary + working-directory: libraries/node env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: pnpm --filter @voidhash/node publish --tag canary --access public --no-git-checks + run: npm publish --tag canary --access public - name: Publish web canary + working-directory: libraries/web env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: pnpm --filter @voidhash/web publish --tag canary --access public --no-git-checks + run: npm publish --tag canary --access public - name: Publish react-native canary + working-directory: libraries/react-native env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: pnpm --filter @voidhash/react-native publish --tag canary --access public --no-git-checks + run: npm publish --tag canary --access public From e9036dbacf0f1e847d6b3ba6598b24af4b32b4d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Sat, 18 Apr 2026 15:19:23 +0200 Subject: [PATCH 009/129] wip: refactor --- apps/cli/package.json | 2 +- apps/cli/src/cli/commands/init.ts | 2 +- apps/cli/src/domain/services/auth.ts | 64 +- apps/cli/src/domain/services/schema.ts | 27 +- apps/cli/src/services/auth/get-session.ts | 32 +- .../organization/create-organization.ts | 29 +- .../organization/list-organizations.ts | 29 +- apps/cli/src/utils/api-client.ts | 49 +- .../organizations/create-organization.ts | 6 +- apps/cli/src/utils/projects/create-project.ts | 11 +- docs/JS-SDK.md | 14 +- examples/react-native-example/package.json | 2 +- examples/react-native-example/tsconfig.json | 5 +- libraries/node/package.json | 2 +- libraries/node/src/effect-client.ts | 13 +- .../node/src/generated/grouped-client.ts | 65 + libraries/node/src/internal/client-types.ts | 172 - .../src/internal/make-generated-client.ts | 46 +- .../internal/normalize-generated-client.ts | 122 - libraries/node/src/promise-client.ts | 3 +- libraries/node/tests/client.test.ts | 34 +- libraries/react-native/package.json | 2 +- .../__tests__/helpers/effect-test-harness.ts | 2 +- libraries/react-native/src/client-effect.ts | 2 +- libraries/react-native/src/core/event-bus.ts | 4 +- .../core/identity/customer-info-manager.ts | 2 +- .../src/core/networking/api-client.ts | 145 +- .../src/core/utils/get-common-sdk-headers.ts | 25 +- .../src/react/hooks/use-customer.ts | 2 +- libraries/web/package.json | 2 +- .../src/core/analytics/analytics-context.ts | 4 +- .../src/core/analytics/analytics-service.ts | 16 +- libraries/web/src/core/analytics/contracts.ts | 8 +- .../web/src/core/networking/api-client.ts | 116 +- .../networking/event-capture-api-client.ts | 22 +- .../networking/normalize-generated-client.ts | 121 - package.json | 2 + packages/api-spec/LICENSE.md | 21 - packages/api-spec/README.md | 43 - packages/api-spec/package.json | 32 - packages/api-spec/src/api.ts | 398 - packages/api-spec/src/auth.ts | 99 - packages/api-spec/src/changeset.ts | 204 - packages/api-spec/src/errors/admin.ts | 10 - packages/api-spec/src/errors/analytics.ts | 28 - packages/api-spec/src/errors/api-key.ts | 19 - packages/api-spec/src/errors/billing.ts | 28 - packages/api-spec/src/errors/changeset.ts | 10 - packages/api-spec/src/errors/common.ts | 29 - packages/api-spec/src/errors/customer.ts | 36 - packages/api-spec/src/errors/index.ts | 18 - packages/api-spec/src/errors/organization.ts | 19 - .../api-spec/src/errors/payment-provider.ts | 73 - .../api-spec/src/errors/paywall-location.ts | 10 - packages/api-spec/src/errors/paywall.ts | 37 - packages/api-spec/src/errors/perk.ts | 32 - packages/api-spec/src/errors/product-perk.ts | 19 - packages/api-spec/src/errors/product.ts | 32 - packages/api-spec/src/errors/project.ts | 23 - packages/api-spec/src/errors/sdk.ts | 41 - packages/api-spec/src/errors/user.ts | 10 - packages/api-spec/src/errors/webhook.ts | 37 - packages/api-spec/src/event-capture.ts | 184 - packages/api-spec/src/index.ts | 5 - packages/api-spec/src/middlewares.ts | 31 - packages/api-spec/src/schema.ts | 536 -- .../openapi/preview/core.json | 8086 +++++++++++++++++ .../openapi/preview/event-capture.json | 787 ++ .../openapi/production/core.json | 8086 +++++++++++++++++ .../openapi/production/event-capture.json | 787 ++ packages/generated-clients/package.json | 22 + .../generated-clients/src/core/generated.ts | 1230 +++ packages/generated-clients/src/core/index.ts | 1 + .../src/event-capture/generated.ts | 229 + .../src/event-capture/index.ts | 37 + packages/generated-clients/src/index.ts | 2 + .../tsconfig.json | 0 packages/shared/src/admin.ts | 2 +- packages/shared/src/analytics.ts | 2 +- packages/shared/src/api-key.ts | 2 +- packages/shared/src/app-store.ts | 2 +- packages/shared/src/billing.ts | 2 +- packages/shared/src/customer.ts | 2 +- packages/shared/src/deploy-changeset.ts | 2 +- packages/shared/src/errors.ts | 2 +- packages/shared/src/google-play.ts | 2 +- packages/shared/src/organization.ts | 2 +- .../src/payment-provider-configuration.ts | 2 +- .../shared/src/payment-provider-product.ts | 2 +- packages/shared/src/paywall.ts | 2 +- packages/shared/src/perk-grant.ts | 2 +- packages/shared/src/perk.ts | 2 +- packages/shared/src/product-perk.ts | 2 +- packages/shared/src/product.ts | 2 +- packages/shared/src/project.ts | 2 +- packages/shared/src/sdk.ts | 2 +- packages/shared/src/user.ts | 2 +- packages/shared/src/webhook.ts | 2 +- pnpm-lock.yaml | 18 +- scripts/generate-node-grouped-client.mjs | 199 + scripts/generate-openapi-clients.mjs | 119 + 101 files changed, 20220 insertions(+), 2693 deletions(-) create mode 100644 libraries/node/src/generated/grouped-client.ts delete mode 100644 libraries/node/src/internal/client-types.ts delete mode 100644 libraries/node/src/internal/normalize-generated-client.ts delete mode 100644 libraries/web/src/core/networking/normalize-generated-client.ts delete mode 100644 packages/api-spec/LICENSE.md delete mode 100644 packages/api-spec/README.md delete mode 100644 packages/api-spec/package.json delete mode 100644 packages/api-spec/src/api.ts delete mode 100644 packages/api-spec/src/auth.ts delete mode 100644 packages/api-spec/src/changeset.ts delete mode 100644 packages/api-spec/src/errors/admin.ts delete mode 100644 packages/api-spec/src/errors/analytics.ts delete mode 100644 packages/api-spec/src/errors/api-key.ts delete mode 100644 packages/api-spec/src/errors/billing.ts delete mode 100644 packages/api-spec/src/errors/changeset.ts delete mode 100644 packages/api-spec/src/errors/common.ts delete mode 100644 packages/api-spec/src/errors/customer.ts delete mode 100644 packages/api-spec/src/errors/index.ts delete mode 100644 packages/api-spec/src/errors/organization.ts delete mode 100644 packages/api-spec/src/errors/payment-provider.ts delete mode 100644 packages/api-spec/src/errors/paywall-location.ts delete mode 100644 packages/api-spec/src/errors/paywall.ts delete mode 100644 packages/api-spec/src/errors/perk.ts delete mode 100644 packages/api-spec/src/errors/product-perk.ts delete mode 100644 packages/api-spec/src/errors/product.ts delete mode 100644 packages/api-spec/src/errors/project.ts delete mode 100644 packages/api-spec/src/errors/sdk.ts delete mode 100644 packages/api-spec/src/errors/user.ts delete mode 100644 packages/api-spec/src/errors/webhook.ts delete mode 100644 packages/api-spec/src/event-capture.ts delete mode 100644 packages/api-spec/src/index.ts delete mode 100644 packages/api-spec/src/middlewares.ts delete mode 100644 packages/api-spec/src/schema.ts create mode 100644 packages/generated-clients/openapi/preview/core.json create mode 100644 packages/generated-clients/openapi/preview/event-capture.json create mode 100644 packages/generated-clients/openapi/production/core.json create mode 100644 packages/generated-clients/openapi/production/event-capture.json create mode 100644 packages/generated-clients/package.json create mode 100644 packages/generated-clients/src/core/generated.ts create mode 100644 packages/generated-clients/src/core/index.ts create mode 100644 packages/generated-clients/src/event-capture/generated.ts create mode 100644 packages/generated-clients/src/event-capture/index.ts create mode 100644 packages/generated-clients/src/index.ts rename packages/{api-spec => generated-clients}/tsconfig.json (100%) create mode 100644 scripts/generate-node-grouped-client.mjs create mode 100644 scripts/generate-openapi-clients.mjs diff --git a/apps/cli/package.json b/apps/cli/package.json index 3cee200de..31a956b7b 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@effect/platform-node": "4.0.0-beta.23", - "@voidhash/api-spec": "workspace:*", + "@voidhash/generated-clients": "workspace:*", "@voidhash/shared": "workspace:*", "better-auth": "catalog:", "effect": "4.0.0-beta.23", diff --git a/apps/cli/src/cli/commands/init.ts b/apps/cli/src/cli/commands/init.ts index acae5624d..2aaf2e17c 100644 --- a/apps/cli/src/cli/commands/init.ts +++ b/apps/cli/src/cli/commands/init.ts @@ -83,7 +83,7 @@ export const initCommand = Command.make("init", { debug: debugOption }, () => organization.id, session.projects.filter((p) => p.organizationId === organization.id), ); - const apiKeys = (yield* apiClient.api_keys.listApiKeys()) as readonly { + const apiKeys = (yield* apiClient.apiKeysListApiKeys()) as readonly { id: string; isPublic: boolean; projectId: string; diff --git a/apps/cli/src/domain/services/auth.ts b/apps/cli/src/domain/services/auth.ts index c269046d3..d4ecd4f50 100644 --- a/apps/cli/src/domain/services/auth.ts +++ b/apps/cli/src/domain/services/auth.ts @@ -1,4 +1,5 @@ import { NodeServices, NodeHttpServer } from "@effect/platform-node"; +import type { AuthSession200 } from "@voidhash/generated-clients"; import { Console, Data, @@ -49,6 +50,43 @@ interface KeyCallbackEvent { type CallbackEvent = CancelledCallbackEvent | KeyCallbackEvent; +interface AuthShape { + readonly getSignedInSession: Effect.Effect< + AuthSession200, + FailedToGetSessionError | NoSignedInUserError + >; + readonly login: Effect.Effect; + readonly logout: Effect.Effect; +} + +const hasTag = ( + error: unknown, + tag: string +): error is { readonly _tag: string } => + typeof error === "object" && + error !== null && + "_tag" in error && + typeof error._tag === "string" && + error._tag === tag; + +const hasNestedTag = ( + error: unknown, + outerTag: string, + innerTag: string +): error is { readonly _tag: string; readonly data: { readonly _tag: string } } => + hasTag(error, outerTag) && + "data" in error && + typeof error.data === "object" && + error.data !== null && + "_tag" in error.data && + typeof error.data._tag === "string" && + error.data._tag === innerTag; + +const isNoSignedInUserError = ( + error: unknown +): error is NoSignedInUserError => + error instanceof NoSignedInUserError || hasTag(error, "NoSignedInUserError"); + const runCallbackServer = (callbackEvents: PubSub.PubSub) => Effect.gen(function* runCallbackServer() { // Create the callback route layer @@ -123,7 +161,7 @@ const make = Effect.gen(function* effect() { * @returns {Effect.Effect} * An Effect that yields the signed-in user's information, or fails with an appropriate error. */ - const getSignedInSession = Effect.gen(function* getSignedInSession() { + const getSignedInSession: AuthShape["getSignedInSession"] = Effect.gen(function* getSignedInSession() { yield* Effect.logDebug("Reading CLI config for session check"); const config = (yield* cliConfig .readConfig() @@ -141,32 +179,34 @@ const make = Effect.gen(function* effect() { } yield* Effect.logDebug("Fetching session from API"); - const sessionResponse = yield* client.auth.session().pipe( + const sessionResponse = yield* client.authSession().pipe( Effect.tap((session) => Effect.logDebug(`Session retrieved for user: ${session.name}`) ), - Effect.catchTags({ - NotAuthenticatedError: () => + Effect.catchIf( + (error) => hasNestedTag(error, "AuthSession500", "NotAuthenticatedError"), + () => Effect.fail(new NoSignedInUserError({ message: "No signed in user" })), - }) + ) ); return sessionResponse; }).pipe( Effect.withSpan("Auth.getSignedInSession"), Effect.catchIf( - (e) => e._tag !== "NoSignedInUserError", - (e) => + isNoSignedInUserError, + (error) => Effect.fail(error), + (error) => Effect.fail( new FailedToGetSessionError({ - cause: e, + cause: error, message: "Failed to get session", }) ) ) ); - const login = Effect.scoped( + const login: AuthShape["login"] = Effect.scoped( Effect.gen(function* login() { yield* Effect.logDebug("Starting login flow"); const callbackEventsPubSub = yield* PubSub.unbounded(); @@ -241,7 +281,7 @@ const make = Effect.gen(function* effect() { * * @returns An Effect that logs out the current user, or fails with a FailedToLogoutError if the logout fails. */ - const logout = Effect.gen(function* logout() { + const logout: AuthShape["logout"] = Effect.gen(function* logout() { yield* Effect.logDebug("Starting logout"); const config = yield* cliConfig.readConfig(); if (!config.api_key) { @@ -269,11 +309,9 @@ const make = Effect.gen(function* effect() { getSignedInSession, login, logout, - } as const; + } satisfies AuthShape; }); -type AuthShape = Effect.Success; - export class Auth extends ServiceMap.Service()( "voidhash-cli/Auth" ) { diff --git a/apps/cli/src/domain/services/schema.ts b/apps/cli/src/domain/services/schema.ts index 846f3da56..77a7025d9 100644 --- a/apps/cli/src/domain/services/schema.ts +++ b/apps/cli/src/domain/services/schema.ts @@ -35,7 +35,7 @@ const make = Effect.gen(function* effect() { const schema = createEmptyNormalizedSchema(); // 1. Fetch all perks - const remotePerks = yield* apiClient.perks.listPerks(); + const remotePerks = yield* apiClient.perksListPerks(); for (const perk of remotePerks) { schema.perks.set(perk.slug, { name: perk.name, @@ -45,7 +45,7 @@ const make = Effect.gen(function* effect() { // 1b. Fetch all active paywall locations const remoteLocations = - yield* apiClient.paywall_locations.listPaywallLocations(); + yield* apiClient.paywallLocationsListPaywallLocations(); for (const location of remoteLocations) { schema.locations.set(location.slug, { description: location.description, @@ -55,11 +55,11 @@ const make = Effect.gen(function* effect() { } // 2. Fetch all products - const remoteProducts = yield* apiClient.products.listProducts(); + const remoteProducts = yield* apiClient.productsListProducts(); // 3. Fetch payment provider configurations const providerConfigs = - yield* apiClient.payment_provider_configurations.listPaymentProviderConfigurations(); + yield* apiClient.paymentProviderConfigurationsListPaymentProviderConfigurations(); for (const config of providerConfigs) { if ( config.providerId === "appleAppStore" || @@ -71,7 +71,7 @@ const make = Effect.gen(function* effect() { // 4. Fetch all payment provider products const providerProducts = - yield* apiClient.payment_provider_products.listPaymentProviderProducts(); + yield* apiClient.paymentProviderProductsListPaymentProviderProducts(); // Build a map of productId -> provider products const productProviderMap = new Map< @@ -98,10 +98,8 @@ const make = Effect.gen(function* effect() { yield* Effect.all( remoteProducts.map((product) => Effect.gen(function* () { - const productPerks = yield* apiClient.product_perks - .listProductPerksByProductId({ - params: { productId: product.id }, - }) + const productPerks = yield* apiClient + .productPerksListProductPerksByProductId(product.id) .pipe( Effect.retry({ schedule: Schedule.exponential(1000), @@ -150,8 +148,7 @@ const make = Effect.gen(function* effect() { * Fetch payment provider configurations */ const fetchProviderConfigurations = () => - apiClient.payment_provider_configurations - .listPaymentProviderConfigurations() + apiClient.paymentProviderConfigurationsListPaymentProviderConfigurations() .pipe( Effect.tap((configs) => Effect.logDebug( @@ -187,8 +184,8 @@ const make = Effect.gen(function* effect() { const deployChange = (change: Change) => Effect.logDebug(`Deploying change: ${formatChange(change)}`).pipe( Effect.andThen( - apiClient.changesets.deployChangeset({ - payload: { changeset: { changes: [change] } }, + apiClient.changesetsDeployChangeset({ + changeset: { changes: [change] }, }) ), Effect.withSpan("SchemaService.deployChange"), @@ -209,8 +206,8 @@ const make = Effect.gen(function* effect() { `Deploying changeset with ${changeset.changes.length} changes` ).pipe( Effect.andThen( - apiClient.changesets.deployChangeset({ - payload: { changeset }, + apiClient.changesetsDeployChangeset({ + changeset, }) ), Effect.withSpan("SchemaService.deployChangeset"), diff --git a/apps/cli/src/services/auth/get-session.ts b/apps/cli/src/services/auth/get-session.ts index 03943b09f..eee1eb593 100644 --- a/apps/cli/src/services/auth/get-session.ts +++ b/apps/cli/src/services/auth/get-session.ts @@ -3,26 +3,36 @@ import { Effect } from "effect"; import { ApiClient } from "../../utils/api-client"; import { OrganizationServiceError } from "../organization/errors"; +const hasNestedTag = ( + error: unknown, + outerTag: string, + innerTag: string +): error is { readonly _tag: string; readonly data: { readonly _tag: string } } => + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === outerTag && + "data" in error && + typeof error.data === "object" && + error.data !== null && + "_tag" in error.data && + error.data._tag === innerTag; + export const getSession = Effect.gen(function* getSession() { const client = yield* ApiClient; return Effect.fn("getSession")( - function* getSession(input: { name: string }) { - const organization = yield* client.organizations.createOrganization({ - payload: { - name: input.name, - }, - }); - - return organization; + function* getSession() { + const session = yield* client.authSession(); + return session; }, (effect) => effect.pipe( Effect.catch((error) => { - if (error._tag === "NotAuthenticatedError") { + if (hasNestedTag(error, "AuthSession500", "NotAuthenticatedError")) { return Effect.fail( new OrganizationServiceError({ message: - "Failed to create an organization because you are not authenticated.", + "Failed to fetch the session because you are not authenticated.", }) ); } @@ -30,7 +40,7 @@ export const getSession = Effect.gen(function* getSession() { return Effect.fail( new OrganizationServiceError({ message: - "Failed to create an organization because of an unknown error. Please try again. If the problem persists, please contact us at support@voidhash.com", + "Failed to fetch the session because of an unknown error. Please try again. If the problem persists, please contact us at support@voidhash.com", }) ); }) diff --git a/apps/cli/src/services/organization/create-organization.ts b/apps/cli/src/services/organization/create-organization.ts index 7bf2ed355..da06c9e33 100644 --- a/apps/cli/src/services/organization/create-organization.ts +++ b/apps/cli/src/services/organization/create-organization.ts @@ -3,14 +3,27 @@ import { Effect } from "effect"; import { ApiClient } from "../../utils/api-client"; import { OrganizationServiceError } from "./errors"; +const hasNestedTag = ( + error: unknown, + outerTag: string, + innerTag: string +): error is { readonly _tag: string; readonly data: { readonly _tag: string } } => + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === outerTag && + "data" in error && + typeof error.data === "object" && + error.data !== null && + "_tag" in error.data && + error.data._tag === innerTag; + export const createOrganization = Effect.gen(function* createOrganization() { const client = yield* ApiClient; return Effect.fn("createOrganization")( function* createOrganization(input: { name: string }) { - const organization = yield* client.organizations.createOrganization({ - payload: { - name: input.name, - }, + const organization = yield* client.organizationsCreateOrganization({ + name: input.name, }); return organization; @@ -18,7 +31,13 @@ export const createOrganization = Effect.gen(function* createOrganization() { (effect) => effect.pipe( Effect.catch((error) => { - if (error._tag === "NotAuthenticatedError") { + if ( + hasNestedTag( + error, + "OrganizationsCreateOrganization500", + "NotAuthenticatedError" + ) + ) { return Effect.fail( new OrganizationServiceError({ message: diff --git a/apps/cli/src/services/organization/list-organizations.ts b/apps/cli/src/services/organization/list-organizations.ts index 76be481c5..8e46c6dcc 100644 --- a/apps/cli/src/services/organization/list-organizations.ts +++ b/apps/cli/src/services/organization/list-organizations.ts @@ -3,14 +3,27 @@ import { Effect } from "effect"; import { ApiClient } from "../../utils/api-client"; import { OrganizationServiceError } from "./errors"; +const hasNestedTag = ( + error: unknown, + outerTag: string, + innerTag: string +): error is { readonly _tag: string; readonly data: { readonly _tag: string } } => + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === outerTag && + "data" in error && + typeof error.data === "object" && + error.data !== null && + "_tag" in error.data && + error.data._tag === innerTag; + export const listOrganizations = Effect.gen(function* listOrganizations() { const client = yield* ApiClient; return Effect.fn("listOrganizations")( function* listOrganizations(input: { name: string }) { - const organization = yield* client.organizations.createOrganization({ - payload: { - name: input.name, - }, + const organization = yield* client.organizationsCreateOrganization({ + name: input.name, }); return organization; @@ -18,7 +31,13 @@ export const listOrganizations = Effect.gen(function* listOrganizations() { (effect) => effect.pipe( Effect.catch((error) => { - if (error._tag === "NotAuthenticatedError") { + if ( + hasNestedTag( + error, + "OrganizationsCreateOrganization500", + "NotAuthenticatedError" + ) + ) { return Effect.fail( new OrganizationServiceError({ message: diff --git a/apps/cli/src/utils/api-client.ts b/apps/cli/src/utils/api-client.ts index e5a4b5f73..a521d3eda 100644 --- a/apps/cli/src/utils/api-client.ts +++ b/apps/cli/src/utils/api-client.ts @@ -1,37 +1,38 @@ -import { VoidhashV1Api } from "@voidhash/api-spec"; +import { + make as makeCoreClient, + type VoidhashCoreClient, +} from "@voidhash/generated-clients"; import { Effect, Layer, ServiceMap } from "effect"; -import { FetchHttpClient, HttpClient } from "effect/unstable/http"; -import { HttpApiClient } from "effect/unstable/httpapi"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import { CliConfig } from "../domain/services/cli-config"; const make = Effect.gen(function* effect() { yield* Effect.logDebug("Initializing API client"); const cliConfig = yield* CliConfig; - return yield* HttpApiClient.make(VoidhashV1Api, { - baseUrl: "http://localhost:5001", + const httpClient = yield* HttpClient.HttpClient; + return makeCoreClient(httpClient as VoidhashCoreClient["httpClient"], { transformClient: (client) => - client.pipe( - HttpClient.mapRequestEffect((request) => - Effect.gen(function* transformClient() { - const config = yield* cliConfig.readConfig().pipe( - Effect.catch(() => - Effect.die("Failed to read config") - ) - ); + Effect.succeed( + client.pipe( + HttpClient.mapRequestEffect((request) => + Effect.gen(function* transformClient() { + const config = yield* cliConfig.readConfig().pipe( + Effect.catch(() => + Effect.die("Failed to read config") + ) + ); - yield* Effect.logDebug( - `API Request: ${request.method} ${request.url}` - ); + yield* Effect.logDebug( + `API Request: ${request.method} ${request.url}` + ); - return { - ...request, - headers: { - ...request.headers, - ...(config.api_key ? { "x-api-key": config.api_key } : {}), - }, - }; - }).pipe(Effect.withSpan("ApiClient.transformRequest")) + return HttpClientRequest.setHeaders( + HttpClientRequest.prependUrl(request, "http://localhost:5001"), + config.api_key ? { "x-api-key": config.api_key } : {} + ); + }).pipe(Effect.withSpan("ApiClient.transformRequest")) + ) ) ), }); diff --git a/apps/cli/src/utils/organizations/create-organization.ts b/apps/cli/src/utils/organizations/create-organization.ts index 548d812d6..b33f8b30e 100644 --- a/apps/cli/src/utils/organizations/create-organization.ts +++ b/apps/cli/src/utils/organizations/create-organization.ts @@ -28,10 +28,8 @@ export const createOrganization = () => }) ); - const organization = yield* client.organizations.createOrganization({ - payload: { - name, - }, + const organization = yield* client.organizationsCreateOrganization({ + name, }); yield* Console.log( diff --git a/apps/cli/src/utils/projects/create-project.ts b/apps/cli/src/utils/projects/create-project.ts index 78d4cce19..eeaa3274a 100644 --- a/apps/cli/src/utils/projects/create-project.ts +++ b/apps/cli/src/utils/projects/create-project.ts @@ -44,14 +44,9 @@ export const createProject = (input: { organizationId: string }) => }) ); - const project = yield* client.projects.createProject({ - // headers: { - // 'x-api-key': apiKey - // }, - payload: { - name, - organizationId: input.organizationId, - }, + const project = yield* client.projectsCreateProject({ + name, + organizationId: input.organizationId, }); yield* Console.log(`Successfully created project ${project.name}`); diff --git a/docs/JS-SDK.md b/docs/JS-SDK.md index 8afedcfae..6890c9b8a 100644 --- a/docs/JS-SDK.md +++ b/docs/JS-SDK.md @@ -43,7 +43,7 @@ The web package should expose: ## Goals - Ship a browser SDK that feels structurally similar to the React Native SDK. -- Reuse existing shared packages where possible, especially `@voidhash/api-spec` and `@voidhash/shared`. +- Reuse existing shared packages where possible, especially `@voidhash/generated-clients` and `@voidhash/shared`. - Make feature flags production-ready first, because the backend contract already exists. - Design analytics as a first-class SDK capability for web, even though the current React Native SDK does not yet implement an analytics transport. - Support anonymous and identified users. @@ -277,7 +277,7 @@ Analytics is a first-class goal of the web SDK, but it needs a clearer contract ### Prerequisite: API Contract Alignment -Before analytics implementation begins, the repo should define or confirm the following in `@voidhash/api-spec`: +Before analytics implementation begins, the repo should define or confirm the following in `@voidhash/generated-clients`: - the analytics ingestion endpoint path - the request payload shape @@ -301,7 +301,7 @@ This should be treated as a required upstream step. The web SDK should not inven ### Recommended Event Payload Shape -The final schema should come from `@voidhash/api-spec`, but the SDK should plan around this structure: +The final schema should come from `@voidhash/generated-clients`, but the SDK should plan around this structure: ```ts type AnalyticsEvent = { @@ -471,7 +471,7 @@ An analytics hook can stay simple in V1: ### Contract Tests -- feature flag request/response compatibility with `@voidhash/api-spec` +- feature flag request/response compatibility with `@voidhash/generated-clients` - analytics contract compatibility once defined upstream ## Example Apps and Documentation @@ -496,7 +496,7 @@ Documentation should cover: | Phase | Scope | Output | | --- | --- | --- | -| 0 | API and package alignment | Confirm `libraries/web`, confirm `@voidhash/web`, finalize analytics API contract in `@voidhash/api-spec`. | +| 0 | API and package alignment | Confirm `libraries/web`, confirm `@voidhash/web`, finalize analytics API contract in `@voidhash/generated-clients`. | | 1 | Core runtime | Create client factory, runtime layer, event bus, identity manager, cache manager, browser platform provider, fetch transport. | | 2 | Feature flags | Implement `getFeatureFlags`, caching, refetch behavior, React hook parity, tests. | | 3 | Analytics | Implement `track`, `page`, queueing, batching, retrying, page-exit flush, tests. | @@ -510,7 +510,7 @@ Documentation should cover: - Keep feature flags server-evaluated in V1. - Keep analytics manual-first in V1. - Exclude paywalls and payments completely from the initial implementation. -- Upstream any shared contract changes into `@voidhash/api-spec` before depending on them in the SDK. +- Upstream any shared contract changes into `@voidhash/generated-clients` before depending on them in the SDK. ## Open Questions @@ -527,7 +527,7 @@ Documentation should cover: 2. Port the React Native identity, cache, and event bus concepts into browser-safe modules. 3. Implement feature flags first using the existing API contract and 5-minute TTL parity. 4. Add the React provider and `useFeatureFlags` hook. -5. Finalize the analytics API contract in `@voidhash/api-spec`. +5. Finalize the analytics API contract in `@voidhash/generated-clients`. 6. Implement analytics queueing, batching, and flush behavior. 7. Add examples, tests, and publishable package metadata. diff --git a/examples/react-native-example/package.json b/examples/react-native-example/package.json index d097173aa..bfb331f4d 100644 --- a/examples/react-native-example/package.json +++ b/examples/react-native-example/package.json @@ -5,7 +5,7 @@ "main": "expo-router/entry", "scripts": { "run:android": "expo run:android", - "run:ios": "expo run:ios", + "run:ios": "expo run:ios --device", "eas:build-dev:local": "eas build --local -e development", "start": "expo start --dev-client", "prebuild": "expo prebuild", diff --git a/examples/react-native-example/tsconfig.json b/examples/react-native-example/tsconfig.json index 2d3b7bd64..79279d1b6 100644 --- a/examples/react-native-example/tsconfig.json +++ b/examples/react-native-example/tsconfig.json @@ -4,7 +4,8 @@ "compilerOptions": { "strict": true, "jsx": "react-jsx", - - "baseUrl": "." + "paths": { + "*": ["./*"] + } } } diff --git a/libraries/node/package.json b/libraries/node/package.json index cd2a6c1a3..7fa622b32 100644 --- a/libraries/node/package.json +++ b/libraries/node/package.json @@ -62,7 +62,7 @@ "test:watch": "vitest -c vitest.unit.mts" }, "dependencies": { - "@voidhash/api-spec": "workspace:*" + "@voidhash/generated-clients": "workspace:*" }, "devDependencies": { "@effect/platform": "catalog:", diff --git a/libraries/node/src/effect-client.ts b/libraries/node/src/effect-client.ts index e672b2446..d432bf97d 100644 --- a/libraries/node/src/effect-client.ts +++ b/libraries/node/src/effect-client.ts @@ -1,20 +1,19 @@ import { Effect } from "effect"; import type { VoidhashNodeClientOptions } from "./types"; -import type { PublicVoidhashNodeEffectClient } from "./internal/client-types"; import { type FilterSdkGroup, filterSdkGroup, } from "./internal/filter-sdk-group"; -import { makeGeneratedClient } from "./internal/make-generated-client"; -import { normalizeGeneratedClient } from "./internal/normalize-generated-client"; +import { + makeGeneratedClient, + type GeneratedVoidhashNodeEffectClient, +} from "./internal/make-generated-client"; export type VoidhashNodeEffectClient = - FilterSdkGroup; + FilterSdkGroup; export const createVoidhashSdk = ( options: VoidhashNodeClientOptions ): VoidhashNodeEffectClient => - filterSdkGroup( - normalizeGeneratedClient(Effect.runSync(makeGeneratedClient(options))) - ) as VoidhashNodeEffectClient; + filterSdkGroup(Effect.runSync(makeGeneratedClient(options))) as VoidhashNodeEffectClient; diff --git a/libraries/node/src/generated/grouped-client.ts b/libraries/node/src/generated/grouped-client.ts new file mode 100644 index 000000000..80d9a880a --- /dev/null +++ b/libraries/node/src/generated/grouped-client.ts @@ -0,0 +1,65 @@ +import type { VoidhashCoreClient } from "@voidhash/generated-clients"; + +export const groupCoreClient = (client: VoidhashCoreClient) => ({ + apiKeys: { + createSecretKey: (request: { payload: Parameters[0] }) => client.apiKeysCreateSecretKey(request.payload), + deleteApiKey: (request: { params: { readonly "apiKeyId": string } }) => client.apiKeysDeleteApiKey(request.params["apiKeyId"]), + getApiKeyById: (request: { params: { readonly "apiKeyId": string } }) => client.apiKeysGetApiKeyById(request.params["apiKeyId"]), + listApiKeys: () => client.apiKeysListApiKeys(), + rotateSecretKey: (request: { params: { readonly "apiKeyId": string } }) => client.apiKeysRotateSecretKey(request.params["apiKeyId"]), + }, + auth: { + session: () => client.authSession(), + }, + changesets: { + deployChangeset: (request: { payload: Parameters[0] }) => client.changesetsDeployChangeset(request.payload), + }, + customers: { + byDistinctId: (request: { params: { readonly "distinctId": string } }) => client.customersByDistinctId(request.params["distinctId"]), + createCustomer: (request: { payload: Parameters[0] }) => client.customersCreateCustomer(request.payload), + getCustomerById: (request: { params: { readonly "customerId": string } }) => client.customersGetCustomerById(request.params["customerId"]), + listCustomers: () => client.customersListCustomers(), + }, + organizations: { + createOrganization: (request: { payload: Parameters[0] }) => client.organizationsCreateOrganization(request.payload), + }, + paymentProviderConfigurations: { + listPaymentProviderConfigurations: () => client.paymentProviderConfigurationsListPaymentProviderConfigurations(), + }, + paymentProviderProducts: { + listPaymentProviderProducts: () => client.paymentProviderProductsListPaymentProviderProducts(), + }, + paywallLocations: { + listPaywallLocations: () => client.paywallLocationsListPaywallLocations(), + }, + perks: { + listPerks: () => client.perksListPerks(), + }, + productPerks: { + listProductPerksByProductId: (request: { params: { readonly "productId": string } }) => client.productPerksListProductPerksByProductId(request.params["productId"]), + }, + products: { + listProducts: () => client.productsListProducts(), + }, + projects: { + createProject: (request: { payload: Parameters[0] }) => client.projectsCreateProject(request.payload), + listProjects: (request: { params: { readonly "organizationId": string } }) => client.projectsListProjects(request.params["organizationId"]), + }, + users: { + getUser: () => client.usersGetUser(), + }, + webhooks: { + createWebhookEndpoint: (request: { payload: Parameters[0] }) => client.webhooksCreateWebhookEndpoint(request.payload), + deleteWebhookEndpoint: (request: { params: { readonly "endpointId": string } }) => client.webhooksDeleteWebhookEndpoint(request.params["endpointId"]), + getWebhookDelivery: (request: { params: { readonly "deliveryId": string } }) => client.webhooksGetWebhookDelivery(request.params["deliveryId"]), + getWebhookEndpoint: (request: { params: { readonly "endpointId": string } }) => client.webhooksGetWebhookEndpoint(request.params["endpointId"]), + listWebhookDeliveries: () => client.webhooksListWebhookDeliveries(), + listWebhookEndpoints: () => client.webhooksListWebhookEndpoints(), + retryWebhookDelivery: (request: { params: { readonly "deliveryId": string } }) => client.webhooksRetryWebhookDelivery(request.params["deliveryId"]), + rotateWebhookSecret: (request: { params: { readonly "endpointId": string } }) => client.webhooksRotateWebhookSecret(request.params["endpointId"]), + testWebhookEndpoint: (request: { params: { readonly "endpointId": string } }) => client.webhooksTestWebhookEndpoint(request.params["endpointId"]), + updateWebhookEndpoint: (request: { params: { readonly "endpointId": string }; payload: Parameters[1] }) => client.webhooksUpdateWebhookEndpoint(request.params["endpointId"], request.payload), + }, +}); + +export type GroupedVoidhashNodeEffectClient = ReturnType; diff --git a/libraries/node/src/internal/client-types.ts b/libraries/node/src/internal/client-types.ts deleted file mode 100644 index 73da656f6..000000000 --- a/libraries/node/src/internal/client-types.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { VoidhashV1Api } from "@voidhash/api-spec"; -import type { Effect } from "effect"; -import type * as Schema from "effect/Schema"; -import type * as HttpClientError from "effect/unstable/http/HttpClientError"; -import type { HttpClientResponse } from "effect/unstable/http"; -import type { HttpApiSchemaError } from "effect/unstable/httpapi/HttpApiError"; -import type { - HttpApi, - HttpApiClient, - HttpApiEndpoint, - HttpApiGroup, - HttpApiMiddleware, -} from "effect/unstable/httpapi"; -import type { Brand } from "effect/Brand"; -import type * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema"; - -type Simplify = { [Key in keyof T]: T[Key] } & {}; - -type ApiGroups = - Api extends HttpApi.HttpApi ? Groups : never; - -type EncodedClientRequest< - Params extends Schema.Top, - Query extends Schema.Top, - Payload extends Schema.Top, - Headers extends Schema.Top, - WithResponse extends boolean, -> = ( - & ([Params["Encoded"]] extends [never] - ? {} - : { readonly params: Params["Encoded"] }) - & ([Query["Encoded"]] extends [never] - ? {} - : { readonly query: Query["Encoded"] }) - & ([Headers["Encoded"]] extends [never] - ? {} - : { readonly headers: Headers["Encoded"] }) - & ([Payload["Encoded"]] extends [never] - ? {} - : Payload["Encoded"] extends infer EncodedPayload - ? EncodedPayload extends - | Brand - | Brand - ? { readonly payload: FormData } - : { readonly payload: Payload["Encoded"] } - : { readonly payload: Payload["Encoded"] }) -) extends infer Request - ? keyof Request extends never - ? void | { readonly withResponse?: WithResponse } - : Request & { readonly withResponse?: WithResponse } - : void; - -type EffectMethod = - Endpoint extends HttpApiEndpoint.HttpApiEndpoint< - infer _Name, - infer _Method, - infer _Path, - infer Params, - infer Query, - infer Payload, - infer Headers, - infer Success, - infer Error, - infer Middleware, - infer _MR - > - ? ( - request: Simplify< - EncodedClientRequest - > - ) => Effect.Effect< - WithResponse extends true - ? [Success["Type"], HttpClientResponse.HttpClientResponse] - : Success["Type"], - | Error["Type"] - | HttpApiMiddleware.Error - | HttpApiMiddleware.ClientError - | HttpApiSchemaError - | HttpClientError.HttpClientError - | Schema.SchemaError, - | Params["DecodingServices"] - | Query["DecodingServices"] - | Payload["DecodingServices"] - | Headers["DecodingServices"] - | Params["EncodingServices"] - | Query["EncodingServices"] - | Payload["EncodingServices"] - | Headers["EncodingServices"] - | Success["DecodingServices"] - | Error["DecodingServices"] - > - : never; - -type EffectTopLevelMethods = - Extract extends - HttpApiGroup.HttpApiGroup - ? Endpoints extends infer Endpoint - ? [HttpApiEndpoint.Name, EffectMethod] - : never - : never; - -type EffectClient = Simplify< - & { - readonly [Group in Extract as HttpApiGroup.Name]: - Group extends HttpApiGroup.HttpApiGroup - ? { - readonly [Endpoint in Endpoints as HttpApiEndpoint.Name]: - EffectMethod; - } - : never; - } - & { - readonly [Method in EffectTopLevelMethods as Method[0]]: Method[1]; - } ->; - -type PromiseTopLevelMethods = - Extract extends - HttpApiGroup.HttpApiGroup - ? Endpoints extends infer Endpoint - ? [HttpApiEndpoint.Name, PromiseMethod] - : never - : never; - -type PromiseMethod = - Endpoint extends HttpApiEndpoint.HttpApiEndpoint< - infer _Name, - infer _Method, - infer _Path, - infer Params, - infer Query, - infer Payload, - infer Headers, - infer Success, - infer _Error, - infer _Middleware, - infer _MR - > - ? ( - request: Simplify< - EncodedClientRequest - > - ) => Promise< - WithResponse extends true - ? [Success["Type"], HttpClientResponse.HttpClientResponse] - : Success["Type"] - > - : never; - -type PromiseClient = Simplify< - & { - readonly [Group in Extract as HttpApiGroup.Name]: - Group extends HttpApiGroup.HttpApiGroup - ? { - readonly [Endpoint in Endpoints as HttpApiEndpoint.Name]: - PromiseMethod; - } - : never; - } - & { - readonly [Method in PromiseTopLevelMethods as Method[0]]: Method[1]; - } ->; - -type PromiseClientForApi = - Api extends HttpApi.HttpApi ? PromiseClient : never; - -export type GeneratedVoidhashNodeEffectClient = HttpApiClient.ForApi; -export type PublicVoidhashNodeEffectClient = EffectClient>; - -export type GeneratedVoidhashNodeClient = PromiseClientForApi; -export type PublicVoidhashNodeClient = PromiseClientForApi; diff --git a/libraries/node/src/internal/make-generated-client.ts b/libraries/node/src/internal/make-generated-client.ts index 55de4d4fd..2a940141a 100644 --- a/libraries/node/src/internal/make-generated-client.ts +++ b/libraries/node/src/internal/make-generated-client.ts @@ -1,16 +1,21 @@ -import { VoidhashV1Api } from "@voidhash/api-spec"; +import { + make as makeCoreClient, + type VoidhashCoreClient, +} from "@voidhash/generated-clients"; import { Effect } from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest, } from "effect/unstable/http"; -import { HttpApiClient } from "effect/unstable/httpapi"; import { VoidhashNodeConfigurationError } from "../errors"; +import { + groupCoreClient, + type GroupedVoidhashNodeEffectClient, +} from "../generated/grouped-client"; import type { VoidhashNodeClientOptions } from "../types"; -import type { GeneratedVoidhashNodeEffectClient } from "./client-types"; -import { toJsonCompatibleApi } from "./json-compatible-api"; +export type GeneratedVoidhashNodeEffectClient = GroupedVoidhashNodeEffectClient; export const DEFAULT_BASE_URL = "https://api.voidhash.com"; @@ -78,24 +83,31 @@ const resolveOptions = (options: VoidhashNodeClientOptions) => { }; }; -const JsonVoidhashV1Api = toJsonCompatibleApi(VoidhashV1Api); - export const makeGeneratedClient = ( options: VoidhashNodeClientOptions ): Effect.Effect => { const resolvedOptions = resolveOptions(options); - return HttpApiClient.make(JsonVoidhashV1Api, { - baseUrl: resolvedOptions.baseUrl, - transformClient: (client) => - client.pipe( - HttpClient.mapRequest((request) => - HttpClientRequest.setHeader( - HttpClientRequest.setHeaders(request, resolvedOptions.headers), - SECRET_KEY_HEADER, - resolvedOptions.secretKey + return Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const client = makeCoreClient(httpClient as VoidhashCoreClient["httpClient"], { + transformClient: (httpClient) => + Effect.succeed( + httpClient.pipe( + HttpClient.mapRequest((request) => + HttpClientRequest.setHeader( + HttpClientRequest.setHeaders( + HttpClientRequest.prependUrl(request, resolvedOptions.baseUrl), + resolvedOptions.headers + ), + SECRET_KEY_HEADER, + resolvedOptions.secretKey + ) + ) ) - ) - ), + ), + }); + + return groupCoreClient(client); }).pipe(Effect.provide(FetchHttpClient.layer)); }; diff --git a/libraries/node/src/internal/normalize-generated-client.ts b/libraries/node/src/internal/normalize-generated-client.ts deleted file mode 100644 index bdba221fa..000000000 --- a/libraries/node/src/internal/normalize-generated-client.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { VoidhashV1Api } from "@voidhash/api-spec"; -import { Effect, Schema } from "effect"; -import { HttpApi, HttpApiEndpoint } from "effect/unstable/httpapi"; - -import type { GeneratedVoidhashNodeEffectClient } from "./client-types"; - -type RequestPartName = "headers" | "params" | "payload" | "query"; - -type EndpointNormalizers = Partial< - Record Effect.Effect> ->; - -const endpointNormalizers = new Map(); - -const endpointKey = (group: string, endpoint: string) => `${group}.${endpoint}`; - -const getPayloadSchema = (endpoint: HttpApiEndpoint.AnyWithProps) => { - const schemas = Array.from(endpoint.payload.values()).flatMap((value) => value.schemas); - - if (schemas.length === 0) { - return undefined; - } - - return schemas.length === 1 ? schemas[0] : Schema.Union(schemas); -}; - -HttpApi.reflect(VoidhashV1Api, { - onGroup: () => {}, - onEndpoint: ({ endpoint, group }) => { - const normalizers: EndpointNormalizers = {}; - - if (endpoint.params) { - normalizers.params = Schema.decodeUnknownEffect(endpoint.params); - } - - if (endpoint.query) { - normalizers.query = Schema.decodeUnknownEffect(endpoint.query); - } - - if (endpoint.headers) { - normalizers.headers = Schema.decodeUnknownEffect(endpoint.headers); - } - - const payloadSchema = getPayloadSchema(endpoint); - if (payloadSchema) { - normalizers.payload = Schema.decodeUnknownEffect(payloadSchema); - } - - endpointNormalizers.set(endpointKey(group.identifier, endpoint.name), normalizers); - }, -}); - -const normalizeRequest = ( - normalizers: EndpointNormalizers, - request: Record | undefined -) => - Effect.gen(function*() { - if (request === undefined) { - return undefined; - } - - const normalized = { - ...request, - }; - - if (normalizers.params && "params" in request) { - normalized.params = yield* normalizers.params(request.params); - } - - if (normalizers.query && "query" in request) { - normalized.query = yield* normalizers.query(request.query); - } - - if (normalizers.headers && "headers" in request) { - normalized.headers = yield* normalizers.headers(request.headers); - } - - if (normalizers.payload && "payload" in request) { - normalized.payload = yield* normalizers.payload(request.payload); - } - - return normalized; - }); - -export const normalizeGeneratedClient = ( - client: GeneratedVoidhashNodeEffectClient -): GeneratedVoidhashNodeEffectClient => - Object.fromEntries( - Object.entries(client).map(([groupName, groupValue]) => { - if (!groupValue || typeof groupValue !== "object") { - return [groupName, groupValue]; - } - - const normalizedGroup = Object.fromEntries( - Object.entries(groupValue).map(([endpointName, endpoint]) => { - if (typeof endpoint !== "function") { - return [endpointName, endpoint]; - } - - const normalizers = endpointNormalizers.get(endpointKey(groupName, endpointName)); - - if (!normalizers) { - return [endpointName, endpoint]; - } - - return [ - endpointName, - (request?: Record) => - Effect.flatMap(normalizeRequest(normalizers, request), (normalized) => - Reflect.apply( - endpoint as (request?: unknown) => Effect.Effect, - groupValue, - [normalized] - ) - ), - ]; - }) - ); - - return [groupName, normalizedGroup]; - }) - ) as GeneratedVoidhashNodeEffectClient; diff --git a/libraries/node/src/promise-client.ts b/libraries/node/src/promise-client.ts index ae71f443b..37ddfb2f2 100644 --- a/libraries/node/src/promise-client.ts +++ b/libraries/node/src/promise-client.ts @@ -4,7 +4,6 @@ import { createVoidhashSdk as createVoidhashEffectSdk, type VoidhashNodeEffectClient, } from "./effect-client"; -import type { PublicVoidhashNodeClient } from "./internal/client-types"; import type { FilterSdkGroup } from "./internal/filter-sdk-group"; import type { VoidhashNodeClientOptions } from "./types"; @@ -46,7 +45,7 @@ const promisifyClient = ( return Object.fromEntries(entries) as RuntimePromisifyClient; }; -export type VoidhashNodeClient = FilterSdkGroup; +export type VoidhashNodeClient = RuntimePromisifyClient; export const createVoidhashSdk = ( options: VoidhashNodeClientOptions diff --git a/libraries/node/tests/client.test.ts b/libraries/node/tests/client.test.ts index fbc37ad5b..1e61fec24 100644 --- a/libraries/node/tests/client.test.ts +++ b/libraries/node/tests/client.test.ts @@ -1,4 +1,3 @@ -import { ActionForbiddenError } from "@voidhash/api-spec/errors"; import { Cause, Effect, Exit, Option } from "effect"; import { afterEach, @@ -21,16 +20,16 @@ import { import { createJsonResponse, installFetchMock } from "./helpers"; const EXPECTED_GROUPS = [ - "api_keys", + "apiKeys", "auth", "changesets", "customers", "organizations", - "payment_provider_configurations", - "payment_provider_products", - "paywall_locations", + "paymentProviderConfigurations", + "paymentProviderProducts", + "paywallLocations", "perks", - "product_perks", + "productPerks", "products", "projects", "users", @@ -251,13 +250,13 @@ describe("@voidhash/node", () => { description: "Updated description", events: ["purchase.completed"], name: "Updated endpoint", - status: "active", + status: "disabled", url: "https://example.com/hooks", }, }); expect(endpoint.id).toBe("wh_123"); - expect(endpoint.createdAt).toEqual(new Date("2026-03-09T12:00:00.000Z")); + expect(endpoint.createdAt).toBe("2026-03-09T12:00:00.000Z"); expect(calls[0]?.method).toBe("PATCH"); expect(calls[0]?.url).toBe( "https://api.voidhash.test/api/v1/webhooks/endpoints/wh_123" @@ -266,13 +265,13 @@ describe("@voidhash/node", () => { description: "Updated description", events: ["purchase.completed"], name: "Updated endpoint", - status: "active", + status: "disabled", url: "https://example.com/hooks", }); expect(calls[0]?.headers["x-secret-key"]).toBe("vh_sk_test"); }); - it("supports DELETE requests with api_keys.deleteApiKey({ params })", async () => { + it("supports DELETE requests with apiKeys.deleteApiKey({ params })", async () => { const { calls } = installFetchMock(() => new Response(null, { status: 204 })); const client = createVoidhashSdk({ @@ -280,7 +279,7 @@ describe("@voidhash/node", () => { secretKey: "vh_sk_test", }); - const result = await client.api_keys.deleteApiKey({ + const result = await client.apiKeys.deleteApiKey({ params: { apiKeyId: "ak_123", }, @@ -322,9 +321,10 @@ describe("@voidhash/node", () => { it("surfaces matching failure objects through effect and promise factories", async () => { installFetchMock(() => createJsonResponse( - new ActionForbiddenError({ + { + _tag: "ActionForbiddenError", message: "Forbidden", - }), + }, 403 ) ); @@ -348,7 +348,11 @@ describe("@voidhash/node", () => { (error: unknown) => error ); - expect(promiseError).toStrictEqual(effectError); - expect(promiseError).toBeInstanceOf(ActionForbiddenError); + expect(promiseError).toMatchObject({ + _tag: (effectError as { _tag?: string })._tag, + }); + expect(promiseError).toMatchObject({ + _tag: "ActionForbiddenError", + }); }); }); diff --git a/libraries/react-native/package.json b/libraries/react-native/package.json index ce14c93fd..7e55036e2 100644 --- a/libraries/react-native/package.json +++ b/libraries/react-native/package.json @@ -68,7 +68,7 @@ "specs": "pnpm run typescript && nitro-codegen --logLevel=\"debug\"" }, "dependencies": { - "@voidhash/api-spec": "workspace:*" + "@voidhash/generated-clients": "workspace:*" }, "devDependencies": { "@types/jest": "^29.5.14", diff --git a/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts b/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts index 1bfd5b0f8..f28bf4268 100644 --- a/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts +++ b/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts @@ -1,4 +1,4 @@ -import type { SdkCustomer } from "@voidhash/api-spec"; +import type { SdkCustomer } from "@voidhash/generated-clients"; import { Effect, Layer, ManagedRuntime, pipe } from "effect"; import { CacheAdapter } from "../../core/caching/cache-adapter"; diff --git a/libraries/react-native/src/client-effect.ts b/libraries/react-native/src/client-effect.ts index 17dfdee61..fe9cf6362 100644 --- a/libraries/react-native/src/client-effect.ts +++ b/libraries/react-native/src/client-effect.ts @@ -1,7 +1,7 @@ import type { CaptureAcceptedResponse, CaptureErrorResponse, -} from "@voidhash/api-spec/event-capture"; +} from "@voidhash/generated-clients/event-capture"; import { Cause, Effect } from "effect"; import { SDK_VERSION } from "./core/constants"; diff --git a/libraries/react-native/src/core/event-bus.ts b/libraries/react-native/src/core/event-bus.ts index 0e4f12686..c25535d21 100644 --- a/libraries/react-native/src/core/event-bus.ts +++ b/libraries/react-native/src/core/event-bus.ts @@ -1,4 +1,4 @@ -import type { SdkCustomer } from "@voidhash/api-spec"; +import type { SdkCustomer } from "@voidhash/generated-clients"; import { ServiceMap } from "effect"; export interface CustomerFetchedEvent { @@ -18,7 +18,7 @@ export interface FeatureFlagsFetchedEvent { readonly flags: ReadonlyArray<{ readonly enabled: boolean; readonly key: string; - readonly payload: unknown | null; + readonly payload?: unknown | null; readonly variantKey: string | null; }>; } diff --git a/libraries/react-native/src/core/identity/customer-info-manager.ts b/libraries/react-native/src/core/identity/customer-info-manager.ts index b70ce1f6a..e425bb7e5 100644 --- a/libraries/react-native/src/core/identity/customer-info-manager.ts +++ b/libraries/react-native/src/core/identity/customer-info-manager.ts @@ -1,4 +1,4 @@ -import type { SdkCustomer } from "@voidhash/api-spec"; +import type { SdkCustomer } from "@voidhash/generated-clients"; import { Effect, Layer, ServiceMap } from "effect"; import { CacheManager } from "../caching/cache-manager"; diff --git a/libraries/react-native/src/core/networking/api-client.ts b/libraries/react-native/src/core/networking/api-client.ts index d5e2e64d0..dc260942f 100644 --- a/libraries/react-native/src/core/networking/api-client.ts +++ b/libraries/react-native/src/core/networking/api-client.ts @@ -1,18 +1,147 @@ -import { VoidhashV1Api } from "@voidhash/api-spec"; +import { + make as makeCoreClient, + type VoidhashCoreClient, + type EvaluateFeatureFlagsBody, + type SdkEvaluateFeatureFlagsParams, + type SdkFeatureFlagsResponse, + type SdkGetCustomerParams, + type SdkIdentifyBody, + type SdkResolvePaywallBody, + type SdkSyncCustomerAttributesBody, + type SdkSyncTransactionRequest, +} from "@voidhash/generated-clients"; import { Effect, Layer, ServiceMap } from "effect"; -import { HttpApiClient } from "effect/unstable/httpapi"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { SdkConfiguration } from "../sdk-configuration"; import { withHttpDebugLogging } from "./http-debug-client"; +export interface ReactNativeFeatureFlagsResponse { + readonly flags: ReadonlyArray<{ + readonly enabled: boolean; + readonly key: string; + readonly payload: unknown | null; + readonly variantKey: string | null; + }>; +} + +export interface ReactNativeSyncTransactionRequest { + readonly platform: "android" | "ios"; + readonly productId: string; + readonly purchaseDate: number; + readonly quantity: number; + readonly receipt?: string | undefined; + readonly purchaseToken?: string | undefined; + readonly transactionId: string; +} + +interface ReactNativeSdkHeaders { + readonly "x-client-bundle-id": string; + readonly "x-client-locale"?: string | undefined; + readonly "x-client-version"?: string | undefined; + readonly "x-distinct-id": string; + readonly "x-is-backgrounded": "false" | "true"; + readonly "x-is-debug-build": "false" | "true"; + readonly "x-nonce": string; + readonly "x-observer-mode": "false" | "true"; + readonly "x-platform": string; + readonly "x-platform-brand"?: string | undefined; + readonly "x-platform-device"?: string | undefined; + readonly "x-platform-flavor": "browser" | "native"; + readonly "x-platform-flavor-version"?: string | undefined; + readonly "x-platform-version"?: string | undefined; + readonly "x-preferred-locales"?: string | undefined; + readonly "x-publishable-key": string; + readonly "x-sdk": "web" | "react-native"; + readonly "x-sdk-version": string; + readonly "x-storefront"?: string | undefined; +} + +const normalizeFeatureFlagsResponse = ( + response: SdkFeatureFlagsResponse +): ReactNativeFeatureFlagsResponse => ({ + flags: response.flags.map((flag) => ({ + enabled: flag.enabled, + key: flag.key, + payload: null, + variantKey: flag.variantKey, + })), +}); + +const bindReactNativeSdkClient = (client: VoidhashCoreClient) => ({ + sdk: { + evaluateFeatureFlags: (request: { + headers: ReactNativeSdkHeaders; + payload: EvaluateFeatureFlagsBody; + }) => + Effect.map( + client.sdkEvaluateFeatureFlags({ + params: request.headers as SdkEvaluateFeatureFlagsParams, + payload: request.payload, + }), + normalizeFeatureFlagsResponse + ), + getCustomer: (request: { headers: ReactNativeSdkHeaders }) => + client.sdkGetCustomer(request.headers as SdkGetCustomerParams), + identify: (request: { + headers: ReactNativeSdkHeaders; + payload: SdkIdentifyBody; + }) => + client.sdkIdentify({ + params: request.headers as Parameters[0]["params"], + payload: request.payload, + }), + resolvePaywall: (request: { + headers: ReactNativeSdkHeaders; + payload: SdkResolvePaywallBody; + }) => + client.sdkResolvePaywall({ + params: request.headers as Parameters[0]["params"], + payload: request.payload, + }), + syncCustomerAttributes: (request: { + headers: ReactNativeSdkHeaders; + payload: SdkSyncCustomerAttributesBody; + }) => + client.sdkSyncCustomerAttributes({ + params: request.headers as Parameters[0]["params"], + payload: request.payload, + }), + syncTransaction: (request: { + headers: ReactNativeSdkHeaders; + payload: ReactNativeSyncTransactionRequest; + }) => + client.sdkSyncTransaction({ + params: request.headers as Parameters[0]["params"], + payload: request.payload as SdkSyncTransactionRequest, + }), + }, +}); + const make = Effect.gen(function* effect() { const sdkConfiguration = yield* SdkConfiguration; - return yield* HttpApiClient.make(VoidhashV1Api, { - baseUrl: sdkConfiguration.baseUrl, - transformClient: sdkConfiguration.debug - ? withHttpDebugLogging - : undefined, - }); + const httpClient = yield* HttpClient.HttpClient; + return bindReactNativeSdkClient( + makeCoreClient(httpClient as VoidhashCoreClient["httpClient"], { + transformClient: sdkConfiguration.debug + ? (client) => + Effect.succeed( + withHttpDebugLogging(client).pipe( + HttpClient.mapRequest((request) => + HttpClientRequest.prependUrl(request, sdkConfiguration.baseUrl) + ) + ) + ) + : (client) => + Effect.succeed( + client.pipe( + HttpClient.mapRequest((request) => + HttpClientRequest.prependUrl(request, sdkConfiguration.baseUrl) + ) + ) + ), + }) + ); }); export class ApiClient extends ServiceMap.Service>()("rn-voidhash/ApiClient") { diff --git a/libraries/react-native/src/core/utils/get-common-sdk-headers.ts b/libraries/react-native/src/core/utils/get-common-sdk-headers.ts index 1ccfd36ac..932e90d3e 100644 --- a/libraries/react-native/src/core/utils/get-common-sdk-headers.ts +++ b/libraries/react-native/src/core/utils/get-common-sdk-headers.ts @@ -1,4 +1,3 @@ -import type { SdkHeaders } from "@voidhash/api-spec"; import { Effect } from "effect"; import { SDK_VERSION } from "../constants"; @@ -14,8 +13,30 @@ const getNonce = () => { return cryptoObject?.randomUUID?.() ?? generateFallbackNonce(); }; +interface ReactNativeSdkHeaders { + readonly "x-client-bundle-id": string; + readonly "x-client-locale"?: string | undefined; + readonly "x-client-version"?: string | undefined; + readonly "x-distinct-id": string; + readonly "x-is-backgrounded": "false" | "true"; + readonly "x-is-debug-build": "false" | "true"; + readonly "x-nonce": string; + readonly "x-observer-mode": "false" | "true"; + readonly "x-platform": string; + readonly "x-platform-brand"?: string | undefined; + readonly "x-platform-device"?: string | undefined; + readonly "x-platform-flavor": "browser" | "native"; + readonly "x-platform-flavor-version"?: string | undefined; + readonly "x-platform-version"?: string | undefined; + readonly "x-preferred-locales"?: string | undefined; + readonly "x-publishable-key": string; + readonly "x-sdk": "web" | "react-native"; + readonly "x-sdk-version": string; + readonly "x-storefront"?: string | undefined; +} + export const getCommonSdkHeaders = (): Effect.Effect< - Omit, + Omit, never, PlatformProvider | SdkConfiguration | IdentityManager > => diff --git a/libraries/react-native/src/react/hooks/use-customer.ts b/libraries/react-native/src/react/hooks/use-customer.ts index 2d88465aa..5118a2479 100644 --- a/libraries/react-native/src/react/hooks/use-customer.ts +++ b/libraries/react-native/src/react/hooks/use-customer.ts @@ -1,4 +1,4 @@ -import type { SdkCustomer } from "@voidhash/api-spec"; +import type { SdkCustomer } from "@voidhash/generated-clients"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import type { VoidhashClient } from "../../client"; diff --git a/libraries/web/package.json b/libraries/web/package.json index 39d3eb042..789fcfbf5 100644 --- a/libraries/web/package.json +++ b/libraries/web/package.json @@ -63,7 +63,7 @@ "test:watch": "vitest -c vitest.unit.mts" }, "dependencies": { - "@voidhash/api-spec": "workspace:*" + "@voidhash/generated-clients": "workspace:*" }, "devDependencies": { "@types/react": "catalog:", diff --git a/libraries/web/src/core/analytics/analytics-context.ts b/libraries/web/src/core/analytics/analytics-context.ts index 6ab6d4d63..1ddebdb6f 100644 --- a/libraries/web/src/core/analytics/analytics-context.ts +++ b/libraries/web/src/core/analytics/analytics-context.ts @@ -1,7 +1,7 @@ import type { EventContextField, EventPropertiesField, -} from "@voidhash/api-spec/event-capture"; +} from "@voidhash/generated-clients/event-capture"; import type { VoidhashTrackOptions } from "../../types"; import { BrowserPlatformProvider } from "../platform/browser-platform-provider"; @@ -65,7 +65,7 @@ export const createAnalyticsEvent = ( distinct_id: distinctId, event: eventName, properties: normalizeAnalyticsRecord(properties ?? {}), - timestamp: options?.timestamp ? new Date(options.timestamp) : new Date(), + timestamp: options?.timestamp ?? new Date().toISOString(), session_id: options?.sessionId, uuid: options?.eventId ?? createEventId(platform), }); diff --git a/libraries/web/src/core/analytics/analytics-service.ts b/libraries/web/src/core/analytics/analytics-service.ts index dfbff832c..02a1ceae2 100644 --- a/libraries/web/src/core/analytics/analytics-service.ts +++ b/libraries/web/src/core/analytics/analytics-service.ts @@ -1,5 +1,5 @@ -import { Effect, Layer, Schema, ServiceMap } from "effect"; -import { CaptureAcceptedResponse } from "@voidhash/api-spec/event-capture"; +import { Effect, Layer, ServiceMap } from "effect"; +import type { CaptureAcceptedResponse } from "@voidhash/generated-clients/event-capture"; import type { AnalyticsFlushResult } from "../../types"; import { CacheManager } from "../caching/cache-manager"; @@ -207,10 +207,12 @@ const make = Effect.gen(function* effect() { if (result._tag === "Success") { if (result.value.status === 202) { - const decodedResponse = yield* Effect.exit( - Schema.decodeUnknownEffect(CaptureAcceptedResponse)(result.value.data) - ); - if (decodedResponse._tag !== "Success") { + if ( + !result.value.data || + typeof result.value.data !== "object" || + typeof (result.value.data as CaptureAcceptedResponse).accepted !== "number" || + typeof (result.value.data as CaptureAcceptedResponse).rejected !== "number" + ) { postponeEvents( ids, Date.now() + getBackoffMs((batch[0]?.attempts ?? 0) + 1) @@ -221,7 +223,7 @@ const make = Effect.gen(function* effect() { dropEvents(ids); yield* persistQueue(); - const response = decodedResponse.value; + const response = result.value.data as CaptureAcceptedResponse; const flushResult: AnalyticsFlushResult = { accepted: response.accepted, rejected: response.rejected, diff --git a/libraries/web/src/core/analytics/contracts.ts b/libraries/web/src/core/analytics/contracts.ts index f799958e7..10d20adca 100644 --- a/libraries/web/src/core/analytics/contracts.ts +++ b/libraries/web/src/core/analytics/contracts.ts @@ -1,12 +1,12 @@ -import { +import type { CaptureBatchRequest, CaptureEvent, -} from "@voidhash/api-spec/event-capture"; +} from "@voidhash/generated-clients/event-capture"; import type { AnalyticsFlushResult } from "../../types"; -export type AnalyticsRequestEvent = typeof CaptureEvent.Type; -export type AnalyticsBatchRequest = typeof CaptureBatchRequest.Type; +export type AnalyticsRequestEvent = CaptureEvent; +export type AnalyticsBatchRequest = CaptureBatchRequest; export interface QueuedAnalyticsEvent { readonly attempts: number; diff --git a/libraries/web/src/core/networking/api-client.ts b/libraries/web/src/core/networking/api-client.ts index 556300dca..4d26174bc 100644 --- a/libraries/web/src/core/networking/api-client.ts +++ b/libraries/web/src/core/networking/api-client.ts @@ -1,16 +1,118 @@ -import { VoidhashV1Api } from "@voidhash/api-spec"; +import { + make as makeCoreClient, + type VoidhashCoreClient, + type EvaluateFeatureFlagsBody, + type SdkEvaluateFeatureFlagsParams, + type SdkFeatureFlagsResponse, + type SdkGetCustomerParams, + type SdkIdentifyBody, + type SdkResolvePaywallBody, + type SdkSyncCustomerAttributesBody, +} from "@voidhash/generated-clients"; import { Effect, Layer, ServiceMap } from "effect"; -import { HttpApiClient } from "effect/unstable/httpapi"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import { SdkConfiguration } from "../sdk-configuration"; -import { normalizeGeneratedClient } from "./normalize-generated-client"; + +interface WebFeatureFlagsResponse { + readonly flags: ReadonlyArray<{ + readonly enabled: boolean; + readonly key: string; + readonly payload: unknown | null; + readonly variantKey: string | null; + }>; +} + +interface WebSdkHeaders { + readonly "x-client-bundle-id": string; + readonly "x-client-locale"?: string | undefined; + readonly "x-client-version"?: string | undefined; + readonly "x-distinct-id": string; + readonly "x-is-backgrounded": "false" | "true"; + readonly "x-is-debug-build": "false" | "true"; + readonly "x-nonce": string; + readonly "x-observer-mode": "false" | "true"; + readonly "x-platform": string; + readonly "x-platform-brand"?: string | undefined; + readonly "x-platform-device"?: string | undefined; + readonly "x-platform-flavor": "browser" | "native"; + readonly "x-platform-flavor-version"?: string | undefined; + readonly "x-platform-version"?: string | undefined; + readonly "x-preferred-locales"?: string | undefined; + readonly "x-publishable-key": string; + readonly "x-sdk": "web" | "react-native"; + readonly "x-sdk-version": string; + readonly "x-storefront"?: string | undefined; +} + +const normalizeFeatureFlagsResponse = ( + response: SdkFeatureFlagsResponse +): WebFeatureFlagsResponse => ({ + flags: response.flags.map((flag) => ({ + enabled: flag.enabled, + key: flag.key, + payload: null, + variantKey: flag.variantKey, + })), +}); + +const bindWebSdkClient = (client: VoidhashCoreClient) => ({ + sdk: { + evaluateFeatureFlags: (request: { + headers: WebSdkHeaders; + payload: EvaluateFeatureFlagsBody; + }) => + Effect.map( + client.sdkEvaluateFeatureFlags({ + params: request.headers as SdkEvaluateFeatureFlagsParams, + payload: request.payload, + }), + normalizeFeatureFlagsResponse + ), + getCustomer: (request: { headers: WebSdkHeaders }) => + client.sdkGetCustomer(request.headers as SdkGetCustomerParams), + identify: (request: { + headers: WebSdkHeaders; + payload: SdkIdentifyBody; + }) => + client.sdkIdentify({ + params: request.headers as Parameters[0]["params"], + payload: request.payload, + }), + resolvePaywall: (request: { + headers: WebSdkHeaders; + payload: SdkResolvePaywallBody; + }) => + client.sdkResolvePaywall({ + params: request.headers as Parameters[0]["params"], + payload: request.payload, + }), + syncCustomerAttributes: (request: { + headers: WebSdkHeaders; + payload: SdkSyncCustomerAttributesBody; + }) => + client.sdkSyncCustomerAttributes({ + params: request.headers as Parameters[0]["params"], + payload: request.payload, + }), + }, +}); const make = Effect.gen(function* effect() { const config = yield* SdkConfiguration; - const rawClient = yield* HttpApiClient.make(VoidhashV1Api, { - baseUrl: config.baseUrl, - }); - return normalizeGeneratedClient(rawClient); + const httpClient = yield* HttpClient.HttpClient; + return bindWebSdkClient( + makeCoreClient(httpClient as VoidhashCoreClient["httpClient"], { + transformClient: (client) => + Effect.succeed( + client.pipe( + HttpClient.mapRequest((request) => + HttpClientRequest.prependUrl(request, config.baseUrl) + ) + ) + ), + }) + ); }); export class ApiClient extends ServiceMap.Service< diff --git a/libraries/web/src/core/networking/event-capture-api-client.ts b/libraries/web/src/core/networking/event-capture-api-client.ts index a68b77fda..f6741d2e4 100644 --- a/libraries/web/src/core/networking/event-capture-api-client.ts +++ b/libraries/web/src/core/networking/event-capture-api-client.ts @@ -1,17 +1,25 @@ -import { EventCaptureApi } from "@voidhash/api-spec/event-capture"; +import { + make as makeEventCaptureClient, + type VoidhashEventCaptureClient, +} from "@voidhash/generated-clients/event-capture"; import { Effect, Layer, ServiceMap } from "effect"; -import { HttpApiClient } from "effect/unstable/httpapi"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import { SdkConfiguration } from "../sdk-configuration"; -import { normalizeGeneratedClient } from "./normalize-generated-client"; -import { toJsonCompatibleApi } from "./json-compatible-api"; const make = Effect.gen(function* effect() { const config = yield* SdkConfiguration; - const rawClient = yield* HttpApiClient.make(toJsonCompatibleApi(EventCaptureApi), { - baseUrl: config.analytics.baseUrl, + const httpClient = yield* HttpClient.HttpClient; + return makeEventCaptureClient(httpClient as VoidhashEventCaptureClient["httpClient"], { + transformClient: (client) => + Effect.succeed( + client.pipe( + HttpClient.mapRequest((request) => + HttpClientRequest.prependUrl(request, config.analytics.baseUrl) + ) + ) + ), }); - return normalizeGeneratedClient(rawClient); }); export class EventCaptureApiClient extends ServiceMap.Service< diff --git a/libraries/web/src/core/networking/normalize-generated-client.ts b/libraries/web/src/core/networking/normalize-generated-client.ts deleted file mode 100644 index f574065ff..000000000 --- a/libraries/web/src/core/networking/normalize-generated-client.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { VoidhashV1Api } from "@voidhash/api-spec"; -import { Effect, Schema } from "effect"; -import { HttpApi, HttpApiEndpoint } from "effect/unstable/httpapi"; - -type RequestPartName = "headers" | "params" | "payload" | "query"; - -type EndpointNormalizers = Partial< - Record Effect.Effect> ->; - -const endpointNormalizers = new Map(); - -const endpointKey = (group: string, endpoint: string) => `${group}.${endpoint}`; - -const getPayloadSchema = (endpoint: HttpApiEndpoint.AnyWithProps) => { - const schemas = Array.from(endpoint.payload.values()).flatMap((value) => value.schemas); - - if (schemas.length === 0) { - return undefined; - } - - return schemas.length === 1 ? schemas[0] : Schema.Union(schemas); -}; - -HttpApi.reflect(VoidhashV1Api, { - onGroup: () => {}, - onEndpoint: ({ endpoint, group }) => { - const normalizers: EndpointNormalizers = {}; - - if (endpoint.params) { - normalizers.params = Schema.decodeUnknownEffect(endpoint.params); - } - - if (endpoint.query) { - normalizers.query = Schema.decodeUnknownEffect(endpoint.query); - } - - if (endpoint.headers) { - normalizers.headers = Schema.decodeUnknownEffect(endpoint.headers); - } - - const payloadSchema = getPayloadSchema(endpoint); - if (payloadSchema) { - normalizers.payload = Schema.decodeUnknownEffect(payloadSchema); - } - - endpointNormalizers.set(endpointKey(group.identifier, endpoint.name), normalizers); - }, -}); - -const normalizeRequest = ( - normalizers: EndpointNormalizers, - request: Record | undefined -) => - Effect.gen(function* normalizeRequestEffect() { - if (request === undefined) { - return undefined; - } - - const normalized = { - ...request, - }; - - if (normalizers.params && "params" in request) { - normalized.params = yield* normalizers.params(request.params); - } - - if (normalizers.query && "query" in request) { - normalized.query = yield* normalizers.query(request.query); - } - - if (normalizers.headers && "headers" in request) { - normalized.headers = yield* normalizers.headers(request.headers); - } - - if (normalizers.payload && "payload" in request) { - normalized.payload = yield* normalizers.payload(request.payload); - } - - return normalized; - }); - -// biome-ignore lint/suspicious/noExplicitAny: Generic client normalization requires dynamic typing -export const normalizeGeneratedClient = >( - client: T -): T => - Object.fromEntries( - Object.entries(client).map(([groupName, groupValue]) => { - if (!groupValue || typeof groupValue !== "object") { - return [groupName, groupValue]; - } - - const normalizedGroup = Object.fromEntries( - Object.entries(groupValue).map(([endpointName, endpoint]) => { - if (typeof endpoint !== "function") { - return [endpointName, endpoint]; - } - - const normalizers = endpointNormalizers.get(endpointKey(groupName, endpointName)); - - if (!normalizers) { - return [endpointName, endpoint]; - } - - return [ - endpointName, - (request?: Record) => - Effect.flatMap(normalizeRequest(normalizers, request), (normalized) => - Reflect.apply( - endpoint as (request?: unknown) => Effect.Effect, - groupValue, - [normalized] - ) - ), - ]; - }) - ); - - return [groupName, normalizedGroup]; - }) - ) as T; diff --git a/package.json b/package.json index 9bc9c8ff0..11307c757 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "lint": "biome check .", "lint:fix": "biome check . --apply", "bump": "bumpp", + "openapi:generate:preview": "node ./scripts/generate-openapi-clients.mjs preview", + "openapi:generate:production": "node ./scripts/generate-openapi-clients.mjs production", "test": "turbo test", "typecheck": "turbo typecheck", "lines-of-code": "npx cloc . --exclude-dir=.next,node_modules,.vercel,.turbo,.git,meta,.github,.expo,build,.expo-shared,nitrogen --vcs git --not-match-f=pnpm-lock.yaml,meta.json --exclude-ext=yaml,md" diff --git a/packages/api-spec/LICENSE.md b/packages/api-spec/LICENSE.md deleted file mode 100644 index cc433146c..000000000 --- a/packages/api-spec/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Voidhash s.r.o. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/api-spec/README.md b/packages/api-spec/README.md deleted file mode 100644 index 777ac13b5..000000000 --- a/packages/api-spec/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# @voidhash/api-spec - -`@voidhash/api-spec` contains the shared Voidhash API contracts (routes, schemas, auth headers, and API error types). - -## Installation - -Install stable: - -```bash -pnpm add @voidhash/api-spec@latest -``` - -Install a specific version: - -```bash -pnpm add @voidhash/api-spec@0.0.1-alpha.1 -``` - -Install latest canary: - -```bash -pnpm add @voidhash/api-spec@canary -``` - -## Runtime Expectations - -- ESM package (`"type": "module"`). -- Exports TypeScript source files from `src`. -- Consumers should compile TS dependencies in their build pipeline. -- Peer dependencies are required from the consuming app: - - `effect` - - `@effect/platform` - -## Release Channels - -- `latest`: stable releases published from `main`. -- `canary`: preview builds published from pushes to `preview`. - -Recommended flow: - -1. Push `api-spec` changes to `preview` to publish a canary build. -2. Validate in `voidhash-mono` against `@voidhash/api-spec@canary` (or the exact canary version). -3. Merge to `main` and publish `latest`. diff --git a/packages/api-spec/package.json b/packages/api-spec/package.json deleted file mode 100644 index ba4d3fb97..000000000 --- a/packages/api-spec/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@voidhash/api-spec", - "version": "0.0.1-alpha.1", - "private": false, - "repository": { - "type": "git", - "url": "https://github.com/voidhashcom/voidhash", - "directory": "packages/api-spec" - }, - "files": ["src", "LICENSE.md", "README.md", "package.json"], - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - }, - "type": "module", - "exports": { - ".": "./src/index.ts", - "./event-capture": "./src/event-capture.ts", - "./errors": "./src/errors/index.ts" - }, - "scripts": { - "typecheck": "tsgo --noEmit" - }, - "dependencies": {}, - "devDependencies": { - "@voidhash/tsconfig": "workspace:*", - "typescript": "5.6.3" - }, - "peerDependencies": { - "effect": "catalog:" - } -} diff --git a/packages/api-spec/src/api.ts b/packages/api-spec/src/api.ts deleted file mode 100644 index db1caeb07..000000000 --- a/packages/api-spec/src/api.ts +++ /dev/null @@ -1,398 +0,0 @@ -import { Schema } from "effect"; -import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; - -import { - ActionForbiddenError, - ApiKeyNotFoundError, - ApiKeyServiceError, - AuthenticationError, - ChangesetDeploymentServiceError, - CustomerInvalidAnonymousIdError, - CustomerNotFoundError, - CustomerServiceError, - OrganizationServiceError, - PaymentProviderConfigurationServiceError, - PaymentProviderProductServiceError, - PaywallLocationServiceError, - PerkServiceError, - ProductPerkServiceError, - ProductPerkValidationError, - ProductServiceError, - ProjectServiceError, - SdkCustomerAlreadyIdentifiedError, - SdkCustomerNotFoundError, - SdkServiceError, - SdkValidationError, - UserServiceError, - WebhookDeliveryNotFoundError, - WebhookEndpointNotFoundError, - WebhookServiceError, - WebhookValidationError, -} from "./errors"; -import { AuthMiddleware } from "./middlewares"; -import { - ApiKey, - ApiKeyWithRawKey, - CreateCustomerBody, - CreateOrganizationBody, - CreateProjectBody, - CreateSecretKeyBody, - CreateWebhookEndpointBody, - Customer, - DeployChangesetBody, - DeployChangesetResponse, - Organization, - PaymentProviderConfiguration, - PaymentProviderProduct, - PaywallLocation, - Perk, - Product, - ProductPerk, - Project, - EvaluateFeatureFlagsBody, - SdkCustomer, - SdkFeatureFlagsResponse, - SdkHeaders, - SdkIdentifyBody, - SdkResolvePaywallBody, - SdkResolvedPaywall, - SdkSyncCustomerAttributesBody, - SdkSyncTransactionBody, - SdkSyncTransactionResponse, - Session, - UpdateWebhookEndpointBody, - User, - WebhookDelivery, - WebhookDeliveryWithAttempts, - WebhookEndpoint, -} from "./schema"; - -export const VoidhashV1Api = HttpApi.make("VoidhashV1Api") - .add( - HttpApiGroup.make("auth") - .add( - HttpApiEndpoint.get("session", "/session", { - success: Session, - error: [ActionForbiddenError], - }).middleware(AuthMiddleware) - ) - .prefix("/auth") - ) - .add( - HttpApiGroup.make("api_keys") - .add( - HttpApiEndpoint.post("createSecretKey", "/", { - success: ApiKeyWithRawKey, - payload: CreateSecretKeyBody, - error: [ApiKeyServiceError, ActionForbiddenError], - }) - ) - .add( - HttpApiEndpoint.get("listApiKeys", "/", { - success: Schema.Array(ApiKey), - error: [ApiKeyServiceError, ActionForbiddenError], - }) - ) - .add( - HttpApiEndpoint.get("getApiKeyById", "/:apiKeyId", { - params: { apiKeyId: Schema.String }, - success: ApiKey, - error: [ApiKeyServiceError, ApiKeyNotFoundError, ActionForbiddenError], - }) - ) - .add( - HttpApiEndpoint.post("rotateSecretKey", "/:apiKeyId/rotate", { - params: { apiKeyId: Schema.String }, - success: ApiKeyWithRawKey, - error: [ApiKeyServiceError, ApiKeyNotFoundError, ActionForbiddenError], - }) - ) - .add( - HttpApiEndpoint.delete("deleteApiKey", "/:apiKeyId", { - params: { apiKeyId: Schema.String }, - error: [ApiKeyServiceError, ApiKeyNotFoundError, ActionForbiddenError], - }) - ) - .middleware(AuthMiddleware) - .prefix("/api-keys") - ) - .add( - HttpApiGroup.make("customers") - .add( - HttpApiEndpoint.post("createCustomer", "/", { - payload: CreateCustomerBody, - success: Customer, - error: [ActionForbiddenError, CustomerInvalidAnonymousIdError, CustomerServiceError], - }) - ) - .add( - HttpApiEndpoint.get("listCustomers", "/", { - success: Schema.Array(Customer), - error: [ActionForbiddenError, CustomerServiceError], - }) - ) - .add( - HttpApiEndpoint.get("getCustomerById", "/:customerId", { - params: { customerId: Schema.String }, - success: Customer, - error: [ActionForbiddenError, CustomerNotFoundError, CustomerServiceError], - }) - ) - .add( - HttpApiEndpoint.get("byDistinctId", "/by-distinct-id/:distinctId", { - params: { distinctId: Schema.String }, - success: Customer, - error: [ActionForbiddenError, CustomerNotFoundError, CustomerServiceError], - }) - ) - .middleware(AuthMiddleware) - .prefix("/customers") - ) - .add( - HttpApiGroup.make("organizations") - .add( - HttpApiEndpoint.post("createOrganization", "/", { - payload: CreateOrganizationBody, - success: Organization, - error: [OrganizationServiceError], - }) - ) - .middleware(AuthMiddleware) - .prefix("/organizations") - ) - .add( - HttpApiGroup.make("perks") - .add( - HttpApiEndpoint.get("listPerks", "/", { - success: Schema.Array(Perk), - error: [ActionForbiddenError, PerkServiceError], - }) - ) - .middleware(AuthMiddleware) - .prefix("/perks") - ) - .add( - HttpApiGroup.make("paywall_locations") - .add( - HttpApiEndpoint.get("listPaywallLocations", "/", { - success: Schema.Array(PaywallLocation), - error: [ActionForbiddenError, PaywallLocationServiceError], - }) - ) - .middleware(AuthMiddleware) - .prefix("/paywall-locations") - ) - .add( - HttpApiGroup.make("projects") - .add( - HttpApiEndpoint.post("createProject", "/", { - payload: CreateProjectBody, - success: Project, - error: [ActionForbiddenError, AuthenticationError, ProjectServiceError], - }) - ) - .add( - HttpApiEndpoint.get("listProjects", "/:organizationId", { - params: { organizationId: Schema.String }, - success: Schema.Array(Project), - error: [ActionForbiddenError, ProjectServiceError], - }) - ) - .middleware(AuthMiddleware) - .prefix("/projects") - ) - .add( - HttpApiGroup.make("products") - .add( - HttpApiEndpoint.get("listProducts", "/", { - success: Schema.Array(Product), - error: [ActionForbiddenError, ProductServiceError], - }) - ) - .middleware(AuthMiddleware) - .prefix("/products") - ) - .add( - HttpApiGroup.make("product_perks") - .add( - HttpApiEndpoint.get("listProductPerksByProductId", "/by-product-id/:productId", { - params: { productId: Schema.String }, - success: Schema.Array(ProductPerk), - error: [ActionForbiddenError, ProductPerkServiceError, ProductPerkValidationError], - }) - ) - .middleware(AuthMiddleware) - .prefix("/product-perks") - ) - .add( - HttpApiGroup.make("sdk") - .add( - HttpApiEndpoint.get("getCustomer", "/get-customer", { - success: SdkCustomer, - headers: SdkHeaders, - error: [AuthenticationError, SdkServiceError, SdkCustomerNotFoundError, SdkValidationError], - }) - ) - .add( - HttpApiEndpoint.post("identify", "/identify", { - payload: SdkIdentifyBody, - success: SdkCustomer, - headers: SdkHeaders, - error: [AuthenticationError, SdkServiceError, SdkValidationError, SdkCustomerAlreadyIdentifiedError], - }) - ) - .add( - HttpApiEndpoint.post("syncCustomerAttributes", "/sync-customer-attributes", { - payload: SdkSyncCustomerAttributesBody, - success: SdkCustomer, - headers: SdkHeaders, - error: [AuthenticationError, SdkServiceError, SdkValidationError], - }) - ) - .add( - HttpApiEndpoint.post("syncTransaction", "/sync-transaction", { - payload: SdkSyncTransactionBody, - success: SdkSyncTransactionResponse, - headers: SdkHeaders, - error: [AuthenticationError, SdkServiceError, SdkValidationError], - }) - ) - .add( - HttpApiEndpoint.post("evaluateFeatureFlags", "/evaluate-flags", { - payload: EvaluateFeatureFlagsBody, - success: SdkFeatureFlagsResponse, - headers: SdkHeaders, - error: [AuthenticationError, SdkServiceError], - }) - ) - .add( - HttpApiEndpoint.post("resolvePaywall", "/resolve-paywall", { - payload: SdkResolvePaywallBody, - success: Schema.NullOr(SdkResolvedPaywall), - headers: SdkHeaders, - error: [AuthenticationError, SdkServiceError, SdkValidationError], - }) - ) - .middleware(AuthMiddleware) - .prefix("/sdk") - ) - .add( - HttpApiGroup.make("users") - .add( - HttpApiEndpoint.get("getUser", "/current", { - success: User, - error: [AuthenticationError, UserServiceError], - }) - ) - .middleware(AuthMiddleware) - .prefix("/users") - ) - .add( - HttpApiGroup.make("payment_provider_configurations") - .add( - HttpApiEndpoint.get("listPaymentProviderConfigurations", "/", { - success: Schema.Array(PaymentProviderConfiguration), - error: [ActionForbiddenError, PaymentProviderConfigurationServiceError], - }) - ) - .middleware(AuthMiddleware) - .prefix("/payment-provider-configurations") - ) - .add( - HttpApiGroup.make("payment_provider_products") - .add( - HttpApiEndpoint.get("listPaymentProviderProducts", "/", { - success: Schema.Array(PaymentProviderProduct), - error: [ActionForbiddenError, PaymentProviderProductServiceError], - }) - ) - .middleware(AuthMiddleware) - .prefix("/payment-provider-products") - ) - .add( - HttpApiGroup.make("changesets") - .add( - HttpApiEndpoint.post("deployChangeset", "/deploy", { - payload: DeployChangesetBody, - success: DeployChangesetResponse, - error: [AuthenticationError, ActionForbiddenError, ChangesetDeploymentServiceError], - }) - ) - .middleware(AuthMiddleware) - .prefix("/changesets") - ) - .add( - HttpApiGroup.make("webhooks") - .add( - HttpApiEndpoint.post("createWebhookEndpoint", "/endpoints", { - payload: CreateWebhookEndpointBody, - success: WebhookEndpoint, - error: [ActionForbiddenError, WebhookValidationError, WebhookServiceError], - }) - ) - .add( - HttpApiEndpoint.get("listWebhookEndpoints", "/endpoints", { - success: Schema.Array(WebhookEndpoint), - error: [ActionForbiddenError, WebhookServiceError], - }) - ) - .add( - HttpApiEndpoint.get("getWebhookEndpoint", "/endpoints/:endpointId", { - params: { endpointId: Schema.String }, - success: WebhookEndpoint, - error: [ActionForbiddenError, WebhookEndpointNotFoundError, WebhookServiceError], - }) - ) - .add( - HttpApiEndpoint.patch("updateWebhookEndpoint", "/endpoints/:endpointId", { - params: { endpointId: Schema.String }, - payload: UpdateWebhookEndpointBody, - success: WebhookEndpoint, - error: [ActionForbiddenError, WebhookEndpointNotFoundError, WebhookValidationError, WebhookServiceError], - }) - ) - .add( - HttpApiEndpoint.delete("deleteWebhookEndpoint", "/endpoints/:endpointId", { - params: { endpointId: Schema.String }, - error: [ActionForbiddenError, WebhookEndpointNotFoundError, WebhookServiceError], - }) - ) - .add( - HttpApiEndpoint.post("rotateWebhookSecret", "/endpoints/:endpointId/rotate-secret", { - params: { endpointId: Schema.String }, - success: WebhookEndpoint, - error: [ActionForbiddenError, WebhookEndpointNotFoundError, WebhookServiceError], - }) - ) - .add( - HttpApiEndpoint.post("testWebhookEndpoint", "/endpoints/:endpointId/test", { - params: { endpointId: Schema.String }, - success: WebhookDelivery, - error: [ActionForbiddenError, WebhookEndpointNotFoundError, WebhookServiceError], - }) - ) - .add( - HttpApiEndpoint.get("listWebhookDeliveries", "/deliveries", { - success: Schema.Array(WebhookDelivery), - error: [ActionForbiddenError, WebhookServiceError], - }) - ) - .add( - HttpApiEndpoint.get("getWebhookDelivery", "/deliveries/:deliveryId", { - params: { deliveryId: Schema.String }, - success: WebhookDeliveryWithAttempts, - error: [ActionForbiddenError, WebhookDeliveryNotFoundError, WebhookServiceError], - }) - ) - .add( - HttpApiEndpoint.post("retryWebhookDelivery", "/deliveries/:deliveryId/retry", { - params: { deliveryId: Schema.String }, - success: WebhookDelivery, - error: [ActionForbiddenError, WebhookDeliveryNotFoundError, WebhookValidationError, WebhookServiceError], - }) - ) - .middleware(AuthMiddleware) - .prefix("/webhooks") - ) - - .prefix("/api/v1"); diff --git a/packages/api-spec/src/auth.ts b/packages/api-spec/src/auth.ts deleted file mode 100644 index 18c58878f..000000000 --- a/packages/api-spec/src/auth.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Schema, ServiceMap } from "effect"; - -// ============================================================================ -// Session Sub-Schemas -// ============================================================================ - -export const ApiSessionOrganizationSchema = Schema.Struct({ - id: Schema.String, - name: Schema.String, - permissions: Schema.Array(Schema.String), - slug: Schema.String, -}); - -export const ApiSessionProjectSchema = Schema.Struct({ - id: Schema.String, - name: Schema.String, - organizationId: Schema.String, - permissions: Schema.Array(Schema.String), - slug: Schema.String, -}); - -const ApiSessionOrganizationsSchema = Schema.Array(ApiSessionOrganizationSchema); -const ApiSessionProjectsSchema = Schema.Array(ApiSessionProjectSchema); - -export const ApiSessionCustomerSchema = Schema.Struct({ - distinctId: Schema.String, -}); - -export const ApiSessionUserSchema = Schema.Struct({ - createdAt: Schema.Date, - email: Schema.String, - emailVerified: Schema.Boolean, - id: Schema.String, - image: Schema.NullOr(Schema.String), - name: Schema.String, - updatedAt: Schema.Date, -}); - -// ============================================================================ -// Session Type Schemas -// ============================================================================ - -export const ApiUserSessionSchema = Schema.Struct({ - cookie: Schema.NullOr(Schema.String), - customer: Schema.Null, - method: Schema.Literal("user"), - name: Schema.String, - organizations: ApiSessionOrganizationsSchema, - projects: ApiSessionProjectsSchema, - user: ApiSessionUserSchema, -}); - -export const ApiSecretKeySessionSchema = Schema.Struct({ - cookie: Schema.Null, - customer: Schema.Null, - method: Schema.Literal("secret-key"), - name: Schema.String, - organizations: ApiSessionOrganizationsSchema, - projects: ApiSessionProjectsSchema, - user: Schema.Null, -}); - -export const ApiPublishableKeySessionSchema = Schema.Struct({ - cookie: Schema.Null, - customer: ApiSessionCustomerSchema, - method: Schema.Literal("publishable-key"), - name: Schema.String, - organizations: ApiSessionOrganizationsSchema, - projects: ApiSessionProjectsSchema, - user: Schema.Null, -}); - -// ============================================================================ -// Combined Auth Session Schema -// ============================================================================ - -export const ApiAuthSessionSchema = Schema.Union([ - ApiUserSessionSchema, - ApiSecretKeySessionSchema, - ApiPublishableKeySessionSchema, -]); - -// ============================================================================ -// Type Exports -// ============================================================================ - -export type ApiUserSession = typeof ApiUserSessionSchema.Type; -export type ApiSecretKeySession = typeof ApiSecretKeySessionSchema.Type; -export type ApiPublishableKeySession = typeof ApiPublishableKeySessionSchema.Type; -export type AnyApiAuthSession = - | ApiUserSession - | ApiSecretKeySession - | ApiPublishableKeySession; - -// ============================================================================ -// Context Tag -// ============================================================================ - -export class ApiAuthSession extends ServiceMap.Service()("api-spec/auth/ApiAuthSession") {} diff --git a/packages/api-spec/src/changeset.ts b/packages/api-spec/src/changeset.ts deleted file mode 100644 index 28972b21e..000000000 --- a/packages/api-spec/src/changeset.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { Schema } from "effect"; - -// ============================================================================ -// Paywall Location Changes -// ============================================================================ - -export const PaywallLocationCreateChangeSchema = Schema.Struct({ - changeType: Schema.Literal("create-paywall-location"), - key: Schema.String, - payload: Schema.Struct({ - description: Schema.optional(Schema.NullOr(Schema.String)), - name: Schema.String, - slug: Schema.String, - }), -}); - -export const PaywallLocationUpdateChangeSchema = Schema.Struct({ - changeType: Schema.Literal("update-paywall-location"), - key: Schema.String, - payload: Schema.Struct({ - description: Schema.optional(Schema.NullOr(Schema.String)), - name: Schema.String, - slug: Schema.String, - }), -}); - -export const PaywallLocationArchiveChangeSchema = Schema.Struct({ - changeType: Schema.Literal("archive-paywall-location"), - key: Schema.String, - payload: Schema.Struct({ - slug: Schema.String, - }), -}); - -// ============================================================================ -// Perk Changes -// ============================================================================ - -export const PerkCreateChangeSchema = Schema.Struct({ - changeType: Schema.Literal("create-perk"), - key: Schema.String, - payload: Schema.Struct({ - name: Schema.String, - slug: Schema.String, - }), -}); - -export const PerkUpdateChangeSchema = Schema.Struct({ - changeType: Schema.Literal("update-perk"), - key: Schema.String, - payload: Schema.Struct({ - name: Schema.String, - slug: Schema.String, - }), -}); - -export const PerkDeleteChangeSchema = Schema.Struct({ - changeType: Schema.Literal("delete-perk"), - key: Schema.String, - payload: Schema.Struct({ - slug: Schema.String, - }), -}); - -// ============================================================================ -// Product Changes -// ============================================================================ - -export const ProductCreateChangeSchema = Schema.Struct({ - changeType: Schema.Literal("create-product"), - key: Schema.String, - payload: Schema.Struct({ - name: Schema.String, - slug: Schema.String, - }), -}); - -export const ProductUpdateChangeSchema = Schema.Struct({ - changeType: Schema.Literal("update-product"), - key: Schema.String, - payload: Schema.Struct({ - name: Schema.String, - slug: Schema.String, - }), -}); - -export const ProductDeleteChangeSchema = Schema.Struct({ - changeType: Schema.Literal("delete-product"), - key: Schema.String, - payload: Schema.Struct({ - slug: Schema.String, - }), -}); - -// ============================================================================ -// Product Perk Changes -// ============================================================================ - -export const ProductPerkCreateChangeSchema = Schema.Struct({ - changeType: Schema.Literal("create-product-perk"), - key: Schema.String, - payload: Schema.Struct({ - perkSlug: Schema.String, - productSlug: Schema.String, - }), -}); - -export const ProductPerkDeleteChangeSchema = Schema.Struct({ - changeType: Schema.Literal("delete-product-perk"), - key: Schema.String, - payload: Schema.Struct({ - perkSlug: Schema.String, - productSlug: Schema.String, - }), -}); - -// ============================================================================ -// Payment Provider Product Changes -// ============================================================================ - -export const PaymentProviderProductCreateChangeSchema = Schema.Struct({ - changeType: Schema.Literal("create-payment-provider-product"), - key: Schema.String, - payload: Schema.Struct({ - configuration: Schema.Record(Schema.String, Schema.Unknown), - productSlug: Schema.String, - providerId: Schema.String, - }), -}); - -export const PaymentProviderProductUpdateChangeSchema = Schema.Struct({ - changeType: Schema.Literal("update-payment-provider-product"), - key: Schema.String, - payload: Schema.Struct({ - configuration: Schema.Record(Schema.String, Schema.Unknown), - productSlug: Schema.String, - providerId: Schema.String, - }), -}); - -export const PaymentProviderProductDeleteChangeSchema = Schema.Struct({ - changeType: Schema.Literal("delete-payment-provider-product"), - key: Schema.String, - payload: Schema.Struct({ - productSlug: Schema.String, - providerId: Schema.String, - }), -}); - -// ============================================================================ -// Combined Schemas -// ============================================================================ - -export const ChangeSchema = Schema.Union([ - PaywallLocationCreateChangeSchema, - PaywallLocationUpdateChangeSchema, - PaywallLocationArchiveChangeSchema, - PerkCreateChangeSchema, - PerkUpdateChangeSchema, - PerkDeleteChangeSchema, - ProductCreateChangeSchema, - ProductUpdateChangeSchema, - ProductDeleteChangeSchema, - ProductPerkCreateChangeSchema, - ProductPerkDeleteChangeSchema, - PaymentProviderProductCreateChangeSchema, - PaymentProviderProductUpdateChangeSchema, - PaymentProviderProductDeleteChangeSchema, -]); - -export const ChangesetSchema = Schema.Struct({ - changes: Schema.Array(ChangeSchema), -}); - -// ============================================================================ -// Utility Functions -// ============================================================================ - -export function sortChangeset(changeset: typeof ChangesetSchema.Type) { - const sortedChangeTypesByPriority: (typeof ChangeSchema.Type.changeType)[] = [ - "create-paywall-location", - "create-perk", - "create-product", - "update-paywall-location", - "update-perk", - "update-product", - "create-product-perk", - "create-payment-provider-product", - "update-payment-provider-product", - "archive-paywall-location", - "delete-product-perk", - "delete-payment-provider-product", - "delete-product", - "delete-perk", - ]; - - const sortedChangeset = [...changeset.changes].sort( - (a, b) => - sortedChangeTypesByPriority.indexOf(a.changeType) - - sortedChangeTypesByPriority.indexOf(b.changeType) - ); - - return sortedChangeset; -} diff --git a/packages/api-spec/src/errors/admin.ts b/packages/api-spec/src/errors/admin.ts deleted file mode 100644 index 8f266a42a..000000000 --- a/packages/api-spec/src/errors/admin.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Schema } from "effect"; - -/** Generic admin service error */ -export class AdminServiceError extends Schema.TaggedErrorClass()( - "AdminServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 } -) {} diff --git a/packages/api-spec/src/errors/analytics.ts b/packages/api-spec/src/errors/analytics.ts deleted file mode 100644 index c860289b7..000000000 --- a/packages/api-spec/src/errors/analytics.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Schema } from "effect"; - -/** Generic analytics service error */ -export class AnalyticsServiceError extends Schema.TaggedErrorClass()( - "AnalyticsServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 } -) {} - -/** Invalid time range error */ -export class InvalidTimeRangeError extends Schema.TaggedErrorClass()( - "InvalidTimeRangeError", - { - message: Schema.String, - }, - { httpApiStatus: 400 } -) {} - -/** Invalid metric error */ -export class InvalidMetricError extends Schema.TaggedErrorClass()( - "InvalidMetricError", - { - message: Schema.String, - }, - { httpApiStatus: 400 } -) {} diff --git a/packages/api-spec/src/errors/api-key.ts b/packages/api-spec/src/errors/api-key.ts deleted file mode 100644 index 1782b4200..000000000 --- a/packages/api-spec/src/errors/api-key.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Schema } from "effect"; - -/** Generic API key service error */ -export class ApiKeyServiceError extends Schema.TaggedErrorClass()( - "ApiKeyServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 } -) {} - -/** API key not found */ -export class ApiKeyNotFoundError extends Schema.TaggedErrorClass()( - "ApiKeyNotFoundError", - { - message: Schema.String, - }, - { httpApiStatus: 404 } -) {} diff --git a/packages/api-spec/src/errors/billing.ts b/packages/api-spec/src/errors/billing.ts deleted file mode 100644 index 71de5cbda..000000000 --- a/packages/api-spec/src/errors/billing.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Schema } from "effect"; - -/** Generic billing service error */ -export class BillingServiceError extends Schema.TaggedErrorClass()( - "BillingServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 } -) {} - -/** Organization billing not found */ -export class OrganizationBillingNotFoundError extends Schema.TaggedErrorClass()( - "OrganizationBillingNotFoundError", - { - message: Schema.String, - }, - { httpApiStatus: 404 } -) {} - -/** Invalid billing tier error */ -export class InvalidBillingTierError extends Schema.TaggedErrorClass()( - "InvalidBillingTierError", - { - message: Schema.String, - }, - { httpApiStatus: 400 } -) {} diff --git a/packages/api-spec/src/errors/changeset.ts b/packages/api-spec/src/errors/changeset.ts deleted file mode 100644 index ec7e8a101..000000000 --- a/packages/api-spec/src/errors/changeset.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Schema } from "effect"; - -/** Generic changeset deployment service error */ -export class ChangesetDeploymentServiceError extends Schema.TaggedErrorClass()( - "ChangesetDeploymentServiceError", - { - cause: Schema.Unknown, - }, - { httpApiStatus: 500 } -) {} diff --git a/packages/api-spec/src/errors/common.ts b/packages/api-spec/src/errors/common.ts deleted file mode 100644 index 0de329ce6..000000000 --- a/packages/api-spec/src/errors/common.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Schema } from "effect"; - -/** Action is forbidden due to insufficient permissions */ -export class ActionForbiddenError extends Schema.TaggedErrorClass()( - "ActionForbiddenError", - { - message: Schema.String, - }, - { httpApiStatus: 403 } -) {} - -/** Authentication failed */ -export class AuthenticationError extends Schema.TaggedErrorClass()( - "AuthenticationError", - { - cause: Schema.String, - message: Schema.String, - }, - { httpApiStatus: 500 } -) {} - -/** User is not authenticated */ -export class NotAuthenticatedError extends Schema.TaggedErrorClass()( - "NotAuthenticatedError", - { - message: Schema.String, - }, - { httpApiStatus: 401 } -) {} diff --git a/packages/api-spec/src/errors/customer.ts b/packages/api-spec/src/errors/customer.ts deleted file mode 100644 index 1b8c252d6..000000000 --- a/packages/api-spec/src/errors/customer.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Schema } from "effect"; - -/** Generic customer service error */ -export class CustomerServiceError extends Schema.TaggedErrorClass()( - "CustomerServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 } -) {} - -/** Customer not found */ -export class CustomerNotFoundError extends Schema.TaggedErrorClass()( - "CustomerNotFoundError", - { - id: Schema.NonEmptyString, - }, - { httpApiStatus: 404 } -) { - override toString(): string { - return `The following customer not found: ${this.id}`; - } -} - -/** Anonymous ID is invalid */ -export class CustomerInvalidAnonymousIdError extends Schema.TaggedErrorClass()( - "CustomerInvalidAnonymousIdError", - { - id: Schema.NonEmptyString, - }, - { httpApiStatus: 400 } -) { - override toString(): string { - return `The following anonymous ID is invalid: ${this.id}`; - } -} diff --git a/packages/api-spec/src/errors/index.ts b/packages/api-spec/src/errors/index.ts deleted file mode 100644 index 3c8500686..000000000 --- a/packages/api-spec/src/errors/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export * from "./admin"; -export * from "./analytics"; -export * from "./api-key"; -export * from "./billing"; -export * from "./changeset"; -export * from "./common"; -export * from "./customer"; -export * from "./organization"; -export * from "./payment-provider"; -export * from "./paywall"; -export * from "./paywall-location"; -export * from "./perk"; -export * from "./product"; -export * from "./product-perk"; -export * from "./project"; -export * from "./sdk"; -export * from "./user"; -export * from "./webhook"; diff --git a/packages/api-spec/src/errors/organization.ts b/packages/api-spec/src/errors/organization.ts deleted file mode 100644 index 4ae8760f5..000000000 --- a/packages/api-spec/src/errors/organization.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Schema } from "effect"; - -/** Generic organization service error */ -export class OrganizationServiceError extends Schema.TaggedErrorClass()( - "OrganizationServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 } -) {} - -/** Organization not found */ -export class OrganizationNotFoundError extends Schema.TaggedErrorClass()( - "OrganizationNotFoundError", - { - message: Schema.String, - }, - { httpApiStatus: 404 } -) {} diff --git a/packages/api-spec/src/errors/payment-provider.ts b/packages/api-spec/src/errors/payment-provider.ts deleted file mode 100644 index cbf0c597b..000000000 --- a/packages/api-spec/src/errors/payment-provider.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Schema } from "effect"; - -/** Generic payment provider configuration service error */ -export class PaymentProviderConfigurationServiceError extends Schema.TaggedErrorClass()( - "PaymentProviderConfigurationServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 } -) {} - -/** Payment provider configuration not found */ -export class PaymentProviderConfigurationNotFoundError extends Schema.TaggedErrorClass()( - "PaymentProviderConfigurationNotFoundError", - { - message: Schema.String, - }, - { httpApiStatus: 404 } -) {} - -/** Payment provider configuration validation error */ -export class PaymentProviderConfigurationValidationError extends Schema.TaggedErrorClass()( - "PaymentProviderConfigurationValidationError", - { - cause: Schema.String, - }, - { httpApiStatus: 400 } -) {} - -/** Payment provider configuration key unavailable */ -export class PaymentProviderConfigurationKeyUnavailableError extends Schema.TaggedErrorClass()( - "PaymentProviderConfigurationKeyUnavailableError", - { - message: Schema.String, - }, - { httpApiStatus: 400 } -) {} - -/** Payment provider already exists */ -export class PaymentProviderAlreadyExistsError extends Schema.TaggedErrorClass()( - "PaymentProviderAlreadyExistsError", - { - message: Schema.String, - }, - { httpApiStatus: 409 } -) {} - -/** Generic payment provider product service error */ -export class PaymentProviderProductServiceError extends Schema.TaggedErrorClass()( - "PaymentProviderProductServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 } -) {} - -/** Payment provider product validation error */ -export class PaymentProviderProductValidationError extends Schema.TaggedErrorClass()( - "PaymentProviderProductValidationError", - { - message: Schema.String, - }, - { httpApiStatus: 400 } -) {} - -/** Payment provider product not found */ -export class PaymentProviderProductNotFoundError extends Schema.TaggedErrorClass()( - "PaymentProviderProductNotFoundError", - { - message: Schema.String, - }, - { httpApiStatus: 404 } -) {} diff --git a/packages/api-spec/src/errors/paywall-location.ts b/packages/api-spec/src/errors/paywall-location.ts deleted file mode 100644 index 2fa8573ac..000000000 --- a/packages/api-spec/src/errors/paywall-location.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Schema } from "effect"; - -/** Generic paywall location service error */ -export class PaywallLocationServiceError extends Schema.TaggedErrorClass()( - "PaywallLocationServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 } -) {} diff --git a/packages/api-spec/src/errors/paywall.ts b/packages/api-spec/src/errors/paywall.ts deleted file mode 100644 index cabac2a39..000000000 --- a/packages/api-spec/src/errors/paywall.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Schema } from "effect"; - -/** Generic paywall service error */ -export class PaywallServiceError extends Schema.TaggedErrorClass()( - "PaywallServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 } -) {} - -/** Paywall not found */ -export class PaywallNotFoundError extends Schema.TaggedErrorClass()( - "PaywallNotFoundError", - { - message: Schema.String, - }, - { httpApiStatus: 404 } -) {} - -/** Paywall slug already exists */ -export class PaywallSlugAlreadyExistsError extends Schema.TaggedErrorClass()( - "PaywallSlugAlreadyExistsError", - { - slug: Schema.String, - }, - { httpApiStatus: 409 } -) {} - -/** Paywall publish error */ -export class PaywallPublishError extends Schema.TaggedErrorClass()( - "PaywallPublishError", - { - message: Schema.String, - }, - { httpApiStatus: 500 } -) {} diff --git a/packages/api-spec/src/errors/perk.ts b/packages/api-spec/src/errors/perk.ts deleted file mode 100644 index 8621d3f6b..000000000 --- a/packages/api-spec/src/errors/perk.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Schema } from "effect"; - -/** Generic perk service error */ -export class PerkServiceError extends Schema.TaggedErrorClass()( - "PerkServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 } -) {} - -/** Perk not found */ -export class PerkNotFoundError extends Schema.TaggedErrorClass()( - "PerkNotFoundError", - { - message: Schema.String, - }, - { httpApiStatus: 404 } -) {} - -/** Perk slug already exists */ -export class PerkSlugAlreadyExistsError extends Schema.TaggedErrorClass()( - "PerkSlugAlreadyExistsError", - { - slug: Schema.String, - }, - { httpApiStatus: 409 } -) { - override toString(): string { - return `The following perk slug already exists: ${this.slug}`; - } -} diff --git a/packages/api-spec/src/errors/product-perk.ts b/packages/api-spec/src/errors/product-perk.ts deleted file mode 100644 index f96c14e3d..000000000 --- a/packages/api-spec/src/errors/product-perk.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Schema } from "effect"; - -/** Generic product perk service error */ -export class ProductPerkServiceError extends Schema.TaggedErrorClass()( - "ProductPerkServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 } -) {} - -/** Product perk validation error */ -export class ProductPerkValidationError extends Schema.TaggedErrorClass()( - "ProductPerkValidationError", - { - message: Schema.String, - }, - { httpApiStatus: 400 } -) {} diff --git a/packages/api-spec/src/errors/product.ts b/packages/api-spec/src/errors/product.ts deleted file mode 100644 index 5b203f9e4..000000000 --- a/packages/api-spec/src/errors/product.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Schema } from "effect"; - -/** Generic product service error */ -export class ProductServiceError extends Schema.TaggedErrorClass()( - "ProductServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 } -) {} - -/** Product not found */ -export class ProductNotFoundError extends Schema.TaggedErrorClass()( - "ProductNotFoundError", - { - message: Schema.String, - }, - { httpApiStatus: 404 } -) {} - -/** Product slug already exists */ -export class ProductSlugAlreadyExistsError extends Schema.TaggedErrorClass()( - "ProductSlugAlreadyExistsError", - { - slug: Schema.String, - }, - { httpApiStatus: 409 } -) { - override toString(): string { - return `The following product slug already exists: ${this.slug}`; - } -} diff --git a/packages/api-spec/src/errors/project.ts b/packages/api-spec/src/errors/project.ts deleted file mode 100644 index f964125cf..000000000 --- a/packages/api-spec/src/errors/project.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Schema } from "effect"; - -/** Generic project service error */ -export class ProjectServiceError extends Schema.TaggedErrorClass()( - "ProjectServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 } -) {} - -/** Project not found */ -export class ProjectNotFoundError extends Schema.TaggedErrorClass()( - "ProjectNotFoundError", - { - projectId: Schema.String, - }, - { httpApiStatus: 404 } -) { - override toString(): string { - return `The following project not found: ${this.projectId}`; - } -} diff --git a/packages/api-spec/src/errors/sdk.ts b/packages/api-spec/src/errors/sdk.ts deleted file mode 100644 index 8f6de2125..000000000 --- a/packages/api-spec/src/errors/sdk.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Schema } from "effect"; - -/** Generic SDK service error */ -export class SdkServiceError extends Schema.TaggedErrorClass()( - "SdkServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 } -) {} - -/** SDK customer not found */ -export class SdkCustomerNotFoundError extends Schema.TaggedErrorClass()( - "SdkCustomerNotFoundError", - { - message: Schema.String, - }, - { httpApiStatus: 404 } -) {} - -/** SDK customer already identified */ -export class SdkCustomerAlreadyIdentifiedError extends Schema.TaggedErrorClass()( - "SdkCustomerAlreadyIdentifiedError", - { - distinctId: Schema.String, - }, - { httpApiStatus: 409 } -) { - override toString(): string { - return `The following customer was already identified: ${this.distinctId}`; - } -} - -/** SDK validation error */ -export class SdkValidationError extends Schema.TaggedErrorClass()( - "SdkValidationError", - { - message: Schema.String, - }, - { httpApiStatus: 400 } -) {} diff --git a/packages/api-spec/src/errors/user.ts b/packages/api-spec/src/errors/user.ts deleted file mode 100644 index a46727294..000000000 --- a/packages/api-spec/src/errors/user.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Schema } from "effect"; - -/** Generic user service error */ -export class UserServiceError extends Schema.TaggedErrorClass()( - "UserServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 } -) {} diff --git a/packages/api-spec/src/errors/webhook.ts b/packages/api-spec/src/errors/webhook.ts deleted file mode 100644 index 87110dc04..000000000 --- a/packages/api-spec/src/errors/webhook.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Schema } from "effect"; - -/** Generic webhook service error */ -export class WebhookServiceError extends Schema.TaggedErrorClass()( - "WebhookServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 } -) {} - -/** Webhook endpoint not found */ -export class WebhookEndpointNotFoundError extends Schema.TaggedErrorClass()( - "WebhookEndpointNotFoundError", - { - endpointId: Schema.String, - }, - { httpApiStatus: 404 } -) {} - -/** Webhook delivery not found */ -export class WebhookDeliveryNotFoundError extends Schema.TaggedErrorClass()( - "WebhookDeliveryNotFoundError", - { - deliveryId: Schema.String, - }, - { httpApiStatus: 404 } -) {} - -/** Webhook validation error */ -export class WebhookValidationError extends Schema.TaggedErrorClass()( - "WebhookValidationError", - { - message: Schema.String, - }, - { httpApiStatus: 400 } -) {} diff --git a/packages/api-spec/src/event-capture.ts b/packages/api-spec/src/event-capture.ts deleted file mode 100644 index 1ad1012db..000000000 --- a/packages/api-spec/src/event-capture.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { Schema } from "effect"; -import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; - -const CaptureErrorResponseFields = { - error: Schema.NonEmptyString, -}; - -// export { CaptureAcceptedResponse, CaptureBatchRequest, CaptureErrorCode, CaptureErrorResponse }; -// export { CaptureEvent, CaptureSdkRequestMetadata }; -export type EventPropertiesFieldPrimitive = string | number | boolean | null; -export type EventPropertiesField = - | EventPropertiesFieldPrimitive - | ReadonlyArray - | { - readonly [key: string]: EventPropertiesField; - }; - -export const EventPropertiesField: Schema.Codec = Schema.Union([ - Schema.String, - Schema.Finite, - Schema.Boolean, - Schema.Null, - Schema.Array( - Schema.suspend((): Schema.Codec => EventPropertiesField), - ), - Schema.Record( - Schema.String, - Schema.suspend((): Schema.Codec => EventPropertiesField), - ), -]); -export const EventPropertiesSchema = Schema.Record(Schema.String, EventPropertiesField); - - -export type EventContextFieldPrimitive = string | number | boolean | null; -export type EventContextField = - | EventContextFieldPrimitive - | ReadonlyArray - | { - readonly [key: string]: EventContextField; - }; -export const EventContextField: Schema.Codec = Schema.Union([ - Schema.String, - Schema.Finite, - Schema.Boolean, - Schema.Null, - Schema.Array( - Schema.suspend((): Schema.Codec => EventContextField), - ), - Schema.Record( - Schema.String, - Schema.suspend((): Schema.Codec => EventContextField), - ), -]); -export const EventContextSchema = Schema.Record(Schema.String, EventContextField); - -export const CaptureEvent = Schema.Struct({ - uuid: Schema.NonEmptyString, - event: Schema.NonEmptyString, - context: EventContextSchema, - properties: EventPropertiesSchema, - distinct_id: Schema.NonEmptyString, - session_id: Schema.optional(Schema.NonEmptyString), - timestamp: Schema.optional(Schema.DateValid), -}); - -export const CaptureSingleRequest = Schema.Struct({ - ...CaptureEvent.fields, - sent_at: Schema.DateValid, - token: Schema.NonEmptyString, -}); - -export const CaptureBatchRequest = Schema.Struct({ - events: Schema.NonEmptyArray(CaptureEvent), - sent_at: Schema.DateValid, - token: Schema.NonEmptyString, -}); - -export class CaptureAcceptedResponse extends Schema.Class( - "CaptureAcceptedResponse", -)({ - accepted: Schema.Int, - rejected: Schema.Int, -}) { - httpApiStatus = 202; -} -// export const CaptureAcceptedApiResponse = CaptureAcceptedResponse.pipe(HttpApiSchema.status(202)); - -export class CaptureInvalidRequestError extends Schema.TaggedErrorClass()( - "CaptureInvalidRequestError", - { - ...CaptureErrorResponseFields, - code: Schema.Literal("invalid_request"), - }, - { httpApiStatus: 400 }, -) {} - -export class CaptureUnauthorizedError extends Schema.TaggedErrorClass()( - "CaptureUnauthorizedError", - { - ...CaptureErrorResponseFields, - code: Schema.Literal("unauthorized"), - }, - { httpApiStatus: 401 }, -) {} - -export class CapturePayloadTooLargeError extends Schema.TaggedErrorClass()( - "CapturePayloadTooLargeError", - { - ...CaptureErrorResponseFields, - code: Schema.Literal("payload_too_large"), - }, - { httpApiStatus: 413 }, -) {} - -export class CaptureRateLimitedError extends Schema.TaggedErrorClass()( - "CaptureRateLimitedError", - { - ...CaptureErrorResponseFields, - code: Schema.Literal("rate_limited"), - retry_after_ms: Schema.optional(Schema.Int), - }, - { httpApiStatus: 429 }, -) {} - -export class CaptureDependencyUnavailableError extends Schema.TaggedErrorClass()( - "CaptureDependencyUnavailableError", - { - ...CaptureErrorResponseFields, - code: Schema.Literal("dependency_unavailable"), - }, - { httpApiStatus: 503 }, -) {} - -export class CaptureInternalServerError extends Schema.TaggedErrorClass()( - "CaptureInternalServerError", - { - ...CaptureErrorResponseFields, - code: Schema.Literal("internal_error"), - }, - { httpApiStatus: 500 }, -) {} - -export type CaptureErrorResponse = - | CaptureInvalidRequestError - | CaptureUnauthorizedError - | CapturePayloadTooLargeError - | CaptureRateLimitedError - | CaptureDependencyUnavailableError - | CaptureInternalServerError; - -export type CaptureErrorCode = CaptureErrorResponse["code"]; - - -export const EventCaptureApi = HttpApi.make("EventCaptureApi").add( - HttpApiGroup.make("event_capture") - .add( - HttpApiEndpoint.post("capture", "/capture", { - error: [ - CaptureInvalidRequestError, - CaptureUnauthorizedError, - CapturePayloadTooLargeError, - CaptureRateLimitedError, - CaptureDependencyUnavailableError, - CaptureInternalServerError, - ], - payload: CaptureSingleRequest, - success: CaptureAcceptedResponse, - }), - ) - .add( - HttpApiEndpoint.post("batch", "/batch", { - error: [ - CaptureInvalidRequestError, - CaptureUnauthorizedError, - CapturePayloadTooLargeError, - CaptureRateLimitedError, - CaptureDependencyUnavailableError, - CaptureInternalServerError, - ], - payload: CaptureBatchRequest, - success: CaptureAcceptedResponse, - }), - ), -); diff --git a/packages/api-spec/src/index.ts b/packages/api-spec/src/index.ts deleted file mode 100644 index 2c8e8fe51..000000000 --- a/packages/api-spec/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./api"; -export * from "./auth"; -export * from "./changeset"; -export * from "./middlewares"; -export * from "./schema"; diff --git a/packages/api-spec/src/middlewares.ts b/packages/api-spec/src/middlewares.ts deleted file mode 100644 index a3ffdc7ef..000000000 --- a/packages/api-spec/src/middlewares.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Schema } from "effect"; -import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"; - -import { ApiAuthSession } from "./auth"; -import { AuthenticationError, NotAuthenticatedError } from "./errors"; - -export class AuthMiddleware extends HttpApiMiddleware.Service()( - "Http/AuthenticationMiddleware", - { - // Optionally define the error schema for the middleware - error: Schema.Union([AuthenticationError, NotAuthenticatedError]), - security: { - apiKey: HttpApiSecurity.apiKey({ - in: "header", - key: "x-api-key", - }), - betterAuthCookie: HttpApiSecurity.apiKey({ - in: "cookie", - key: "__Secure-better-auth.session_token", - }), - publishableKey: HttpApiSecurity.apiKey({ - in: "header", - key: "x-publishable-key", - }), - secretKey: HttpApiSecurity.apiKey({ - in: "header", - key: "x-secret-key", - }), - }, - } -) {} diff --git a/packages/api-spec/src/schema.ts b/packages/api-spec/src/schema.ts deleted file mode 100644 index c43cc6b65..000000000 --- a/packages/api-spec/src/schema.ts +++ /dev/null @@ -1,536 +0,0 @@ -import { Schema } from "effect"; - -import { ChangesetSchema } from "./changeset"; - -export const PublishableKeyAuthHeaders = Schema.Struct({ - "x-distinct-id": Schema.String, - "x-publishable-key": Schema.String, -}); - -export const ApiKeyAuthHeaders = Schema.Struct({ - "x-api-key": Schema.String, -}); - -export const SecretKeyAuthHeaders = Schema.Struct({ - "x-secret-key": Schema.String, -}); - -// ======================================================== -// Auth -// ======================================================== - -const SessionAuthMethods = Schema.Union([ - Schema.Literal("api-key"), - Schema.Literal("publishable-key"), - Schema.Literal("secret-key"), -]); - -export const Session = Schema.Struct({ - method: SessionAuthMethods, - name: Schema.String, - organizations: Schema.Array( - Schema.Struct({ - id: Schema.String, - name: Schema.String, - slug: Schema.String, - }) - ), - projects: Schema.Array( - Schema.Struct({ - id: Schema.String, - name: Schema.String, - organizationId: Schema.String, - slug: Schema.String, - }) - ), -}); - -// ======================================================== -// API Keys -// ======================================================== - -export class ApiKey extends Schema.Class("ApiKey")({ - end: Schema.String, - id: Schema.String, - isPublic: Schema.Boolean, - name: Schema.String, - prefix: Schema.String, - projectId: Schema.String, - rawKey: Schema.optional(Schema.String), -}) {} - -export class ApiKeyWithRawKey extends Schema.Class( - "ApiKeyWithRawKey" -)({ - end: Schema.String, - id: Schema.String, - isPublic: Schema.Boolean, - name: Schema.String, - prefix: Schema.String, - projectId: Schema.String, - rawKey: Schema.String, -}) {} - -export class CreateSecretKeyBody extends Schema.Class( - "CreateSecretKeyBody" -)({ - name: Schema.String, - projectId: Schema.String, -}) {} - -export const ApiKeyIdParam = Schema.String; - -// ======================================================== -// Customers -// ======================================================== - -export class Customer extends Schema.Class("Customer")({ - customerId: Schema.String, - distinctId: Schema.String, - email: Schema.NullOr(Schema.String), - name: Schema.NullOr(Schema.String), -}) {} - -export class CreateCustomerBody extends Schema.Class( - "CreateCustomerBody" -)({ - distinctId: Schema.String, - email: Schema.optional(Schema.String), - name: Schema.optional(Schema.String), -}) {} - -export const CustomerIdParam = Schema.String; - -export const DistinctIdParam = Schema.String; - -// ======================================================== -// Organizations -// ======================================================== - -export class CreateOrganizationBody extends Schema.Class( - "CreateOrganizationBody" -)({ - name: Schema.String, -}) {} - -export class Organization extends Schema.Class("Organization")({ - id: Schema.String, - name: Schema.String, - slug: Schema.String, -}) {} - -// ======================================================== -// Perks -// ======================================================== - -export class Perk extends Schema.Class("Perk")({ - id: Schema.String, - name: Schema.String, - projectId: Schema.String, - slug: Schema.String, -}) {} - -// ======================================================== -// Paywall Locations -// ======================================================== - -export class PaywallLocation extends Schema.Class("PaywallLocation")({ - description: Schema.NullOr(Schema.String), - id: Schema.String, - name: Schema.String, - projectId: Schema.String, - slug: Schema.String, -}) {} - -// ======================================================== -// Products -// ======================================================== - -export const ProductType = Schema.Literals([ - "subscription", - "one-time", - "one-time-consumable", -]); - -export class Product extends Schema.Class("Product")({ - id: Schema.String, - name: Schema.String, - projectId: Schema.String, - slug: Schema.String, - type: ProductType, -}) {} - -// ======================================================== -// Product Perks -// ======================================================== - -export const ProductIdParam = Schema.String; - -export class ProductPerk extends Schema.Class("ProductPerk")({ - id: Schema.String, - perkId: Schema.String, - productId: Schema.String, -}) {} - -// ======================================================== -// Payment Provider Configurations -// ======================================================== - -export class PaymentProviderConfiguration extends Schema.Class( - "PaymentProviderConfiguration" -)({ - enabled: Schema.Boolean, - id: Schema.String, - name: Schema.String, - projectId: Schema.String, - providerId: Schema.String, -}) {} - -// ======================================================== -// Payment Provider Products -// ======================================================== - -export class PaymentProviderProduct extends Schema.Class( - "PaymentProviderProduct" -)({ - configuration: Schema.Record(Schema.String, Schema.Unknown), - id: Schema.String, - paymentProviderConfigurationId: Schema.String, - productId: Schema.String, - providerId: Schema.String, -}) {} - -// ======================================================== -// Changesets -// ======================================================== - -export class DeployChangesetBody extends Schema.Class( - "DeployChangesetBody" -)({ - changeset: ChangesetSchema, -}) {} - -export class DeployChangesetResponse extends Schema.Class( - "DeployChangesetResponse" -)({ - deploymentId: Schema.String, -}) {} - -// ======================================================== -// Projects -// ======================================================== - -export class CreateProjectBody extends Schema.Class( - "CreateProjectBody" -)({ - name: Schema.String, - organizationId: Schema.String, -}) {} - -export class Project extends Schema.Class("Project")({ - id: Schema.String, - name: Schema.String, - slug: Schema.String, -}) {} - -export const OrganizationIdParam = Schema.String; - -// ======================================================== -// SDK -// ======================================================== - -const CommonSdkHeaders = Schema.Struct({ - "x-client-bundle-id": Schema.String, - "x-client-locale": Schema.optional(Schema.String), - "x-client-version": Schema.optional(Schema.String), - "x-is-backgrounded": Schema.Literal("false"), - "x-is-debug-build": Schema.Literals(["true", "false"]), - "x-nonce": Schema.optional(Schema.String), - "x-observer-mode": Schema.Literals(["true", "false"]), - "x-platform": Schema.String, - "x-platform-brand": Schema.optional(Schema.String), - "x-platform-device": Schema.optional(Schema.String), - "x-platform-flavor": Schema.Literals(["native", "browser"]), - "x-platform-flavor-version": Schema.optional(Schema.String), - "x-platform-version": Schema.optional(Schema.String), - "x-preferred-locales": Schema.optional(Schema.String), - "x-sdk": Schema.Literals(["react-native", "web"]), - "x-sdk-version": Schema.String, - "x-storefront": Schema.optional(Schema.String), -}); - -export const SdkHeaders = Schema.Struct({ - ...PublishableKeyAuthHeaders.fields, - ...CommonSdkHeaders.fields, -}); - -const SdkTraitValue = Schema.Union([ - Schema.String, - Schema.Number, - Schema.Boolean, - Schema.Null, -]); - -const SdkTraits = Schema.Record(Schema.String, SdkTraitValue); - -// SDK Identify -export class SdkIdentifyBody extends Schema.Class( - "SdkIdentifyBody" -)({ - distinctId: Schema.String, - email: Schema.optional(Schema.String), - name: Schema.optional(Schema.String), - traits: Schema.optional(SdkTraits), -}) {} - -// SDK Sync Customer Attributes -export class SdkSyncCustomerAttributesBody extends Schema.Class( - "SdkSyncCustomerAttributesBody" -)({ - email: Schema.optional(Schema.String), - name: Schema.optional(Schema.String), - traits: Schema.optional(SdkTraits), -}) {} - -export const SdkSyncTransactionBody = Schema.Struct({ - platform: Schema.Literals(["ios", "android"]), - productId: Schema.String, - purchaseDate: Schema.Number, - purchaseToken: Schema.optional(Schema.String), - quantity: Schema.Number, - receipt: Schema.optional(Schema.String), - transactionId: Schema.String, -}); - -export class SdkSyncTransactionResponse extends Schema.Class( - "SdkSyncTransactionResponse" -)({ - accepted: Schema.Boolean, -}) {} - -export class SdkCustomer extends Schema.Class("SdkCustomer")({ - customerId: Schema.String, - distinctId: Schema.String, - email: Schema.NullOr(Schema.String), - name: Schema.NullOr(Schema.String), -}) {} - -// ======================================================== -// User -// ======================================================== - -export class User extends Schema.Class("User")({ - createdAt: Schema.Date, - email: Schema.String, - emailVerified: Schema.Boolean, - id: Schema.String, - image: Schema.NullOr(Schema.String), - name: Schema.String, - organizations: Schema.Array( - Schema.Struct({ - id: Schema.String, - logo: Schema.NullOr(Schema.String), - name: Schema.String, - slug: Schema.String, - }) - ), - projects: Schema.Array( - Schema.Struct({ - id: Schema.String, - logo: Schema.NullOr(Schema.String), - name: Schema.String, - organizationId: Schema.String, - slug: Schema.String, - }) - ), - updatedAt: Schema.Date, -}) {} - -// ======================================================== -// Webhooks -// ======================================================== - -export const WebhookEventType = Schema.Literals([ - "customer.created", - "customer.updated", - "customer.deleted", - "subscription.created", - "subscription.renewed", - "subscription.cancelled", - "subscription.expired", - "purchase.completed", - "purchase.refunded", -]); - -export const WebhookEndpointStatus = Schema.Literals(["active", "disabled", "failed"]); - -export const WebhookDeliveryStatus = Schema.Literals([ - "pending", - "in_progress", - "succeeded", - "failed", - "exhausted", -]); - -export class WebhookEndpoint extends Schema.Class( - "WebhookEndpoint" -)({ - consecutiveFailures: Schema.Number, - createdAt: Schema.NullOr(Schema.Date), - description: Schema.NullOr(Schema.String), - events: Schema.Array(WebhookEventType), - id: Schema.String, - lastSuccessAt: Schema.NullOr(Schema.Date), - name: Schema.String, - projectId: Schema.String, - secret: Schema.String, - status: WebhookEndpointStatus, - url: Schema.String, -}) {} - -export class CreateWebhookEndpointBody extends Schema.Class( - "CreateWebhookEndpointBody" -)({ - description: Schema.optional(Schema.String), - events: Schema.Array(Schema.String), - name: Schema.String, - url: Schema.String, -}) {} - -export class UpdateWebhookEndpointBody extends Schema.Class( - "UpdateWebhookEndpointBody" -)({ - description: Schema.optional(Schema.NullOr(Schema.String)), - events: Schema.optional(Schema.Array(Schema.String)), - name: Schema.optional(Schema.String), - status: Schema.optional(Schema.Literals(["active", "disabled"])), - url: Schema.optional(Schema.String), -}) {} - -export const WebhookEndpointIdParam = Schema.String; - -export class WebhookDelivery extends Schema.Class( - "WebhookDelivery" -)({ - attemptCount: Schema.Number, - completedAt: Schema.NullOr(Schema.Date), - createdAt: Schema.NullOr(Schema.Date), - eventOccurredAt: Schema.Date, - eventType: Schema.String, - id: Schema.String, - maxAttempts: Schema.Number, - nextAttemptAt: Schema.NullOr(Schema.Date), - payload: Schema.Unknown, - projectId: Schema.String, - status: WebhookDeliveryStatus, - webhookEndpointId: Schema.String, -}) {} - -export class WebhookDeliveryAttempt extends Schema.Class( - "WebhookDeliveryAttempt" -)({ - attemptNumber: Schema.Number, - createdAt: Schema.NullOr(Schema.Date), - durationMs: Schema.NullOr(Schema.Number), - errorMessage: Schema.NullOr(Schema.String), - id: Schema.String, - responseBody: Schema.NullOr(Schema.String), - statusCode: Schema.NullOr(Schema.Number), - succeeded: Schema.Boolean, -}) {} - -export class WebhookDeliveryWithAttempts extends Schema.Class( - "WebhookDeliveryWithAttempts" -)({ - attemptCount: Schema.Number, - attempts: Schema.Array(WebhookDeliveryAttempt), - completedAt: Schema.NullOr(Schema.Date), - createdAt: Schema.NullOr(Schema.Date), - eventOccurredAt: Schema.Date, - eventType: Schema.String, - id: Schema.String, - maxAttempts: Schema.Number, - nextAttemptAt: Schema.NullOr(Schema.Date), - payload: Schema.Unknown, - projectId: Schema.String, - status: WebhookDeliveryStatus, - webhookEndpointId: Schema.String, -}) {} - -export const WebhookDeliveryIdParam = Schema.String; - -// ======================================================== -// Feature Flags (SDK) -// ======================================================== - -export class EvaluateFeatureFlagsBody extends Schema.Class( - "EvaluateFeatureFlagsBody" -)({ - flagKeys: Schema.optional(Schema.Array(Schema.String)), -}) {} - -export class SdkFeatureFlagResult extends Schema.Class( - "SdkFeatureFlagResult" -)({ - enabled: Schema.Boolean, - key: Schema.String, - payload: Schema.NullOr(Schema.Unknown), - variantKey: Schema.NullOr(Schema.String), -}) {} - -export class SdkFeatureFlagsResponse extends Schema.Class( - "SdkFeatureFlagsResponse" -)({ - flags: Schema.Array(SdkFeatureFlagResult), -}) {} - -// ======================================================== -// Paywall Resolution (SDK) -// ======================================================== - -export class SdkResolvePaywallBody extends Schema.Class( - "SdkResolvePaywallBody" -)({ - locationSlug: Schema.String, -}) {} - -const SdkResolvedPaywallShowingType = Schema.Literals([ - "paywall_release", - "feature_flag", -]); - -const SdkResolvedPaywallShowingPaywall = Schema.Struct({ - id: Schema.String, - name: Schema.String, - slug: Schema.String, -}); - -const SdkResolvedPaywallShowingPaywallRelease = Schema.Struct({ - htmlUrl: Schema.String, - publishedAt: Schema.NullOr(Schema.Date), - releaseId: Schema.String, - version: Schema.Number, -}); - -export class SdkResolvedPaywallShowing extends Schema.Class( - "SdkResolvedPaywallShowing" -)({ - id: Schema.String, - paywall: Schema.NullOr(SdkResolvedPaywallShowingPaywall), - paywallId: Schema.NullOr(Schema.String), - paywallRelease: Schema.NullOr(SdkResolvedPaywallShowingPaywallRelease), - paywallReleaseId: Schema.NullOr(Schema.String), - startedAt: Schema.Date, - type: SdkResolvedPaywallShowingType, -}) {} - -export class SdkResolvedPaywall extends Schema.Class( - "SdkResolvedPaywall" -)({ - location: Schema.Struct({ - id: Schema.String, - name: Schema.String, - slug: Schema.String, - }), - showing: SdkResolvedPaywallShowing, -}) {} diff --git a/packages/generated-clients/openapi/preview/core.json b/packages/generated-clients/openapi/preview/core.json new file mode 100644 index 000000000..e8aefec47 --- /dev/null +++ b/packages/generated-clients/openapi/preview/core.json @@ -0,0 +1,8086 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Api", + "version": "0.0.1" + }, + "paths": { + "/api/v1/auth/session": { + "get": { + "tags": [ + "auth" + ], + "operationId": "auth.session", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "method": { + "anyOf": [ + { + "type": "string", + "enum": [ + "api-key" + ] + }, + { + "type": "string", + "enum": [ + "publishable-key" + ] + }, + { + "type": "string", + "enum": [ + "secret-key" + ] + } + ] + }, + "name": { + "type": "string" + }, + "organizations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "slug" + ], + "additionalProperties": false + } + }, + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "organizationId", + "slug" + ], + "additionalProperties": false + } + } + }, + "required": [ + "method", + "name", + "organizations", + "projects" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + } + } + } + } + } + }, + "/api/v1/api-keys": { + "post": { + "tags": [ + "api_keys" + ], + "operationId": "api_keys.createSecretKey", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "ApiKeyWithRawKey", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyWithRawKey" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "ApiKeyServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ApiKeyServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSecretKeyBody" + } + } + }, + "required": true + } + }, + "get": { + "tags": [ + "api_keys" + ], + "operationId": "api_keys.listApiKeys", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiKey" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "ApiKeyServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ApiKeyServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/api-keys/{apiKeyId}": { + "get": { + "tags": [ + "api_keys" + ], + "operationId": "api_keys.getApiKeyById", + "parameters": [ + { + "name": "apiKeyId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "ApiKey", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKey" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "ApiKeyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyNotFoundError" + } + } + } + }, + "500": { + "description": "ApiKeyServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ApiKeyServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + }, + "delete": { + "tags": [ + "api_keys" + ], + "operationId": "api_keys.deleteApiKey", + "parameters": [ + { + "name": "apiKeyId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "ApiKeyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyNotFoundError" + } + } + } + }, + "500": { + "description": "ApiKeyServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ApiKeyServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/api-keys/{apiKeyId}/rotate": { + "post": { + "tags": [ + "api_keys" + ], + "operationId": "api_keys.rotateSecretKey", + "parameters": [ + { + "name": "apiKeyId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "ApiKeyWithRawKey", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyWithRawKey" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "ApiKeyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyNotFoundError" + } + } + } + }, + "500": { + "description": "ApiKeyServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ApiKeyServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/customers": { + "post": { + "tags": [ + "customers" + ], + "operationId": "customers.createCustomer", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Customer", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Customer" + } + } + } + }, + "400": { + "description": "CustomerInvalidAnonymousIdError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CustomerInvalidAnonymousIdError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "CustomerServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CustomerServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCustomerBody" + } + } + }, + "required": true + } + }, + "get": { + "tags": [ + "customers" + ], + "operationId": "customers.listCustomers", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Customer" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "CustomerServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CustomerServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/customers/{customerId}": { + "get": { + "tags": [ + "customers" + ], + "operationId": "customers.getCustomerById", + "parameters": [ + { + "name": "customerId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Customer", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Customer" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "CustomerNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomerNotFoundError" + } + } + } + }, + "500": { + "description": "CustomerServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CustomerServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/customers/by-distinct-id/{distinctId}": { + "get": { + "tags": [ + "customers" + ], + "operationId": "customers.byDistinctId", + "parameters": [ + { + "name": "distinctId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Customer", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Customer" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "CustomerNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomerNotFoundError" + } + } + } + }, + "500": { + "description": "CustomerServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CustomerServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/organizations": { + "post": { + "tags": [ + "organizations" + ], + "operationId": "organizations.createOrganization", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Organization", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Organization" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "500": { + "description": "OrganizationServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/OrganizationServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrganizationBody" + } + } + }, + "required": true + } + } + }, + "/api/v1/perks": { + "get": { + "tags": [ + "perks" + ], + "operationId": "perks.listPerks", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Perk" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "PerkServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PerkServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/paywall-locations": { + "get": { + "tags": [ + "paywall_locations" + ], + "operationId": "paywall_locations.listPaywallLocations", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PaywallLocation" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "PaywallLocationServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PaywallLocationServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/projects": { + "post": { + "tags": [ + "projects" + ], + "operationId": "projects.createProject", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "AuthenticationError | ProjectServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/ProjectServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectBody" + } + } + }, + "required": true + } + } + }, + "/api/v1/projects/{organizationId}": { + "get": { + "tags": [ + "projects" + ], + "operationId": "projects.listProjects", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "ProjectServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/products": { + "get": { + "tags": [ + "products" + ], + "operationId": "products.listProducts", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Product" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "ProductServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProductServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/product-perks/by-product-id/{productId}": { + "get": { + "tags": [ + "product_perks" + ], + "operationId": "product_perks.listProductPerksByProductId", + "parameters": [ + { + "name": "productId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProductPerk" + } + } + } + } + }, + "400": { + "description": "ProductPerkValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProductPerkValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "ProductPerkServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProductPerkServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/sdk/get-customer": { + "get": { + "tags": [ + "sdk" + ], + "operationId": "sdk.getCustomer", + "parameters": [ + { + "name": "x-distinct-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-publishable-key", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-bundle-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-locale", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-client-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-is-backgrounded", + "in": "header", + "schema": { + "type": "string", + "enum": [ + "false" + ] + }, + "required": true + }, + { + "name": "x-is-debug-build", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-nonce", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-observer-mode", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-platform-brand", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-device", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-flavor", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "native" + ] + }, + { + "type": "string", + "enum": [ + "browser" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform-flavor-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-preferred-locales", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-sdk", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "react-native" + ] + }, + { + "type": "string", + "enum": [ + "web" + ] + } + ] + }, + "required": true + }, + { + "name": "x-sdk-version", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-storefront", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "SdkCustomer", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkCustomer" + } + } + } + }, + "400": { + "description": "SdkValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SdkValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "404": { + "description": "SdkCustomerNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkCustomerNotFoundError" + } + } + } + }, + "500": { + "description": "AuthenticationError | SdkServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/SdkServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/sdk/identify": { + "post": { + "tags": [ + "sdk" + ], + "operationId": "sdk.identify", + "parameters": [ + { + "name": "x-distinct-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-publishable-key", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-bundle-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-locale", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-client-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-is-backgrounded", + "in": "header", + "schema": { + "type": "string", + "enum": [ + "false" + ] + }, + "required": true + }, + { + "name": "x-is-debug-build", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-nonce", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-observer-mode", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-platform-brand", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-device", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-flavor", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "native" + ] + }, + { + "type": "string", + "enum": [ + "browser" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform-flavor-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-preferred-locales", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-sdk", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "react-native" + ] + }, + { + "type": "string", + "enum": [ + "web" + ] + } + ] + }, + "required": true + }, + { + "name": "x-sdk-version", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-storefront", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "SdkCustomer", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkCustomer" + } + } + } + }, + "400": { + "description": "SdkValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SdkValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "409": { + "description": "SdkCustomerAlreadyIdentifiedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkCustomerAlreadyIdentifiedError" + } + } + } + }, + "500": { + "description": "AuthenticationError | SdkServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/SdkServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkIdentifyBody" + } + } + }, + "required": true + } + } + }, + "/api/v1/sdk/sync-customer-attributes": { + "post": { + "tags": [ + "sdk" + ], + "operationId": "sdk.syncCustomerAttributes", + "parameters": [ + { + "name": "x-distinct-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-publishable-key", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-bundle-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-locale", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-client-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-is-backgrounded", + "in": "header", + "schema": { + "type": "string", + "enum": [ + "false" + ] + }, + "required": true + }, + { + "name": "x-is-debug-build", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-nonce", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-observer-mode", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-platform-brand", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-device", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-flavor", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "native" + ] + }, + { + "type": "string", + "enum": [ + "browser" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform-flavor-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-preferred-locales", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-sdk", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "react-native" + ] + }, + { + "type": "string", + "enum": [ + "web" + ] + } + ] + }, + "required": true + }, + { + "name": "x-sdk-version", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-storefront", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "SdkCustomer", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkCustomer" + } + } + } + }, + "400": { + "description": "SdkValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SdkValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "500": { + "description": "AuthenticationError | SdkServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/SdkServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkSyncCustomerAttributesBody" + } + } + }, + "required": true + } + } + }, + "/api/v1/sdk/sync-transaction": { + "post": { + "tags": [ + "sdk" + ], + "operationId": "sdk.syncTransaction", + "parameters": [ + { + "name": "x-distinct-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-publishable-key", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-bundle-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-locale", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-client-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-is-backgrounded", + "in": "header", + "schema": { + "type": "string", + "enum": [ + "false" + ] + }, + "required": true + }, + { + "name": "x-is-debug-build", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-nonce", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-observer-mode", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-platform-brand", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-device", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-flavor", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "native" + ] + }, + { + "type": "string", + "enum": [ + "browser" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform-flavor-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-preferred-locales", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-sdk", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "react-native" + ] + }, + { + "type": "string", + "enum": [ + "web" + ] + } + ] + }, + "required": true + }, + { + "name": "x-sdk-version", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-storefront", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "SdkSyncTransactionResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkSyncTransactionResponse" + } + } + } + }, + "400": { + "description": "SdkValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SdkValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "500": { + "description": "AuthenticationError | SdkServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/SdkServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "platform": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ios" + ] + }, + { + "type": "string", + "enum": [ + "android" + ] + } + ] + }, + "productId": { + "type": "string" + }, + "purchaseDate": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "purchaseToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "quantity": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "receipt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "transactionId": { + "type": "string" + } + }, + "required": [ + "platform", + "productId", + "purchaseDate", + "quantity", + "transactionId" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/v1/sdk/evaluate-flags": { + "post": { + "tags": [ + "sdk" + ], + "operationId": "sdk.evaluateFeatureFlags", + "parameters": [ + { + "name": "x-distinct-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-publishable-key", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-bundle-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-locale", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-client-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-is-backgrounded", + "in": "header", + "schema": { + "type": "string", + "enum": [ + "false" + ] + }, + "required": true + }, + { + "name": "x-is-debug-build", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-nonce", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-observer-mode", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-platform-brand", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-device", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-flavor", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "native" + ] + }, + { + "type": "string", + "enum": [ + "browser" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform-flavor-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-preferred-locales", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-sdk", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "react-native" + ] + }, + { + "type": "string", + "enum": [ + "web" + ] + } + ] + }, + "required": true + }, + { + "name": "x-sdk-version", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-storefront", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "SdkFeatureFlagsResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkFeatureFlagsResponse" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "500": { + "description": "AuthenticationError | SdkServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/SdkServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvaluateFeatureFlagsBody" + } + } + }, + "required": true + } + } + }, + "/api/v1/sdk/resolve-paywall": { + "post": { + "tags": [ + "sdk" + ], + "operationId": "sdk.resolvePaywall", + "parameters": [ + { + "name": "x-distinct-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-publishable-key", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-bundle-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-locale", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-client-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-is-backgrounded", + "in": "header", + "schema": { + "type": "string", + "enum": [ + "false" + ] + }, + "required": true + }, + { + "name": "x-is-debug-build", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-nonce", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-observer-mode", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-platform-brand", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-device", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-flavor", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "native" + ] + }, + { + "type": "string", + "enum": [ + "browser" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform-flavor-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-preferred-locales", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-sdk", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "react-native" + ] + }, + { + "type": "string", + "enum": [ + "web" + ] + } + ] + }, + "required": true + }, + { + "name": "x-sdk-version", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-storefront", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SdkResolvedPaywall" + }, + { + "type": "null" + } + ] + } + } + } + }, + "400": { + "description": "SdkValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SdkValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "500": { + "description": "AuthenticationError | SdkServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/SdkServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkResolvePaywallBody" + } + } + }, + "required": true + } + } + }, + "/api/v1/users/current": { + "get": { + "tags": [ + "users" + ], + "operationId": "users.getUser", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "User", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "500": { + "description": "AuthenticationError | UserServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/UserServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/payment-provider-configurations": { + "get": { + "tags": [ + "payment_provider_configurations" + ], + "operationId": "payment_provider_configurations.listPaymentProviderConfigurations", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PaymentProviderConfiguration" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "PaymentProviderConfigurationServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PaymentProviderConfigurationServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/payment-provider-products": { + "get": { + "tags": [ + "payment_provider_products" + ], + "operationId": "payment_provider_products.listPaymentProviderProducts", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PaymentProviderProduct" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "PaymentProviderProductServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PaymentProviderProductServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/changesets/deploy": { + "post": { + "tags": [ + "changesets" + ], + "operationId": "changesets.deployChangeset", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "DeployChangesetResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployChangesetResponse" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "AuthenticationError | ChangesetDeploymentServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/ChangesetDeploymentServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployChangesetBody" + } + } + }, + "required": true + } + } + }, + "/api/v1/webhooks/endpoints": { + "post": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.createWebhookEndpoint", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "WebhookEndpoint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpoint" + } + } + } + }, + "400": { + "description": "WebhookValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWebhookEndpointBody" + } + } + }, + "required": true + } + }, + "get": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.listWebhookEndpoints", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookEndpoint" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/webhooks/endpoints/{endpointId}": { + "get": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.getWebhookEndpoint", + "parameters": [ + { + "name": "endpointId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "WebhookEndpoint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpoint" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "WebhookEndpointNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointNotFoundError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + }, + "patch": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.updateWebhookEndpoint", + "parameters": [ + { + "name": "endpointId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "WebhookEndpoint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpoint" + } + } + } + }, + "400": { + "description": "WebhookValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "WebhookEndpointNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointNotFoundError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWebhookEndpointBody" + } + } + }, + "required": true + } + }, + "delete": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.deleteWebhookEndpoint", + "parameters": [ + { + "name": "endpointId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "WebhookEndpointNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointNotFoundError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/webhooks/endpoints/{endpointId}/rotate-secret": { + "post": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.rotateWebhookSecret", + "parameters": [ + { + "name": "endpointId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "WebhookEndpoint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpoint" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "WebhookEndpointNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointNotFoundError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/webhooks/endpoints/{endpointId}/test": { + "post": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.testWebhookEndpoint", + "parameters": [ + { + "name": "endpointId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "WebhookDelivery", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookDelivery" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "WebhookEndpointNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointNotFoundError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/webhooks/deliveries": { + "get": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.listWebhookDeliveries", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookDelivery" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/webhooks/deliveries/{deliveryId}": { + "get": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.getWebhookDelivery", + "parameters": [ + { + "name": "deliveryId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "WebhookDeliveryWithAttempts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookDeliveryWithAttempts" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "WebhookDeliveryNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookDeliveryNotFoundError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/webhooks/deliveries/{deliveryId}/retry": { + "post": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.retryWebhookDelivery", + "parameters": [ + { + "name": "deliveryId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "WebhookDelivery", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookDelivery" + } + } + } + }, + "400": { + "description": "WebhookValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "WebhookDeliveryNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookDeliveryNotFoundError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ActionForbiddenError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ActionForbiddenError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "AuthenticationError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AuthenticationError" + ] + }, + "cause": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause", + "message" + ], + "additionalProperties": false + }, + "NotAuthenticatedError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "NotAuthenticatedError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "effect_HttpApiSchemaError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "HttpApiSchemaError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "CreateSecretKeyBody": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "projectId": { + "type": "string" + } + }, + "required": [ + "name", + "projectId" + ], + "additionalProperties": false + }, + "ApiKeyWithRawKey": { + "type": "object", + "properties": { + "end": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isPublic": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "prefix": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "rawKey": { + "type": "string" + } + }, + "required": [ + "end", + "id", + "isPublic", + "name", + "prefix", + "projectId", + "rawKey" + ], + "additionalProperties": false + }, + "ApiKeyServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ApiKeyServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "ApiKey": { + "type": "object", + "properties": { + "end": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isPublic": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "prefix": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "rawKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "end", + "id", + "isPublic", + "name", + "prefix", + "projectId" + ], + "additionalProperties": false + }, + "ApiKeyNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ApiKeyNotFoundError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "CreateCustomerBody": { + "type": "object", + "properties": { + "distinctId": { + "type": "string" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "distinctId" + ], + "additionalProperties": false + }, + "Customer": { + "type": "object", + "properties": { + "customerId": { + "type": "string" + }, + "distinctId": { + "type": "string" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "customerId", + "distinctId", + "email", + "name" + ], + "additionalProperties": false + }, + "CustomerInvalidAnonymousIdError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CustomerInvalidAnonymousIdError" + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + } + }, + "required": [ + "_tag", + "id" + ], + "additionalProperties": false + }, + "CustomerServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CustomerServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "CustomerNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CustomerNotFoundError" + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + } + }, + "required": [ + "_tag", + "id" + ], + "additionalProperties": false + }, + "CreateOrganizationBody": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "Organization": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "slug" + ], + "additionalProperties": false + }, + "OrganizationServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "OrganizationServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "Perk": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "projectId", + "slug" + ], + "additionalProperties": false + }, + "PerkServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PerkServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "PaywallLocation": { + "type": "object", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name", + "projectId", + "slug" + ], + "additionalProperties": false + }, + "PaywallLocationServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PaywallLocationServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "CreateProjectBody": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "organizationId": { + "type": "string" + } + }, + "required": [ + "name", + "organizationId" + ], + "additionalProperties": false + }, + "Project": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "slug" + ], + "additionalProperties": false + }, + "ProjectServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProjectServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "Product": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "type": { + "anyOf": [ + { + "type": "string", + "enum": [ + "subscription" + ] + }, + { + "type": "string", + "enum": [ + "one-time" + ] + }, + { + "type": "string", + "enum": [ + "one-time-consumable" + ] + } + ] + } + }, + "required": [ + "id", + "name", + "projectId", + "slug", + "type" + ], + "additionalProperties": false + }, + "ProductServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProductServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "ProductPerk": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "perkId": { + "type": "string" + }, + "productId": { + "type": "string" + } + }, + "required": [ + "id", + "perkId", + "productId" + ], + "additionalProperties": false + }, + "ProductPerkServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProductPerkServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "ProductPerkValidationError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProductPerkValidationError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "SdkCustomer": { + "type": "object", + "properties": { + "customerId": { + "type": "string" + }, + "distinctId": { + "type": "string" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "customerId", + "distinctId", + "email", + "name" + ], + "additionalProperties": false + }, + "SdkServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SdkServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "SdkCustomerNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SdkCustomerNotFoundError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "SdkValidationError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SdkValidationError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "SdkIdentifyBody": { + "type": "object", + "properties": { + "distinctId": { + "type": "string" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "traits": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "distinctId" + ], + "additionalProperties": false + }, + "SdkCustomerAlreadyIdentifiedError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SdkCustomerAlreadyIdentifiedError" + ] + }, + "distinctId": { + "type": "string" + } + }, + "required": [ + "_tag", + "distinctId" + ], + "additionalProperties": false + }, + "SdkSyncCustomerAttributesBody": { + "type": "object", + "properties": { + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "traits": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "SdkSyncTransactionResponse": { + "type": "object", + "properties": { + "accepted": { + "type": "boolean" + } + }, + "required": [ + "accepted" + ], + "additionalProperties": false + }, + "EvaluateFeatureFlagsBody": { + "type": "object", + "properties": { + "flagKeys": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "SdkFeatureFlagResult": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "payload": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "null" + } + ] + }, + "variantKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "enabled", + "key", + "payload", + "variantKey" + ], + "additionalProperties": false + }, + "SdkFeatureFlagsResponse": { + "type": "object", + "properties": { + "flags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SdkFeatureFlagResult" + } + } + }, + "required": [ + "flags" + ], + "additionalProperties": false + }, + "SdkResolvePaywallBody": { + "type": "object", + "properties": { + "locationSlug": { + "type": "string" + } + }, + "required": [ + "locationSlug" + ], + "additionalProperties": false + }, + "SdkResolvedPaywallShowing": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "paywall": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "slug" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "paywallId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "paywallRelease": { + "anyOf": [ + { + "type": "object", + "properties": { + "htmlUrl": { + "type": "string" + }, + "publishedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "releaseId": { + "type": "string" + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + } + }, + "required": [ + "htmlUrl", + "publishedAt", + "releaseId", + "version" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "paywallReleaseId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "type": "string" + }, + "type": { + "anyOf": [ + { + "type": "string", + "enum": [ + "paywall_release" + ] + }, + { + "type": "string", + "enum": [ + "feature_flag" + ] + } + ] + } + }, + "required": [ + "id", + "paywall", + "paywallId", + "paywallRelease", + "paywallReleaseId", + "startedAt", + "type" + ], + "additionalProperties": false + }, + "SdkResolvedPaywall": { + "type": "object", + "properties": { + "location": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "slug" + ], + "additionalProperties": false + }, + "showing": { + "$ref": "#/components/schemas/SdkResolvedPaywallShowing" + } + }, + "required": [ + "location", + "showing" + ], + "additionalProperties": false + }, + "User": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "email": { + "type": "string" + }, + "emailVerified": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "organizations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "logo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "logo", + "name", + "slug" + ], + "additionalProperties": false + } + }, + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "logo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "logo", + "name", + "organizationId", + "slug" + ], + "additionalProperties": false + } + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "createdAt", + "email", + "emailVerified", + "id", + "image", + "name", + "organizations", + "projects", + "updatedAt" + ], + "additionalProperties": false + }, + "UserServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "UserServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "PaymentProviderConfiguration": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "providerId": { + "type": "string" + } + }, + "required": [ + "enabled", + "id", + "name", + "projectId", + "providerId" + ], + "additionalProperties": false + }, + "PaymentProviderConfigurationServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PaymentProviderConfigurationServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "PaymentProviderProduct": { + "type": "object", + "properties": { + "configuration": { + "type": "object", + "additionalProperties": { + "type": "null" + } + }, + "id": { + "type": "string" + }, + "paymentProviderConfigurationId": { + "type": "string" + }, + "productId": { + "type": "string" + }, + "providerId": { + "type": "string" + } + }, + "required": [ + "configuration", + "id", + "paymentProviderConfigurationId", + "productId", + "providerId" + ], + "additionalProperties": false + }, + "PaymentProviderProductServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PaymentProviderProductServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "DeployChangesetBody": { + "type": "object", + "properties": { + "changeset": { + "type": "object", + "properties": { + "changes": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "create-paywall-location" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "description": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "name", + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "update-paywall-location" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "description": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "name", + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "archive-paywall-location" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "slug": { + "type": "string" + } + }, + "required": [ + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "create-perk" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "name", + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "update-perk" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "name", + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "delete-perk" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "slug": { + "type": "string" + } + }, + "required": [ + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "create-product" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "name", + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "update-product" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "name", + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "delete-product" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "slug": { + "type": "string" + } + }, + "required": [ + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "create-product-perk" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "perkSlug": { + "type": "string" + }, + "productSlug": { + "type": "string" + } + }, + "required": [ + "perkSlug", + "productSlug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "delete-product-perk" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "perkSlug": { + "type": "string" + }, + "productSlug": { + "type": "string" + } + }, + "required": [ + "perkSlug", + "productSlug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "create-payment-provider-product" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "configuration": { + "type": "object", + "additionalProperties": { + "type": "null" + } + }, + "productSlug": { + "type": "string" + }, + "providerId": { + "type": "string" + } + }, + "required": [ + "configuration", + "productSlug", + "providerId" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "update-payment-provider-product" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "configuration": { + "type": "object", + "additionalProperties": { + "type": "null" + } + }, + "productSlug": { + "type": "string" + }, + "providerId": { + "type": "string" + } + }, + "required": [ + "configuration", + "productSlug", + "providerId" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "delete-payment-provider-product" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "productSlug": { + "type": "string" + }, + "providerId": { + "type": "string" + } + }, + "required": [ + "productSlug", + "providerId" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "changes" + ], + "additionalProperties": false + } + }, + "required": [ + "changeset" + ], + "additionalProperties": false + }, + "DeployChangesetResponse": { + "type": "object", + "properties": { + "deploymentId": { + "type": "string" + } + }, + "required": [ + "deploymentId" + ], + "additionalProperties": false + }, + "ChangesetDeploymentServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ChangesetDeploymentServiceError" + ] + }, + "cause": { + "type": "null" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "CreateWebhookEndpointBody": { + "type": "object", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "events", + "name", + "url" + ], + "additionalProperties": false + }, + "WebhookEndpoint": { + "type": "object", + "properties": { + "consecutiveFailures": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "events": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "enum": [ + "customer.created" + ] + }, + { + "type": "string", + "enum": [ + "customer.updated" + ] + }, + { + "type": "string", + "enum": [ + "customer.deleted" + ] + }, + { + "type": "string", + "enum": [ + "subscription.created" + ] + }, + { + "type": "string", + "enum": [ + "subscription.renewed" + ] + }, + { + "type": "string", + "enum": [ + "subscription.cancelled" + ] + }, + { + "type": "string", + "enum": [ + "subscription.expired" + ] + }, + { + "type": "string", + "enum": [ + "purchase.completed" + ] + }, + { + "type": "string", + "enum": [ + "purchase.refunded" + ] + } + ] + } + }, + "id": { + "type": "string" + }, + "lastSuccessAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "secret": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "active" + ] + }, + { + "type": "string", + "enum": [ + "disabled" + ] + }, + { + "type": "string", + "enum": [ + "failed" + ] + } + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "consecutiveFailures", + "createdAt", + "description", + "events", + "id", + "lastSuccessAt", + "name", + "projectId", + "secret", + "status", + "url" + ], + "additionalProperties": false + }, + "WebhookValidationError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "WebhookValidationError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "WebhookServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "WebhookServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "WebhookEndpointNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "WebhookEndpointNotFoundError" + ] + }, + "endpointId": { + "type": "string" + } + }, + "required": [ + "_tag", + "endpointId" + ], + "additionalProperties": false + }, + "UpdateWebhookEndpointBody": { + "type": "object", + "properties": { + "description": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "events": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "status": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "enum": [ + "active" + ] + }, + { + "type": "string", + "enum": [ + "disabled" + ] + } + ] + }, + { + "type": "null" + } + ] + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "WebhookDelivery": { + "type": "object", + "properties": { + "attemptCount": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "eventOccurredAt": { + "type": "string" + }, + "eventType": { + "type": "string" + }, + "id": { + "type": "string" + }, + "maxAttempts": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "nextAttemptAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "payload": { + "type": "null" + }, + "projectId": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "pending" + ] + }, + { + "type": "string", + "enum": [ + "in_progress" + ] + }, + { + "type": "string", + "enum": [ + "succeeded" + ] + }, + { + "type": "string", + "enum": [ + "failed" + ] + }, + { + "type": "string", + "enum": [ + "exhausted" + ] + } + ] + }, + "webhookEndpointId": { + "type": "string" + } + }, + "required": [ + "attemptCount", + "completedAt", + "createdAt", + "eventOccurredAt", + "eventType", + "id", + "maxAttempts", + "nextAttemptAt", + "payload", + "projectId", + "status", + "webhookEndpointId" + ], + "additionalProperties": false + }, + "WebhookDeliveryAttempt": { + "type": "object", + "properties": { + "attemptNumber": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "durationMs": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "null" + } + ] + }, + "errorMessage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "responseBody": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "statusCode": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "null" + } + ] + }, + "succeeded": { + "type": "boolean" + } + }, + "required": [ + "attemptNumber", + "createdAt", + "durationMs", + "errorMessage", + "id", + "responseBody", + "statusCode", + "succeeded" + ], + "additionalProperties": false + }, + "WebhookDeliveryWithAttempts": { + "type": "object", + "properties": { + "attemptCount": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "attempts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookDeliveryAttempt" + } + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "eventOccurredAt": { + "type": "string" + }, + "eventType": { + "type": "string" + }, + "id": { + "type": "string" + }, + "maxAttempts": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "nextAttemptAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "payload": { + "type": "null" + }, + "projectId": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "pending" + ] + }, + { + "type": "string", + "enum": [ + "in_progress" + ] + }, + { + "type": "string", + "enum": [ + "succeeded" + ] + }, + { + "type": "string", + "enum": [ + "failed" + ] + }, + { + "type": "string", + "enum": [ + "exhausted" + ] + } + ] + }, + "webhookEndpointId": { + "type": "string" + } + }, + "required": [ + "attemptCount", + "attempts", + "completedAt", + "createdAt", + "eventOccurredAt", + "eventType", + "id", + "maxAttempts", + "nextAttemptAt", + "payload", + "projectId", + "status", + "webhookEndpointId" + ], + "additionalProperties": false + }, + "WebhookDeliveryNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "WebhookDeliveryNotFoundError" + ] + }, + "deliveryId": { + "type": "string" + } + }, + "required": [ + "_tag", + "deliveryId" + ], + "additionalProperties": false + } + }, + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "name": "x-api-key", + "in": "header" + }, + "betterAuthCookie": { + "type": "apiKey", + "name": "__Secure-better-auth.session_token", + "in": "cookie" + }, + "publishableKey": { + "type": "apiKey", + "name": "x-publishable-key", + "in": "header" + }, + "secretKey": { + "type": "apiKey", + "name": "x-secret-key", + "in": "header" + } + } + }, + "security": [], + "tags": [ + { + "name": "auth" + }, + { + "name": "api_keys" + }, + { + "name": "customers" + }, + { + "name": "organizations" + }, + { + "name": "perks" + }, + { + "name": "paywall_locations" + }, + { + "name": "projects" + }, + { + "name": "products" + }, + { + "name": "product_perks" + }, + { + "name": "sdk" + }, + { + "name": "users" + }, + { + "name": "payment_provider_configurations" + }, + { + "name": "payment_provider_products" + }, + { + "name": "changesets" + }, + { + "name": "webhooks" + } + ] +} diff --git a/packages/generated-clients/openapi/preview/event-capture.json b/packages/generated-clients/openapi/preview/event-capture.json new file mode 100644 index 000000000..a219e4a0b --- /dev/null +++ b/packages/generated-clients/openapi/preview/event-capture.json @@ -0,0 +1,787 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Api", + "version": "0.0.1" + }, + "paths": { + "/capture": { + "post": { + "tags": [ + "event_capture" + ], + "operationId": "event_capture.capture", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "CaptureAcceptedResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureAcceptedResponse" + } + } + } + }, + "400": { + "description": "CaptureInvalidRequestError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CaptureInvalidRequestError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "401": { + "description": "CaptureUnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureUnauthorizedError" + } + } + } + }, + "413": { + "description": "CapturePayloadTooLargeError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CapturePayloadTooLargeError" + } + } + } + }, + "429": { + "description": "CaptureRateLimitedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureRateLimitedError" + } + } + } + }, + "500": { + "description": "CaptureInternalServerError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureInternalServerError" + } + } + } + }, + "503": { + "description": "CaptureDependencyUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureDependencyUnavailableError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "event": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "context": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Union_" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Union_1" + } + }, + "distinct_id": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "session_id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "format": "date-time" + } + ] + }, + { + "type": "null" + } + ] + }, + "sent_at": { + "type": "string", + "allOf": [ + { + "format": "date-time" + } + ] + }, + "token": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + } + }, + "required": [ + "uuid", + "event", + "context", + "properties", + "distinct_id", + "sent_at", + "token" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/batch": { + "post": { + "tags": [ + "event_capture" + ], + "operationId": "event_capture.batch", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "CaptureAcceptedResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureAcceptedResponse" + } + } + } + }, + "400": { + "description": "CaptureInvalidRequestError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CaptureInvalidRequestError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "401": { + "description": "CaptureUnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureUnauthorizedError" + } + } + } + }, + "413": { + "description": "CapturePayloadTooLargeError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CapturePayloadTooLargeError" + } + } + } + }, + "429": { + "description": "CaptureRateLimitedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureRateLimitedError" + } + } + } + }, + "500": { + "description": "CaptureInternalServerError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureInternalServerError" + } + } + } + }, + "503": { + "description": "CaptureDependencyUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureDependencyUnavailableError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "events": { + "type": "array", + "prefixItems": [ + { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "event": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "context": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Union_" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Union_1" + } + }, + "distinct_id": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "session_id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "format": "date-time" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "uuid", + "event", + "context", + "properties", + "distinct_id" + ], + "additionalProperties": false + } + ], + "minItems": 1, + "items": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "event": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "context": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Union_" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Union_1" + } + }, + "distinct_id": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "session_id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "format": "date-time" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "uuid", + "event", + "context", + "properties", + "distinct_id" + ], + "additionalProperties": false + } + }, + "sent_at": { + "type": "string", + "allOf": [ + { + "format": "date-time" + } + ] + }, + "token": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + } + }, + "required": [ + "events", + "sent_at", + "token" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + } + }, + "components": { + "schemas": { + "Union_": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/Union_" + } + }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Union_" + } + } + ] + }, + "Union_1": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/Union_1" + } + }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Union_1" + } + } + ] + }, + "CaptureAcceptedResponse": { + "type": "object", + "properties": { + "accepted": { + "type": "integer" + }, + "rejected": { + "type": "integer" + } + }, + "required": [ + "accepted", + "rejected" + ], + "additionalProperties": false + }, + "CaptureInvalidRequestError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CaptureInvalidRequestError" + ] + }, + "error": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "code": { + "type": "string", + "enum": [ + "invalid_request" + ] + } + }, + "required": [ + "_tag", + "error", + "code" + ], + "additionalProperties": false + }, + "effect_HttpApiSchemaError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "HttpApiSchemaError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "CaptureUnauthorizedError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CaptureUnauthorizedError" + ] + }, + "error": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "code": { + "type": "string", + "enum": [ + "unauthorized" + ] + } + }, + "required": [ + "_tag", + "error", + "code" + ], + "additionalProperties": false + }, + "CapturePayloadTooLargeError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CapturePayloadTooLargeError" + ] + }, + "error": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "code": { + "type": "string", + "enum": [ + "payload_too_large" + ] + } + }, + "required": [ + "_tag", + "error", + "code" + ], + "additionalProperties": false + }, + "CaptureRateLimitedError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CaptureRateLimitedError" + ] + }, + "error": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "retry_after_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "error", + "code" + ], + "additionalProperties": false + }, + "CaptureDependencyUnavailableError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CaptureDependencyUnavailableError" + ] + }, + "error": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "code": { + "type": "string", + "enum": [ + "dependency_unavailable" + ] + } + }, + "required": [ + "_tag", + "error", + "code" + ], + "additionalProperties": false + }, + "CaptureInternalServerError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CaptureInternalServerError" + ] + }, + "error": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "code": { + "type": "string", + "enum": [ + "internal_error" + ] + } + }, + "required": [ + "_tag", + "error", + "code" + ], + "additionalProperties": false + } + }, + "securitySchemes": {} + }, + "security": [], + "tags": [ + { + "name": "event_capture" + } + ] +} diff --git a/packages/generated-clients/openapi/production/core.json b/packages/generated-clients/openapi/production/core.json new file mode 100644 index 000000000..e8aefec47 --- /dev/null +++ b/packages/generated-clients/openapi/production/core.json @@ -0,0 +1,8086 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Api", + "version": "0.0.1" + }, + "paths": { + "/api/v1/auth/session": { + "get": { + "tags": [ + "auth" + ], + "operationId": "auth.session", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "method": { + "anyOf": [ + { + "type": "string", + "enum": [ + "api-key" + ] + }, + { + "type": "string", + "enum": [ + "publishable-key" + ] + }, + { + "type": "string", + "enum": [ + "secret-key" + ] + } + ] + }, + "name": { + "type": "string" + }, + "organizations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "slug" + ], + "additionalProperties": false + } + }, + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "organizationId", + "slug" + ], + "additionalProperties": false + } + } + }, + "required": [ + "method", + "name", + "organizations", + "projects" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + } + } + } + } + } + }, + "/api/v1/api-keys": { + "post": { + "tags": [ + "api_keys" + ], + "operationId": "api_keys.createSecretKey", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "ApiKeyWithRawKey", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyWithRawKey" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "ApiKeyServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ApiKeyServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSecretKeyBody" + } + } + }, + "required": true + } + }, + "get": { + "tags": [ + "api_keys" + ], + "operationId": "api_keys.listApiKeys", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiKey" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "ApiKeyServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ApiKeyServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/api-keys/{apiKeyId}": { + "get": { + "tags": [ + "api_keys" + ], + "operationId": "api_keys.getApiKeyById", + "parameters": [ + { + "name": "apiKeyId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "ApiKey", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKey" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "ApiKeyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyNotFoundError" + } + } + } + }, + "500": { + "description": "ApiKeyServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ApiKeyServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + }, + "delete": { + "tags": [ + "api_keys" + ], + "operationId": "api_keys.deleteApiKey", + "parameters": [ + { + "name": "apiKeyId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "ApiKeyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyNotFoundError" + } + } + } + }, + "500": { + "description": "ApiKeyServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ApiKeyServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/api-keys/{apiKeyId}/rotate": { + "post": { + "tags": [ + "api_keys" + ], + "operationId": "api_keys.rotateSecretKey", + "parameters": [ + { + "name": "apiKeyId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "ApiKeyWithRawKey", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyWithRawKey" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "ApiKeyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyNotFoundError" + } + } + } + }, + "500": { + "description": "ApiKeyServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ApiKeyServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/customers": { + "post": { + "tags": [ + "customers" + ], + "operationId": "customers.createCustomer", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Customer", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Customer" + } + } + } + }, + "400": { + "description": "CustomerInvalidAnonymousIdError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CustomerInvalidAnonymousIdError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "CustomerServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CustomerServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCustomerBody" + } + } + }, + "required": true + } + }, + "get": { + "tags": [ + "customers" + ], + "operationId": "customers.listCustomers", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Customer" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "CustomerServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CustomerServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/customers/{customerId}": { + "get": { + "tags": [ + "customers" + ], + "operationId": "customers.getCustomerById", + "parameters": [ + { + "name": "customerId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Customer", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Customer" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "CustomerNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomerNotFoundError" + } + } + } + }, + "500": { + "description": "CustomerServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CustomerServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/customers/by-distinct-id/{distinctId}": { + "get": { + "tags": [ + "customers" + ], + "operationId": "customers.byDistinctId", + "parameters": [ + { + "name": "distinctId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Customer", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Customer" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "CustomerNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomerNotFoundError" + } + } + } + }, + "500": { + "description": "CustomerServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CustomerServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/organizations": { + "post": { + "tags": [ + "organizations" + ], + "operationId": "organizations.createOrganization", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Organization", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Organization" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "500": { + "description": "OrganizationServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/OrganizationServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrganizationBody" + } + } + }, + "required": true + } + } + }, + "/api/v1/perks": { + "get": { + "tags": [ + "perks" + ], + "operationId": "perks.listPerks", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Perk" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "PerkServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PerkServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/paywall-locations": { + "get": { + "tags": [ + "paywall_locations" + ], + "operationId": "paywall_locations.listPaywallLocations", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PaywallLocation" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "PaywallLocationServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PaywallLocationServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/projects": { + "post": { + "tags": [ + "projects" + ], + "operationId": "projects.createProject", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "AuthenticationError | ProjectServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/ProjectServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectBody" + } + } + }, + "required": true + } + } + }, + "/api/v1/projects/{organizationId}": { + "get": { + "tags": [ + "projects" + ], + "operationId": "projects.listProjects", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "ProjectServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/products": { + "get": { + "tags": [ + "products" + ], + "operationId": "products.listProducts", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Product" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "ProductServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProductServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/product-perks/by-product-id/{productId}": { + "get": { + "tags": [ + "product_perks" + ], + "operationId": "product_perks.listProductPerksByProductId", + "parameters": [ + { + "name": "productId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProductPerk" + } + } + } + } + }, + "400": { + "description": "ProductPerkValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProductPerkValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "ProductPerkServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProductPerkServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/sdk/get-customer": { + "get": { + "tags": [ + "sdk" + ], + "operationId": "sdk.getCustomer", + "parameters": [ + { + "name": "x-distinct-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-publishable-key", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-bundle-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-locale", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-client-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-is-backgrounded", + "in": "header", + "schema": { + "type": "string", + "enum": [ + "false" + ] + }, + "required": true + }, + { + "name": "x-is-debug-build", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-nonce", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-observer-mode", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-platform-brand", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-device", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-flavor", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "native" + ] + }, + { + "type": "string", + "enum": [ + "browser" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform-flavor-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-preferred-locales", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-sdk", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "react-native" + ] + }, + { + "type": "string", + "enum": [ + "web" + ] + } + ] + }, + "required": true + }, + { + "name": "x-sdk-version", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-storefront", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "SdkCustomer", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkCustomer" + } + } + } + }, + "400": { + "description": "SdkValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SdkValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "404": { + "description": "SdkCustomerNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkCustomerNotFoundError" + } + } + } + }, + "500": { + "description": "AuthenticationError | SdkServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/SdkServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/sdk/identify": { + "post": { + "tags": [ + "sdk" + ], + "operationId": "sdk.identify", + "parameters": [ + { + "name": "x-distinct-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-publishable-key", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-bundle-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-locale", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-client-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-is-backgrounded", + "in": "header", + "schema": { + "type": "string", + "enum": [ + "false" + ] + }, + "required": true + }, + { + "name": "x-is-debug-build", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-nonce", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-observer-mode", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-platform-brand", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-device", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-flavor", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "native" + ] + }, + { + "type": "string", + "enum": [ + "browser" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform-flavor-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-preferred-locales", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-sdk", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "react-native" + ] + }, + { + "type": "string", + "enum": [ + "web" + ] + } + ] + }, + "required": true + }, + { + "name": "x-sdk-version", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-storefront", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "SdkCustomer", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkCustomer" + } + } + } + }, + "400": { + "description": "SdkValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SdkValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "409": { + "description": "SdkCustomerAlreadyIdentifiedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkCustomerAlreadyIdentifiedError" + } + } + } + }, + "500": { + "description": "AuthenticationError | SdkServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/SdkServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkIdentifyBody" + } + } + }, + "required": true + } + } + }, + "/api/v1/sdk/sync-customer-attributes": { + "post": { + "tags": [ + "sdk" + ], + "operationId": "sdk.syncCustomerAttributes", + "parameters": [ + { + "name": "x-distinct-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-publishable-key", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-bundle-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-locale", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-client-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-is-backgrounded", + "in": "header", + "schema": { + "type": "string", + "enum": [ + "false" + ] + }, + "required": true + }, + { + "name": "x-is-debug-build", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-nonce", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-observer-mode", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-platform-brand", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-device", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-flavor", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "native" + ] + }, + { + "type": "string", + "enum": [ + "browser" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform-flavor-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-preferred-locales", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-sdk", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "react-native" + ] + }, + { + "type": "string", + "enum": [ + "web" + ] + } + ] + }, + "required": true + }, + { + "name": "x-sdk-version", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-storefront", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "SdkCustomer", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkCustomer" + } + } + } + }, + "400": { + "description": "SdkValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SdkValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "500": { + "description": "AuthenticationError | SdkServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/SdkServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkSyncCustomerAttributesBody" + } + } + }, + "required": true + } + } + }, + "/api/v1/sdk/sync-transaction": { + "post": { + "tags": [ + "sdk" + ], + "operationId": "sdk.syncTransaction", + "parameters": [ + { + "name": "x-distinct-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-publishable-key", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-bundle-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-locale", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-client-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-is-backgrounded", + "in": "header", + "schema": { + "type": "string", + "enum": [ + "false" + ] + }, + "required": true + }, + { + "name": "x-is-debug-build", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-nonce", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-observer-mode", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-platform-brand", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-device", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-flavor", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "native" + ] + }, + { + "type": "string", + "enum": [ + "browser" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform-flavor-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-preferred-locales", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-sdk", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "react-native" + ] + }, + { + "type": "string", + "enum": [ + "web" + ] + } + ] + }, + "required": true + }, + { + "name": "x-sdk-version", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-storefront", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "SdkSyncTransactionResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkSyncTransactionResponse" + } + } + } + }, + "400": { + "description": "SdkValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SdkValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "500": { + "description": "AuthenticationError | SdkServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/SdkServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "platform": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ios" + ] + }, + { + "type": "string", + "enum": [ + "android" + ] + } + ] + }, + "productId": { + "type": "string" + }, + "purchaseDate": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "purchaseToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "quantity": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "receipt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "transactionId": { + "type": "string" + } + }, + "required": [ + "platform", + "productId", + "purchaseDate", + "quantity", + "transactionId" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/v1/sdk/evaluate-flags": { + "post": { + "tags": [ + "sdk" + ], + "operationId": "sdk.evaluateFeatureFlags", + "parameters": [ + { + "name": "x-distinct-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-publishable-key", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-bundle-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-locale", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-client-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-is-backgrounded", + "in": "header", + "schema": { + "type": "string", + "enum": [ + "false" + ] + }, + "required": true + }, + { + "name": "x-is-debug-build", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-nonce", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-observer-mode", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-platform-brand", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-device", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-flavor", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "native" + ] + }, + { + "type": "string", + "enum": [ + "browser" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform-flavor-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-preferred-locales", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-sdk", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "react-native" + ] + }, + { + "type": "string", + "enum": [ + "web" + ] + } + ] + }, + "required": true + }, + { + "name": "x-sdk-version", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-storefront", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "SdkFeatureFlagsResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkFeatureFlagsResponse" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "500": { + "description": "AuthenticationError | SdkServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/SdkServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvaluateFeatureFlagsBody" + } + } + }, + "required": true + } + } + }, + "/api/v1/sdk/resolve-paywall": { + "post": { + "tags": [ + "sdk" + ], + "operationId": "sdk.resolvePaywall", + "parameters": [ + { + "name": "x-distinct-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-publishable-key", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-bundle-id", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-client-locale", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-client-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-is-backgrounded", + "in": "header", + "schema": { + "type": "string", + "enum": [ + "false" + ] + }, + "required": true + }, + { + "name": "x-is-debug-build", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-nonce", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-observer-mode", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true" + ] + }, + { + "type": "string", + "enum": [ + "false" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-platform-brand", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-device", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-flavor", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "native" + ] + }, + { + "type": "string", + "enum": [ + "browser" + ] + } + ] + }, + "required": true + }, + { + "name": "x-platform-flavor-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-platform-version", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-preferred-locales", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "x-sdk", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "react-native" + ] + }, + { + "type": "string", + "enum": [ + "web" + ] + } + ] + }, + "required": true + }, + { + "name": "x-sdk-version", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-storefront", + "in": "header", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SdkResolvedPaywall" + }, + { + "type": "null" + } + ] + } + } + } + }, + "400": { + "description": "SdkValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SdkValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "500": { + "description": "AuthenticationError | SdkServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/SdkServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdkResolvePaywallBody" + } + } + }, + "required": true + } + } + }, + "/api/v1/users/current": { + "get": { + "tags": [ + "users" + ], + "operationId": "users.getUser", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "User", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "500": { + "description": "AuthenticationError | UserServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/UserServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/payment-provider-configurations": { + "get": { + "tags": [ + "payment_provider_configurations" + ], + "operationId": "payment_provider_configurations.listPaymentProviderConfigurations", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PaymentProviderConfiguration" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "PaymentProviderConfigurationServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PaymentProviderConfigurationServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/payment-provider-products": { + "get": { + "tags": [ + "payment_provider_products" + ], + "operationId": "payment_provider_products.listPaymentProviderProducts", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PaymentProviderProduct" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "PaymentProviderProductServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PaymentProviderProductServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/changesets/deploy": { + "post": { + "tags": [ + "changesets" + ], + "operationId": "changesets.deployChangeset", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "DeployChangesetResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployChangesetResponse" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "AuthenticationError | ChangesetDeploymentServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/ChangesetDeploymentServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployChangesetBody" + } + } + }, + "required": true + } + } + }, + "/api/v1/webhooks/endpoints": { + "post": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.createWebhookEndpoint", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "WebhookEndpoint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpoint" + } + } + } + }, + "400": { + "description": "WebhookValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWebhookEndpointBody" + } + } + }, + "required": true + } + }, + "get": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.listWebhookEndpoints", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookEndpoint" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/webhooks/endpoints/{endpointId}": { + "get": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.getWebhookEndpoint", + "parameters": [ + { + "name": "endpointId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "WebhookEndpoint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpoint" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "WebhookEndpointNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointNotFoundError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + }, + "patch": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.updateWebhookEndpoint", + "parameters": [ + { + "name": "endpointId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "WebhookEndpoint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpoint" + } + } + } + }, + "400": { + "description": "WebhookValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "WebhookEndpointNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointNotFoundError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWebhookEndpointBody" + } + } + }, + "required": true + } + }, + "delete": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.deleteWebhookEndpoint", + "parameters": [ + { + "name": "endpointId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "WebhookEndpointNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointNotFoundError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/webhooks/endpoints/{endpointId}/rotate-secret": { + "post": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.rotateWebhookSecret", + "parameters": [ + { + "name": "endpointId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "WebhookEndpoint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpoint" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "WebhookEndpointNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointNotFoundError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/webhooks/endpoints/{endpointId}/test": { + "post": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.testWebhookEndpoint", + "parameters": [ + { + "name": "endpointId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "WebhookDelivery", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookDelivery" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "WebhookEndpointNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointNotFoundError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/webhooks/deliveries": { + "get": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.listWebhookDeliveries", + "parameters": [], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookDelivery" + } + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/webhooks/deliveries/{deliveryId}": { + "get": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.getWebhookDelivery", + "parameters": [ + { + "name": "deliveryId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "WebhookDeliveryWithAttempts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookDeliveryWithAttempts" + } + } + } + }, + "400": { + "description": "The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "WebhookDeliveryNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookDeliveryNotFoundError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + }, + "/api/v1/webhooks/deliveries/{deliveryId}/retry": { + "post": { + "tags": [ + "webhooks" + ], + "operationId": "webhooks.retryWebhookDelivery", + "parameters": [ + { + "name": "deliveryId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "apiKey": [] + }, + { + "betterAuthCookie": [] + }, + { + "publishableKey": [] + }, + { + "secretKey": [] + } + ], + "responses": { + "200": { + "description": "WebhookDelivery", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookDelivery" + } + } + } + }, + "400": { + "description": "WebhookValidationError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookValidationError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "403": { + "description": "ActionForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionForbiddenError" + } + } + } + }, + "404": { + "description": "WebhookDeliveryNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookDeliveryNotFoundError" + } + } + } + }, + "500": { + "description": "WebhookServiceError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookServiceError" + }, + { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthenticationError" + }, + { + "$ref": "#/components/schemas/NotAuthenticatedError" + } + ] + } + ] + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ActionForbiddenError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ActionForbiddenError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "AuthenticationError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AuthenticationError" + ] + }, + "cause": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause", + "message" + ], + "additionalProperties": false + }, + "NotAuthenticatedError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "NotAuthenticatedError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "effect_HttpApiSchemaError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "HttpApiSchemaError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "CreateSecretKeyBody": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "projectId": { + "type": "string" + } + }, + "required": [ + "name", + "projectId" + ], + "additionalProperties": false + }, + "ApiKeyWithRawKey": { + "type": "object", + "properties": { + "end": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isPublic": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "prefix": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "rawKey": { + "type": "string" + } + }, + "required": [ + "end", + "id", + "isPublic", + "name", + "prefix", + "projectId", + "rawKey" + ], + "additionalProperties": false + }, + "ApiKeyServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ApiKeyServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "ApiKey": { + "type": "object", + "properties": { + "end": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isPublic": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "prefix": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "rawKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "end", + "id", + "isPublic", + "name", + "prefix", + "projectId" + ], + "additionalProperties": false + }, + "ApiKeyNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ApiKeyNotFoundError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "CreateCustomerBody": { + "type": "object", + "properties": { + "distinctId": { + "type": "string" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "distinctId" + ], + "additionalProperties": false + }, + "Customer": { + "type": "object", + "properties": { + "customerId": { + "type": "string" + }, + "distinctId": { + "type": "string" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "customerId", + "distinctId", + "email", + "name" + ], + "additionalProperties": false + }, + "CustomerInvalidAnonymousIdError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CustomerInvalidAnonymousIdError" + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + } + }, + "required": [ + "_tag", + "id" + ], + "additionalProperties": false + }, + "CustomerServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CustomerServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "CustomerNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CustomerNotFoundError" + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + } + }, + "required": [ + "_tag", + "id" + ], + "additionalProperties": false + }, + "CreateOrganizationBody": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "Organization": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "slug" + ], + "additionalProperties": false + }, + "OrganizationServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "OrganizationServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "Perk": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "projectId", + "slug" + ], + "additionalProperties": false + }, + "PerkServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PerkServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "PaywallLocation": { + "type": "object", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name", + "projectId", + "slug" + ], + "additionalProperties": false + }, + "PaywallLocationServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PaywallLocationServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "CreateProjectBody": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "organizationId": { + "type": "string" + } + }, + "required": [ + "name", + "organizationId" + ], + "additionalProperties": false + }, + "Project": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "slug" + ], + "additionalProperties": false + }, + "ProjectServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProjectServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "Product": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "type": { + "anyOf": [ + { + "type": "string", + "enum": [ + "subscription" + ] + }, + { + "type": "string", + "enum": [ + "one-time" + ] + }, + { + "type": "string", + "enum": [ + "one-time-consumable" + ] + } + ] + } + }, + "required": [ + "id", + "name", + "projectId", + "slug", + "type" + ], + "additionalProperties": false + }, + "ProductServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProductServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "ProductPerk": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "perkId": { + "type": "string" + }, + "productId": { + "type": "string" + } + }, + "required": [ + "id", + "perkId", + "productId" + ], + "additionalProperties": false + }, + "ProductPerkServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProductPerkServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "ProductPerkValidationError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProductPerkValidationError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "SdkCustomer": { + "type": "object", + "properties": { + "customerId": { + "type": "string" + }, + "distinctId": { + "type": "string" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "customerId", + "distinctId", + "email", + "name" + ], + "additionalProperties": false + }, + "SdkServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SdkServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "SdkCustomerNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SdkCustomerNotFoundError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "SdkValidationError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SdkValidationError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "SdkIdentifyBody": { + "type": "object", + "properties": { + "distinctId": { + "type": "string" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "traits": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "distinctId" + ], + "additionalProperties": false + }, + "SdkCustomerAlreadyIdentifiedError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SdkCustomerAlreadyIdentifiedError" + ] + }, + "distinctId": { + "type": "string" + } + }, + "required": [ + "_tag", + "distinctId" + ], + "additionalProperties": false + }, + "SdkSyncCustomerAttributesBody": { + "type": "object", + "properties": { + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "traits": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "SdkSyncTransactionResponse": { + "type": "object", + "properties": { + "accepted": { + "type": "boolean" + } + }, + "required": [ + "accepted" + ], + "additionalProperties": false + }, + "EvaluateFeatureFlagsBody": { + "type": "object", + "properties": { + "flagKeys": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "SdkFeatureFlagResult": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "payload": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "null" + } + ] + }, + "variantKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "enabled", + "key", + "payload", + "variantKey" + ], + "additionalProperties": false + }, + "SdkFeatureFlagsResponse": { + "type": "object", + "properties": { + "flags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SdkFeatureFlagResult" + } + } + }, + "required": [ + "flags" + ], + "additionalProperties": false + }, + "SdkResolvePaywallBody": { + "type": "object", + "properties": { + "locationSlug": { + "type": "string" + } + }, + "required": [ + "locationSlug" + ], + "additionalProperties": false + }, + "SdkResolvedPaywallShowing": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "paywall": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "slug" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "paywallId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "paywallRelease": { + "anyOf": [ + { + "type": "object", + "properties": { + "htmlUrl": { + "type": "string" + }, + "publishedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "releaseId": { + "type": "string" + }, + "version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + } + }, + "required": [ + "htmlUrl", + "publishedAt", + "releaseId", + "version" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "paywallReleaseId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "type": "string" + }, + "type": { + "anyOf": [ + { + "type": "string", + "enum": [ + "paywall_release" + ] + }, + { + "type": "string", + "enum": [ + "feature_flag" + ] + } + ] + } + }, + "required": [ + "id", + "paywall", + "paywallId", + "paywallRelease", + "paywallReleaseId", + "startedAt", + "type" + ], + "additionalProperties": false + }, + "SdkResolvedPaywall": { + "type": "object", + "properties": { + "location": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "slug" + ], + "additionalProperties": false + }, + "showing": { + "$ref": "#/components/schemas/SdkResolvedPaywallShowing" + } + }, + "required": [ + "location", + "showing" + ], + "additionalProperties": false + }, + "User": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "email": { + "type": "string" + }, + "emailVerified": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "organizations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "logo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "logo", + "name", + "slug" + ], + "additionalProperties": false + } + }, + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "logo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "logo", + "name", + "organizationId", + "slug" + ], + "additionalProperties": false + } + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "createdAt", + "email", + "emailVerified", + "id", + "image", + "name", + "organizations", + "projects", + "updatedAt" + ], + "additionalProperties": false + }, + "UserServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "UserServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "PaymentProviderConfiguration": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "providerId": { + "type": "string" + } + }, + "required": [ + "enabled", + "id", + "name", + "projectId", + "providerId" + ], + "additionalProperties": false + }, + "PaymentProviderConfigurationServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PaymentProviderConfigurationServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "PaymentProviderProduct": { + "type": "object", + "properties": { + "configuration": { + "type": "object", + "additionalProperties": { + "type": "null" + } + }, + "id": { + "type": "string" + }, + "paymentProviderConfigurationId": { + "type": "string" + }, + "productId": { + "type": "string" + }, + "providerId": { + "type": "string" + } + }, + "required": [ + "configuration", + "id", + "paymentProviderConfigurationId", + "productId", + "providerId" + ], + "additionalProperties": false + }, + "PaymentProviderProductServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PaymentProviderProductServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "DeployChangesetBody": { + "type": "object", + "properties": { + "changeset": { + "type": "object", + "properties": { + "changes": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "create-paywall-location" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "description": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "name", + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "update-paywall-location" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "description": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "name", + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "archive-paywall-location" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "slug": { + "type": "string" + } + }, + "required": [ + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "create-perk" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "name", + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "update-perk" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "name", + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "delete-perk" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "slug": { + "type": "string" + } + }, + "required": [ + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "create-product" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "name", + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "update-product" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "name", + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "delete-product" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "slug": { + "type": "string" + } + }, + "required": [ + "slug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "create-product-perk" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "perkSlug": { + "type": "string" + }, + "productSlug": { + "type": "string" + } + }, + "required": [ + "perkSlug", + "productSlug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "delete-product-perk" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "perkSlug": { + "type": "string" + }, + "productSlug": { + "type": "string" + } + }, + "required": [ + "perkSlug", + "productSlug" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "create-payment-provider-product" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "configuration": { + "type": "object", + "additionalProperties": { + "type": "null" + } + }, + "productSlug": { + "type": "string" + }, + "providerId": { + "type": "string" + } + }, + "required": [ + "configuration", + "productSlug", + "providerId" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "update-payment-provider-product" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "configuration": { + "type": "object", + "additionalProperties": { + "type": "null" + } + }, + "productSlug": { + "type": "string" + }, + "providerId": { + "type": "string" + } + }, + "required": [ + "configuration", + "productSlug", + "providerId" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "changeType": { + "type": "string", + "enum": [ + "delete-payment-provider-product" + ] + }, + "key": { + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "productSlug": { + "type": "string" + }, + "providerId": { + "type": "string" + } + }, + "required": [ + "productSlug", + "providerId" + ], + "additionalProperties": false + } + }, + "required": [ + "changeType", + "key", + "payload" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "changes" + ], + "additionalProperties": false + } + }, + "required": [ + "changeset" + ], + "additionalProperties": false + }, + "DeployChangesetResponse": { + "type": "object", + "properties": { + "deploymentId": { + "type": "string" + } + }, + "required": [ + "deploymentId" + ], + "additionalProperties": false + }, + "ChangesetDeploymentServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ChangesetDeploymentServiceError" + ] + }, + "cause": { + "type": "null" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "CreateWebhookEndpointBody": { + "type": "object", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "events", + "name", + "url" + ], + "additionalProperties": false + }, + "WebhookEndpoint": { + "type": "object", + "properties": { + "consecutiveFailures": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "events": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "enum": [ + "customer.created" + ] + }, + { + "type": "string", + "enum": [ + "customer.updated" + ] + }, + { + "type": "string", + "enum": [ + "customer.deleted" + ] + }, + { + "type": "string", + "enum": [ + "subscription.created" + ] + }, + { + "type": "string", + "enum": [ + "subscription.renewed" + ] + }, + { + "type": "string", + "enum": [ + "subscription.cancelled" + ] + }, + { + "type": "string", + "enum": [ + "subscription.expired" + ] + }, + { + "type": "string", + "enum": [ + "purchase.completed" + ] + }, + { + "type": "string", + "enum": [ + "purchase.refunded" + ] + } + ] + } + }, + "id": { + "type": "string" + }, + "lastSuccessAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "secret": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "active" + ] + }, + { + "type": "string", + "enum": [ + "disabled" + ] + }, + { + "type": "string", + "enum": [ + "failed" + ] + } + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "consecutiveFailures", + "createdAt", + "description", + "events", + "id", + "lastSuccessAt", + "name", + "projectId", + "secret", + "status", + "url" + ], + "additionalProperties": false + }, + "WebhookValidationError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "WebhookValidationError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "WebhookServiceError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "WebhookServiceError" + ] + }, + "cause": { + "type": "string" + } + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": false + }, + "WebhookEndpointNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "WebhookEndpointNotFoundError" + ] + }, + "endpointId": { + "type": "string" + } + }, + "required": [ + "_tag", + "endpointId" + ], + "additionalProperties": false + }, + "UpdateWebhookEndpointBody": { + "type": "object", + "properties": { + "description": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "events": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "status": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "enum": [ + "active" + ] + }, + { + "type": "string", + "enum": [ + "disabled" + ] + } + ] + }, + { + "type": "null" + } + ] + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "WebhookDelivery": { + "type": "object", + "properties": { + "attemptCount": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "eventOccurredAt": { + "type": "string" + }, + "eventType": { + "type": "string" + }, + "id": { + "type": "string" + }, + "maxAttempts": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "nextAttemptAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "payload": { + "type": "null" + }, + "projectId": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "pending" + ] + }, + { + "type": "string", + "enum": [ + "in_progress" + ] + }, + { + "type": "string", + "enum": [ + "succeeded" + ] + }, + { + "type": "string", + "enum": [ + "failed" + ] + }, + { + "type": "string", + "enum": [ + "exhausted" + ] + } + ] + }, + "webhookEndpointId": { + "type": "string" + } + }, + "required": [ + "attemptCount", + "completedAt", + "createdAt", + "eventOccurredAt", + "eventType", + "id", + "maxAttempts", + "nextAttemptAt", + "payload", + "projectId", + "status", + "webhookEndpointId" + ], + "additionalProperties": false + }, + "WebhookDeliveryAttempt": { + "type": "object", + "properties": { + "attemptNumber": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "durationMs": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "null" + } + ] + }, + "errorMessage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "responseBody": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "statusCode": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "null" + } + ] + }, + "succeeded": { + "type": "boolean" + } + }, + "required": [ + "attemptNumber", + "createdAt", + "durationMs", + "errorMessage", + "id", + "responseBody", + "statusCode", + "succeeded" + ], + "additionalProperties": false + }, + "WebhookDeliveryWithAttempts": { + "type": "object", + "properties": { + "attemptCount": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "attempts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookDeliveryAttempt" + } + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "eventOccurredAt": { + "type": "string" + }, + "eventType": { + "type": "string" + }, + "id": { + "type": "string" + }, + "maxAttempts": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "nextAttemptAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "payload": { + "type": "null" + }, + "projectId": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "pending" + ] + }, + { + "type": "string", + "enum": [ + "in_progress" + ] + }, + { + "type": "string", + "enum": [ + "succeeded" + ] + }, + { + "type": "string", + "enum": [ + "failed" + ] + }, + { + "type": "string", + "enum": [ + "exhausted" + ] + } + ] + }, + "webhookEndpointId": { + "type": "string" + } + }, + "required": [ + "attemptCount", + "attempts", + "completedAt", + "createdAt", + "eventOccurredAt", + "eventType", + "id", + "maxAttempts", + "nextAttemptAt", + "payload", + "projectId", + "status", + "webhookEndpointId" + ], + "additionalProperties": false + }, + "WebhookDeliveryNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "WebhookDeliveryNotFoundError" + ] + }, + "deliveryId": { + "type": "string" + } + }, + "required": [ + "_tag", + "deliveryId" + ], + "additionalProperties": false + } + }, + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "name": "x-api-key", + "in": "header" + }, + "betterAuthCookie": { + "type": "apiKey", + "name": "__Secure-better-auth.session_token", + "in": "cookie" + }, + "publishableKey": { + "type": "apiKey", + "name": "x-publishable-key", + "in": "header" + }, + "secretKey": { + "type": "apiKey", + "name": "x-secret-key", + "in": "header" + } + } + }, + "security": [], + "tags": [ + { + "name": "auth" + }, + { + "name": "api_keys" + }, + { + "name": "customers" + }, + { + "name": "organizations" + }, + { + "name": "perks" + }, + { + "name": "paywall_locations" + }, + { + "name": "projects" + }, + { + "name": "products" + }, + { + "name": "product_perks" + }, + { + "name": "sdk" + }, + { + "name": "users" + }, + { + "name": "payment_provider_configurations" + }, + { + "name": "payment_provider_products" + }, + { + "name": "changesets" + }, + { + "name": "webhooks" + } + ] +} diff --git a/packages/generated-clients/openapi/production/event-capture.json b/packages/generated-clients/openapi/production/event-capture.json new file mode 100644 index 000000000..a219e4a0b --- /dev/null +++ b/packages/generated-clients/openapi/production/event-capture.json @@ -0,0 +1,787 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Api", + "version": "0.0.1" + }, + "paths": { + "/capture": { + "post": { + "tags": [ + "event_capture" + ], + "operationId": "event_capture.capture", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "CaptureAcceptedResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureAcceptedResponse" + } + } + } + }, + "400": { + "description": "CaptureInvalidRequestError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CaptureInvalidRequestError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "401": { + "description": "CaptureUnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureUnauthorizedError" + } + } + } + }, + "413": { + "description": "CapturePayloadTooLargeError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CapturePayloadTooLargeError" + } + } + } + }, + "429": { + "description": "CaptureRateLimitedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureRateLimitedError" + } + } + } + }, + "500": { + "description": "CaptureInternalServerError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureInternalServerError" + } + } + } + }, + "503": { + "description": "CaptureDependencyUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureDependencyUnavailableError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "event": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "context": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Union_" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Union_1" + } + }, + "distinct_id": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "session_id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "format": "date-time" + } + ] + }, + { + "type": "null" + } + ] + }, + "sent_at": { + "type": "string", + "allOf": [ + { + "format": "date-time" + } + ] + }, + "token": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + } + }, + "required": [ + "uuid", + "event", + "context", + "properties", + "distinct_id", + "sent_at", + "token" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/batch": { + "post": { + "tags": [ + "event_capture" + ], + "operationId": "event_capture.batch", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "CaptureAcceptedResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureAcceptedResponse" + } + } + } + }, + "400": { + "description": "CaptureInvalidRequestError | The request or response did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CaptureInvalidRequestError" + }, + { + "$ref": "#/components/schemas/effect_HttpApiSchemaError" + } + ] + } + } + } + }, + "401": { + "description": "CaptureUnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureUnauthorizedError" + } + } + } + }, + "413": { + "description": "CapturePayloadTooLargeError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CapturePayloadTooLargeError" + } + } + } + }, + "429": { + "description": "CaptureRateLimitedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureRateLimitedError" + } + } + } + }, + "500": { + "description": "CaptureInternalServerError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureInternalServerError" + } + } + } + }, + "503": { + "description": "CaptureDependencyUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureDependencyUnavailableError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "events": { + "type": "array", + "prefixItems": [ + { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "event": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "context": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Union_" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Union_1" + } + }, + "distinct_id": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "session_id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "format": "date-time" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "uuid", + "event", + "context", + "properties", + "distinct_id" + ], + "additionalProperties": false + } + ], + "minItems": 1, + "items": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "event": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "context": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Union_" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Union_1" + } + }, + "distinct_id": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "session_id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "format": "date-time" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "uuid", + "event", + "context", + "properties", + "distinct_id" + ], + "additionalProperties": false + } + }, + "sent_at": { + "type": "string", + "allOf": [ + { + "format": "date-time" + } + ] + }, + "token": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + } + }, + "required": [ + "events", + "sent_at", + "token" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + } + }, + "components": { + "schemas": { + "Union_": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/Union_" + } + }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Union_" + } + } + ] + }, + "Union_1": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/Union_1" + } + }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Union_1" + } + } + ] + }, + "CaptureAcceptedResponse": { + "type": "object", + "properties": { + "accepted": { + "type": "integer" + }, + "rejected": { + "type": "integer" + } + }, + "required": [ + "accepted", + "rejected" + ], + "additionalProperties": false + }, + "CaptureInvalidRequestError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CaptureInvalidRequestError" + ] + }, + "error": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "code": { + "type": "string", + "enum": [ + "invalid_request" + ] + } + }, + "required": [ + "_tag", + "error", + "code" + ], + "additionalProperties": false + }, + "effect_HttpApiSchemaError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "HttpApiSchemaError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "CaptureUnauthorizedError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CaptureUnauthorizedError" + ] + }, + "error": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "code": { + "type": "string", + "enum": [ + "unauthorized" + ] + } + }, + "required": [ + "_tag", + "error", + "code" + ], + "additionalProperties": false + }, + "CapturePayloadTooLargeError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CapturePayloadTooLargeError" + ] + }, + "error": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "code": { + "type": "string", + "enum": [ + "payload_too_large" + ] + } + }, + "required": [ + "_tag", + "error", + "code" + ], + "additionalProperties": false + }, + "CaptureRateLimitedError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CaptureRateLimitedError" + ] + }, + "error": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "retry_after_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "error", + "code" + ], + "additionalProperties": false + }, + "CaptureDependencyUnavailableError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CaptureDependencyUnavailableError" + ] + }, + "error": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "code": { + "type": "string", + "enum": [ + "dependency_unavailable" + ] + } + }, + "required": [ + "_tag", + "error", + "code" + ], + "additionalProperties": false + }, + "CaptureInternalServerError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CaptureInternalServerError" + ] + }, + "error": { + "type": "string", + "allOf": [ + { + "minLength": 1 + } + ] + }, + "code": { + "type": "string", + "enum": [ + "internal_error" + ] + } + }, + "required": [ + "_tag", + "error", + "code" + ], + "additionalProperties": false + } + }, + "securitySchemes": {} + }, + "security": [], + "tags": [ + { + "name": "event_capture" + } + ] +} diff --git a/packages/generated-clients/package.json b/packages/generated-clients/package.json new file mode 100644 index 000000000..de8743462 --- /dev/null +++ b/packages/generated-clients/package.json @@ -0,0 +1,22 @@ +{ + "name": "@voidhash/generated-clients", + "version": "0.0.1-alpha.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./core": "./src/core/index.ts", + "./event-capture": "./src/event-capture/index.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": {}, + "devDependencies": { + "@voidhash/tsconfig": "workspace:*", + "typescript": "5.6.3" + }, + "peerDependencies": { + "effect": "catalog:" + } +} diff --git a/packages/generated-clients/src/core/generated.ts b/packages/generated-clients/src/core/generated.ts new file mode 100644 index 000000000..d936f4b12 --- /dev/null +++ b/packages/generated-clients/src/core/generated.ts @@ -0,0 +1,1230 @@ +import type * as HttpClient from "effect/unstable/http/HttpClient" +import * as HttpClientError from "effect/unstable/http/HttpClientError" +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse" +import * as Data from "effect/Data" +import * as Effect from "effect/Effect" + +export type AuthSession200MethodEnum = "secret-key" + +export interface AuthSession200 { + readonly "method": AuthSession200MethodEnum | AuthSession200MethodEnum | AuthSession200MethodEnum; + readonly "name": string; + readonly "organizations": ReadonlyArray<{ + readonly "id": string; + readonly "name": string; + readonly "slug": string +}>; + readonly "projects": ReadonlyArray<{ + readonly "id": string; + readonly "name": string; + readonly "organizationId": string; + readonly "slug": string +}> +} + +export type EffectHttpApiSchemaErrorTag = "HttpApiSchemaError" + +export interface EffectHttpApiSchemaError { + readonly "_tag": EffectHttpApiSchemaErrorTag; + readonly "message": string +} + +export type ActionForbiddenErrorTag = "ActionForbiddenError" + +export interface ActionForbiddenError { + readonly "_tag": ActionForbiddenErrorTag; + readonly "message": string +} + +export type AuthenticationErrorTag = "AuthenticationError" + +export interface AuthenticationError { + readonly "_tag": AuthenticationErrorTag; + readonly "cause": string; + readonly "message": string +} + +export type NotAuthenticatedErrorTag = "NotAuthenticatedError" + +export interface NotAuthenticatedError { + readonly "_tag": NotAuthenticatedErrorTag; + readonly "message": string +} + +export type AuthSession500 = AuthenticationError | NotAuthenticatedError + +export interface ApiKey { + readonly "end": string; + readonly "id": string; + readonly "isPublic": boolean; + readonly "name": string; + readonly "prefix": string; + readonly "projectId": string; + readonly "rawKey"?: string | null | undefined +} + +export type ApiKeysListApiKeys200 = ReadonlyArray + +export type ApiKeyServiceErrorTag = "ApiKeyServiceError" + +export interface ApiKeyServiceError { + readonly "_tag": ApiKeyServiceErrorTag; + readonly "cause": string +} + +export type ApiKeysListApiKeys500 = ApiKeyServiceError | AuthenticationError | NotAuthenticatedError + +export interface CreateSecretKeyBody { + readonly "name": string; + readonly "projectId": string +} + +export interface ApiKeyWithRawKey { + readonly "end": string; + readonly "id": string; + readonly "isPublic": boolean; + readonly "name": string; + readonly "prefix": string; + readonly "projectId": string; + readonly "rawKey": string +} + +export type ApiKeysCreateSecretKey500 = ApiKeyServiceError | AuthenticationError | NotAuthenticatedError + +export type ApiKeyNotFoundErrorTag = "ApiKeyNotFoundError" + +export interface ApiKeyNotFoundError { + readonly "_tag": ApiKeyNotFoundErrorTag; + readonly "message": string +} + +export type ApiKeysGetApiKeyById500 = ApiKeyServiceError | AuthenticationError | NotAuthenticatedError + +export type ApiKeysDeleteApiKey500 = ApiKeyServiceError | AuthenticationError | NotAuthenticatedError + +export type ApiKeysRotateSecretKey500 = ApiKeyServiceError | AuthenticationError | NotAuthenticatedError + +export interface Customer { + readonly "customerId": string; + readonly "distinctId": string; + readonly "email": string | null; + readonly "name": string | null +} + +export type CustomersListCustomers200 = ReadonlyArray + +export type CustomerServiceErrorTag = "CustomerServiceError" + +export interface CustomerServiceError { + readonly "_tag": CustomerServiceErrorTag; + readonly "cause": string +} + +export type CustomersListCustomers500 = CustomerServiceError | AuthenticationError | NotAuthenticatedError + +export interface CreateCustomerBody { + readonly "distinctId": string; + readonly "email"?: string | null | undefined; + readonly "name"?: string | null | undefined +} + +export type CustomerInvalidAnonymousIdErrorTag = "CustomerInvalidAnonymousIdError" + +export interface CustomerInvalidAnonymousIdError { + readonly "_tag": CustomerInvalidAnonymousIdErrorTag; + readonly "id": string +} + +export type CustomersCreateCustomer400 = CustomerInvalidAnonymousIdError | EffectHttpApiSchemaError + +export type CustomersCreateCustomer500 = CustomerServiceError | AuthenticationError | NotAuthenticatedError + +export type CustomerNotFoundErrorTag = "CustomerNotFoundError" + +export interface CustomerNotFoundError { + readonly "_tag": CustomerNotFoundErrorTag; + readonly "id": string +} + +export type CustomersGetCustomerById500 = CustomerServiceError | AuthenticationError | NotAuthenticatedError + +export type CustomersByDistinctId500 = CustomerServiceError | AuthenticationError | NotAuthenticatedError + +export interface CreateOrganizationBody { + readonly "name": string +} + +export interface Organization { + readonly "id": string; + readonly "name": string; + readonly "slug": string +} + +export type OrganizationServiceErrorTag = "OrganizationServiceError" + +export interface OrganizationServiceError { + readonly "_tag": OrganizationServiceErrorTag; + readonly "cause": string +} + +export type OrganizationsCreateOrganization500 = OrganizationServiceError | AuthenticationError | NotAuthenticatedError + +export interface Perk { + readonly "id": string; + readonly "name": string; + readonly "projectId": string; + readonly "slug": string +} + +export type PerksListPerks200 = ReadonlyArray + +export type PerkServiceErrorTag = "PerkServiceError" + +export interface PerkServiceError { + readonly "_tag": PerkServiceErrorTag; + readonly "cause": string +} + +export type PerksListPerks500 = PerkServiceError | AuthenticationError | NotAuthenticatedError + +export interface PaywallLocation { + readonly "description": string | null; + readonly "id": string; + readonly "name": string; + readonly "projectId": string; + readonly "slug": string +} + +export type PaywallLocationsListPaywallLocations200 = ReadonlyArray + +export type PaywallLocationServiceErrorTag = "PaywallLocationServiceError" + +export interface PaywallLocationServiceError { + readonly "_tag": PaywallLocationServiceErrorTag; + readonly "cause": string +} + +export type PaywallLocationsListPaywallLocations500 = PaywallLocationServiceError | AuthenticationError | NotAuthenticatedError + +export interface CreateProjectBody { + readonly "name": string; + readonly "organizationId": string +} + +export interface Project { + readonly "id": string; + readonly "name": string; + readonly "slug": string +} + +export type ProjectServiceErrorTag = "ProjectServiceError" + +export interface ProjectServiceError { + readonly "_tag": ProjectServiceErrorTag; + readonly "cause": string +} + +export type ProjectsCreateProject500 = AuthenticationError | ProjectServiceError | AuthenticationError | NotAuthenticatedError + +export type ProjectsListProjects200 = ReadonlyArray + +export type ProjectsListProjects500 = ProjectServiceError | AuthenticationError | NotAuthenticatedError + +export type ProductTypeEnum = "one-time-consumable" + +export interface Product { + readonly "id": string; + readonly "name": string; + readonly "projectId": string; + readonly "slug": string; + readonly "type": ProductTypeEnum | ProductTypeEnum | ProductTypeEnum +} + +export type ProductsListProducts200 = ReadonlyArray + +export type ProductServiceErrorTag = "ProductServiceError" + +export interface ProductServiceError { + readonly "_tag": ProductServiceErrorTag; + readonly "cause": string +} + +export type ProductsListProducts500 = ProductServiceError | AuthenticationError | NotAuthenticatedError + +export interface ProductPerk { + readonly "id": string; + readonly "perkId": string; + readonly "productId": string +} + +export type ProductPerksListProductPerksByProductId200 = ReadonlyArray + +export type ProductPerkValidationErrorTag = "ProductPerkValidationError" + +export interface ProductPerkValidationError { + readonly "_tag": ProductPerkValidationErrorTag; + readonly "message": string +} + +export type ProductPerksListProductPerksByProductId400 = ProductPerkValidationError | EffectHttpApiSchemaError + +export type ProductPerkServiceErrorTag = "ProductPerkServiceError" + +export interface ProductPerkServiceError { + readonly "_tag": ProductPerkServiceErrorTag; + readonly "cause": string +} + +export type ProductPerksListProductPerksByProductId500 = ProductPerkServiceError | AuthenticationError | NotAuthenticatedError + +export type SdkGetCustomerParamsXIsBackgrounded = "false" + +export type SdkGetCustomerParamsXIsDebugBuildEnum = "false" + +export type SdkGetCustomerParamsXObserverModeEnum = "false" + +export type SdkGetCustomerParamsXPlatformFlavorEnum = "browser" + +export type SdkGetCustomerParamsXSdkEnum = "web" + +export interface SdkGetCustomerParams { + readonly "x-distinct-id": string; + readonly "x-publishable-key": string; + readonly "x-client-bundle-id": string; + readonly "x-client-locale"?: string | null | undefined; + readonly "x-client-version"?: string | null | undefined; + readonly "x-is-backgrounded": SdkGetCustomerParamsXIsBackgrounded; + readonly "x-is-debug-build": SdkGetCustomerParamsXIsDebugBuildEnum | SdkGetCustomerParamsXIsDebugBuildEnum; + readonly "x-nonce"?: string | null | undefined; + readonly "x-observer-mode": SdkGetCustomerParamsXObserverModeEnum | SdkGetCustomerParamsXObserverModeEnum; + readonly "x-platform": string; + readonly "x-platform-brand"?: string | null | undefined; + readonly "x-platform-device"?: string | null | undefined; + readonly "x-platform-flavor": SdkGetCustomerParamsXPlatformFlavorEnum | SdkGetCustomerParamsXPlatformFlavorEnum; + readonly "x-platform-flavor-version"?: string | null | undefined; + readonly "x-platform-version"?: string | null | undefined; + readonly "x-preferred-locales"?: string | null | undefined; + readonly "x-sdk": SdkGetCustomerParamsXSdkEnum | SdkGetCustomerParamsXSdkEnum; + readonly "x-sdk-version": string; + readonly "x-storefront"?: string | null | undefined +} + +export interface SdkCustomer { + readonly "customerId": string; + readonly "distinctId": string; + readonly "email": string | null; + readonly "name": string | null +} + +export type SdkValidationErrorTag = "SdkValidationError" + +export interface SdkValidationError { + readonly "_tag": SdkValidationErrorTag; + readonly "message": string +} + +export type SdkGetCustomer400 = SdkValidationError | EffectHttpApiSchemaError + +export type SdkCustomerNotFoundErrorTag = "SdkCustomerNotFoundError" + +export interface SdkCustomerNotFoundError { + readonly "_tag": SdkCustomerNotFoundErrorTag; + readonly "message": string +} + +export type SdkServiceErrorTag = "SdkServiceError" + +export interface SdkServiceError { + readonly "_tag": SdkServiceErrorTag; + readonly "cause": string +} + +export type SdkGetCustomer500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError + +export type SdkIdentifyParamsXIsBackgrounded = "false" + +export type SdkIdentifyParamsXIsDebugBuildEnum = "false" + +export type SdkIdentifyParamsXObserverModeEnum = "false" + +export type SdkIdentifyParamsXPlatformFlavorEnum = "browser" + +export type SdkIdentifyParamsXSdkEnum = "web" + +export interface SdkIdentifyParams { + readonly "x-distinct-id": string; + readonly "x-publishable-key": string; + readonly "x-client-bundle-id": string; + readonly "x-client-locale"?: string | null | undefined; + readonly "x-client-version"?: string | null | undefined; + readonly "x-is-backgrounded": SdkIdentifyParamsXIsBackgrounded; + readonly "x-is-debug-build": SdkIdentifyParamsXIsDebugBuildEnum | SdkIdentifyParamsXIsDebugBuildEnum; + readonly "x-nonce"?: string | null | undefined; + readonly "x-observer-mode": SdkIdentifyParamsXObserverModeEnum | SdkIdentifyParamsXObserverModeEnum; + readonly "x-platform": string; + readonly "x-platform-brand"?: string | null | undefined; + readonly "x-platform-device"?: string | null | undefined; + readonly "x-platform-flavor": SdkIdentifyParamsXPlatformFlavorEnum | SdkIdentifyParamsXPlatformFlavorEnum; + readonly "x-platform-flavor-version"?: string | null | undefined; + readonly "x-platform-version"?: string | null | undefined; + readonly "x-preferred-locales"?: string | null | undefined; + readonly "x-sdk": SdkIdentifyParamsXSdkEnum | SdkIdentifyParamsXSdkEnum; + readonly "x-sdk-version": string; + readonly "x-storefront"?: string | null | undefined +} + +export interface SdkIdentifyBody { + readonly "distinctId": string; + readonly "email"?: string | null | undefined; + readonly "name"?: string | null | undefined; + readonly "traits"?: Record | null | undefined +} + +export type SdkIdentify400 = SdkValidationError | EffectHttpApiSchemaError + +export type SdkCustomerAlreadyIdentifiedErrorTag = "SdkCustomerAlreadyIdentifiedError" + +export interface SdkCustomerAlreadyIdentifiedError { + readonly "_tag": SdkCustomerAlreadyIdentifiedErrorTag; + readonly "distinctId": string +} + +export type SdkIdentify500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError + +export type SdkSyncCustomerAttributesParamsXIsBackgrounded = "false" + +export type SdkSyncCustomerAttributesParamsXIsDebugBuildEnum = "false" + +export type SdkSyncCustomerAttributesParamsXObserverModeEnum = "false" + +export type SdkSyncCustomerAttributesParamsXPlatformFlavorEnum = "browser" + +export type SdkSyncCustomerAttributesParamsXSdkEnum = "web" + +export interface SdkSyncCustomerAttributesParams { + readonly "x-distinct-id": string; + readonly "x-publishable-key": string; + readonly "x-client-bundle-id": string; + readonly "x-client-locale"?: string | null | undefined; + readonly "x-client-version"?: string | null | undefined; + readonly "x-is-backgrounded": SdkSyncCustomerAttributesParamsXIsBackgrounded; + readonly "x-is-debug-build": SdkSyncCustomerAttributesParamsXIsDebugBuildEnum | SdkSyncCustomerAttributesParamsXIsDebugBuildEnum; + readonly "x-nonce"?: string | null | undefined; + readonly "x-observer-mode": SdkSyncCustomerAttributesParamsXObserverModeEnum | SdkSyncCustomerAttributesParamsXObserverModeEnum; + readonly "x-platform": string; + readonly "x-platform-brand"?: string | null | undefined; + readonly "x-platform-device"?: string | null | undefined; + readonly "x-platform-flavor": SdkSyncCustomerAttributesParamsXPlatformFlavorEnum | SdkSyncCustomerAttributesParamsXPlatformFlavorEnum; + readonly "x-platform-flavor-version"?: string | null | undefined; + readonly "x-platform-version"?: string | null | undefined; + readonly "x-preferred-locales"?: string | null | undefined; + readonly "x-sdk": SdkSyncCustomerAttributesParamsXSdkEnum | SdkSyncCustomerAttributesParamsXSdkEnum; + readonly "x-sdk-version": string; + readonly "x-storefront"?: string | null | undefined +} + +export interface SdkSyncCustomerAttributesBody { + readonly "email"?: string | null | undefined; + readonly "name"?: string | null | undefined; + readonly "traits"?: Record | null | undefined +} + +export type SdkSyncCustomerAttributes400 = SdkValidationError | EffectHttpApiSchemaError + +export type SdkSyncCustomerAttributes500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError + +export type SdkSyncTransactionParamsXIsBackgrounded = "false" + +export type SdkSyncTransactionParamsXIsDebugBuildEnum = "false" + +export type SdkSyncTransactionParamsXObserverModeEnum = "false" + +export type SdkSyncTransactionParamsXPlatformFlavorEnum = "browser" + +export type SdkSyncTransactionParamsXSdkEnum = "web" + +export interface SdkSyncTransactionParams { + readonly "x-distinct-id": string; + readonly "x-publishable-key": string; + readonly "x-client-bundle-id": string; + readonly "x-client-locale"?: string | null | undefined; + readonly "x-client-version"?: string | null | undefined; + readonly "x-is-backgrounded": SdkSyncTransactionParamsXIsBackgrounded; + readonly "x-is-debug-build": SdkSyncTransactionParamsXIsDebugBuildEnum | SdkSyncTransactionParamsXIsDebugBuildEnum; + readonly "x-nonce"?: string | null | undefined; + readonly "x-observer-mode": SdkSyncTransactionParamsXObserverModeEnum | SdkSyncTransactionParamsXObserverModeEnum; + readonly "x-platform": string; + readonly "x-platform-brand"?: string | null | undefined; + readonly "x-platform-device"?: string | null | undefined; + readonly "x-platform-flavor": SdkSyncTransactionParamsXPlatformFlavorEnum | SdkSyncTransactionParamsXPlatformFlavorEnum; + readonly "x-platform-flavor-version"?: string | null | undefined; + readonly "x-platform-version"?: string | null | undefined; + readonly "x-preferred-locales"?: string | null | undefined; + readonly "x-sdk": SdkSyncTransactionParamsXSdkEnum | SdkSyncTransactionParamsXSdkEnum; + readonly "x-sdk-version": string; + readonly "x-storefront"?: string | null | undefined +} + +export type SdkSyncTransactionRequestPlatformEnum = "android" + +export type SdkSyncTransactionRequestPurchaseDateEnum = "-Infinity" + +export type SdkSyncTransactionRequestQuantityEnum = "-Infinity" + +export interface SdkSyncTransactionRequest { + readonly "platform": SdkSyncTransactionRequestPlatformEnum | SdkSyncTransactionRequestPlatformEnum; + readonly "productId": string; + readonly "purchaseDate": number | SdkSyncTransactionRequestPurchaseDateEnum | SdkSyncTransactionRequestPurchaseDateEnum | SdkSyncTransactionRequestPurchaseDateEnum; + readonly "purchaseToken"?: string | null | undefined; + readonly "quantity": number | SdkSyncTransactionRequestQuantityEnum | SdkSyncTransactionRequestQuantityEnum | SdkSyncTransactionRequestQuantityEnum; + readonly "receipt"?: string | null | undefined; + readonly "transactionId": string +} + +export interface SdkSyncTransactionResponse { + readonly "accepted": boolean +} + +export type SdkSyncTransaction400 = SdkValidationError | EffectHttpApiSchemaError + +export type SdkSyncTransaction500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError + +export type SdkEvaluateFeatureFlagsParamsXIsBackgrounded = "false" + +export type SdkEvaluateFeatureFlagsParamsXIsDebugBuildEnum = "false" + +export type SdkEvaluateFeatureFlagsParamsXObserverModeEnum = "false" + +export type SdkEvaluateFeatureFlagsParamsXPlatformFlavorEnum = "browser" + +export type SdkEvaluateFeatureFlagsParamsXSdkEnum = "web" + +export interface SdkEvaluateFeatureFlagsParams { + readonly "x-distinct-id": string; + readonly "x-publishable-key": string; + readonly "x-client-bundle-id": string; + readonly "x-client-locale"?: string | null | undefined; + readonly "x-client-version"?: string | null | undefined; + readonly "x-is-backgrounded": SdkEvaluateFeatureFlagsParamsXIsBackgrounded; + readonly "x-is-debug-build": SdkEvaluateFeatureFlagsParamsXIsDebugBuildEnum | SdkEvaluateFeatureFlagsParamsXIsDebugBuildEnum; + readonly "x-nonce"?: string | null | undefined; + readonly "x-observer-mode": SdkEvaluateFeatureFlagsParamsXObserverModeEnum | SdkEvaluateFeatureFlagsParamsXObserverModeEnum; + readonly "x-platform": string; + readonly "x-platform-brand"?: string | null | undefined; + readonly "x-platform-device"?: string | null | undefined; + readonly "x-platform-flavor": SdkEvaluateFeatureFlagsParamsXPlatformFlavorEnum | SdkEvaluateFeatureFlagsParamsXPlatformFlavorEnum; + readonly "x-platform-flavor-version"?: string | null | undefined; + readonly "x-platform-version"?: string | null | undefined; + readonly "x-preferred-locales"?: string | null | undefined; + readonly "x-sdk": SdkEvaluateFeatureFlagsParamsXSdkEnum | SdkEvaluateFeatureFlagsParamsXSdkEnum; + readonly "x-sdk-version": string; + readonly "x-storefront"?: string | null | undefined +} + +export interface EvaluateFeatureFlagsBody { + readonly "flagKeys"?: ReadonlyArray | null | undefined +} + +export interface SdkFeatureFlagResult { + readonly "enabled": boolean; + readonly "key": string; + readonly "variantKey": string | null +} + +export interface SdkFeatureFlagsResponse { + readonly "flags": ReadonlyArray +} + +export type SdkEvaluateFeatureFlags500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError + +export type SdkResolvePaywallParamsXIsBackgrounded = "false" + +export type SdkResolvePaywallParamsXIsDebugBuildEnum = "false" + +export type SdkResolvePaywallParamsXObserverModeEnum = "false" + +export type SdkResolvePaywallParamsXPlatformFlavorEnum = "browser" + +export type SdkResolvePaywallParamsXSdkEnum = "web" + +export interface SdkResolvePaywallParams { + readonly "x-distinct-id": string; + readonly "x-publishable-key": string; + readonly "x-client-bundle-id": string; + readonly "x-client-locale"?: string | null | undefined; + readonly "x-client-version"?: string | null | undefined; + readonly "x-is-backgrounded": SdkResolvePaywallParamsXIsBackgrounded; + readonly "x-is-debug-build": SdkResolvePaywallParamsXIsDebugBuildEnum | SdkResolvePaywallParamsXIsDebugBuildEnum; + readonly "x-nonce"?: string | null | undefined; + readonly "x-observer-mode": SdkResolvePaywallParamsXObserverModeEnum | SdkResolvePaywallParamsXObserverModeEnum; + readonly "x-platform": string; + readonly "x-platform-brand"?: string | null | undefined; + readonly "x-platform-device"?: string | null | undefined; + readonly "x-platform-flavor": SdkResolvePaywallParamsXPlatformFlavorEnum | SdkResolvePaywallParamsXPlatformFlavorEnum; + readonly "x-platform-flavor-version"?: string | null | undefined; + readonly "x-platform-version"?: string | null | undefined; + readonly "x-preferred-locales"?: string | null | undefined; + readonly "x-sdk": SdkResolvePaywallParamsXSdkEnum | SdkResolvePaywallParamsXSdkEnum; + readonly "x-sdk-version": string; + readonly "x-storefront"?: string | null | undefined +} + +export interface SdkResolvePaywallBody { + readonly "locationSlug": string +} + +export type SdkResolvedPaywallShowingPaywallReleaseEnumVersionEnum = "-Infinity" + +export type SdkResolvedPaywallShowingTypeEnum = "feature_flag" + +export interface SdkResolvedPaywallShowing { + readonly "id": string; + readonly "paywall": { + readonly "id": string; + readonly "name": string; + readonly "slug": string +} | null; + readonly "paywallId": string | null; + readonly "paywallRelease": { + readonly "htmlUrl": string; + readonly "publishedAt": string | null; + readonly "releaseId": string; + readonly "version": number | "NaN" | "Infinity" | "-Infinity" +} | null; + readonly "paywallReleaseId": string | null; + readonly "startedAt": string; + readonly "type": SdkResolvedPaywallShowingTypeEnum | SdkResolvedPaywallShowingTypeEnum +} + +export interface SdkResolvedPaywall { + readonly "location": { + readonly "id": string; + readonly "name": string; + readonly "slug": string +}; + readonly "showing": SdkResolvedPaywallShowing +} + +export type SdkResolvePaywall200 = SdkResolvedPaywall | null + +export type SdkResolvePaywall400 = SdkValidationError | EffectHttpApiSchemaError + +export type SdkResolvePaywall500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError + +export interface User { + readonly "createdAt": string; + readonly "email": string; + readonly "emailVerified": boolean; + readonly "id": string; + readonly "image": string | null; + readonly "name": string; + readonly "organizations": ReadonlyArray<{ + readonly "id": string; + readonly "logo": string | null; + readonly "name": string; + readonly "slug": string +}>; + readonly "projects": ReadonlyArray<{ + readonly "id": string; + readonly "logo": string | null; + readonly "name": string; + readonly "organizationId": string; + readonly "slug": string +}>; + readonly "updatedAt": string +} + +export type UserServiceErrorTag = "UserServiceError" + +export interface UserServiceError { + readonly "_tag": UserServiceErrorTag; + readonly "cause": string +} + +export type UsersGetUser500 = AuthenticationError | UserServiceError | AuthenticationError | NotAuthenticatedError + +export interface PaymentProviderConfiguration { + readonly "enabled": boolean; + readonly "id": string; + readonly "name": string; + readonly "projectId": string; + readonly "providerId": string +} + +export type PaymentProviderConfigurationsListPaymentProviderConfigurations200 = ReadonlyArray + +export type PaymentProviderConfigurationServiceErrorTag = "PaymentProviderConfigurationServiceError" + +export interface PaymentProviderConfigurationServiceError { + readonly "_tag": PaymentProviderConfigurationServiceErrorTag; + readonly "cause": string +} + +export type PaymentProviderConfigurationsListPaymentProviderConfigurations500 = PaymentProviderConfigurationServiceError | AuthenticationError | NotAuthenticatedError + +export interface PaymentProviderProduct { + readonly "configuration": Record; + readonly "id": string; + readonly "paymentProviderConfigurationId": string; + readonly "productId": string; + readonly "providerId": string +} + +export type PaymentProviderProductsListPaymentProviderProducts200 = ReadonlyArray + +export type PaymentProviderProductServiceErrorTag = "PaymentProviderProductServiceError" + +export interface PaymentProviderProductServiceError { + readonly "_tag": PaymentProviderProductServiceErrorTag; + readonly "cause": string +} + +export type PaymentProviderProductsListPaymentProviderProducts500 = PaymentProviderProductServiceError | AuthenticationError | NotAuthenticatedError + +export interface DeployChangesetBody { + readonly "changeset": { + readonly "changes": ReadonlyArray<{ + readonly "changeType": "create-paywall-location"; + readonly "key": string; + readonly "payload": { + readonly "description"?: string | null | null | undefined; + readonly "name": string; + readonly "slug": string +} +} | { + readonly "changeType": "update-paywall-location"; + readonly "key": string; + readonly "payload": { + readonly "description"?: string | null | null | undefined; + readonly "name": string; + readonly "slug": string +} +} | { + readonly "changeType": "archive-paywall-location"; + readonly "key": string; + readonly "payload": { + readonly "slug": string +} +} | { + readonly "changeType": "create-perk"; + readonly "key": string; + readonly "payload": { + readonly "name": string; + readonly "slug": string +} +} | { + readonly "changeType": "update-perk"; + readonly "key": string; + readonly "payload": { + readonly "name": string; + readonly "slug": string +} +} | { + readonly "changeType": "delete-perk"; + readonly "key": string; + readonly "payload": { + readonly "slug": string +} +} | { + readonly "changeType": "create-product"; + readonly "key": string; + readonly "payload": { + readonly "name": string; + readonly "slug": string +} +} | { + readonly "changeType": "update-product"; + readonly "key": string; + readonly "payload": { + readonly "name": string; + readonly "slug": string +} +} | { + readonly "changeType": "delete-product"; + readonly "key": string; + readonly "payload": { + readonly "slug": string +} +} | { + readonly "changeType": "create-product-perk"; + readonly "key": string; + readonly "payload": { + readonly "perkSlug": string; + readonly "productSlug": string +} +} | { + readonly "changeType": "delete-product-perk"; + readonly "key": string; + readonly "payload": { + readonly "perkSlug": string; + readonly "productSlug": string +} +} | { + readonly "changeType": "create-payment-provider-product"; + readonly "key": string; + readonly "payload": { + readonly "configuration": Record; + readonly "productSlug": string; + readonly "providerId": string +} +} | { + readonly "changeType": "update-payment-provider-product"; + readonly "key": string; + readonly "payload": { + readonly "configuration": Record; + readonly "productSlug": string; + readonly "providerId": string +} +} | { + readonly "changeType": "delete-payment-provider-product"; + readonly "key": string; + readonly "payload": { + readonly "productSlug": string; + readonly "providerId": string +} +}> +} +} + +export interface DeployChangesetResponse { + readonly "deploymentId": string +} + +export type ChangesetDeploymentServiceErrorTag = "ChangesetDeploymentServiceError" + +export interface ChangesetDeploymentServiceError { + readonly "_tag": ChangesetDeploymentServiceErrorTag; + readonly "cause": null +} + +export type ChangesetsDeployChangeset500 = AuthenticationError | ChangesetDeploymentServiceError | AuthenticationError | NotAuthenticatedError + +export type WebhookEndpointConsecutiveFailuresEnum = "-Infinity" + +export type WebhookEndpointStatusEnum = "failed" + +export interface WebhookEndpoint { + readonly "consecutiveFailures": number | WebhookEndpointConsecutiveFailuresEnum | WebhookEndpointConsecutiveFailuresEnum | WebhookEndpointConsecutiveFailuresEnum; + readonly "createdAt": string | null; + readonly "description": string | null; + readonly "events": ReadonlyArray<"customer.created" | "customer.updated" | "customer.deleted" | "subscription.created" | "subscription.renewed" | "subscription.cancelled" | "subscription.expired" | "purchase.completed" | "purchase.refunded">; + readonly "id": string; + readonly "lastSuccessAt": string | null; + readonly "name": string; + readonly "projectId": string; + readonly "secret": string; + readonly "status": WebhookEndpointStatusEnum | WebhookEndpointStatusEnum | WebhookEndpointStatusEnum; + readonly "url": string +} + +export type WebhooksListWebhookEndpoints200 = ReadonlyArray + +export type WebhookServiceErrorTag = "WebhookServiceError" + +export interface WebhookServiceError { + readonly "_tag": WebhookServiceErrorTag; + readonly "cause": string +} + +export type WebhooksListWebhookEndpoints500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError + +export interface CreateWebhookEndpointBody { + readonly "description"?: string | null | undefined; + readonly "events": ReadonlyArray; + readonly "name": string; + readonly "url": string +} + +export type WebhookValidationErrorTag = "WebhookValidationError" + +export interface WebhookValidationError { + readonly "_tag": WebhookValidationErrorTag; + readonly "message": string +} + +export type WebhooksCreateWebhookEndpoint400 = WebhookValidationError | EffectHttpApiSchemaError + +export type WebhooksCreateWebhookEndpoint500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError + +export type WebhookEndpointNotFoundErrorTag = "WebhookEndpointNotFoundError" + +export interface WebhookEndpointNotFoundError { + readonly "_tag": WebhookEndpointNotFoundErrorTag; + readonly "endpointId": string +} + +export type WebhooksGetWebhookEndpoint500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError + +export type WebhooksDeleteWebhookEndpoint500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError + +export type UpdateWebhookEndpointBodyStatusEnum = "disabled" + +export interface UpdateWebhookEndpointBody { + readonly "description"?: string | null | null | undefined; + readonly "events"?: ReadonlyArray | null | undefined; + readonly "name"?: string | null | undefined; + readonly "status"?: UpdateWebhookEndpointBodyStatusEnum | UpdateWebhookEndpointBodyStatusEnum | null | undefined; + readonly "url"?: string | null | undefined +} + +export type WebhooksUpdateWebhookEndpoint400 = WebhookValidationError | EffectHttpApiSchemaError + +export type WebhooksUpdateWebhookEndpoint500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError + +export type WebhooksRotateWebhookSecret500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError + +export type WebhookDeliveryAttemptCountEnum = "-Infinity" + +export type WebhookDeliveryMaxAttemptsEnum = "-Infinity" + +export type WebhookDeliveryStatusEnum = "exhausted" + +export interface WebhookDelivery { + readonly "attemptCount": number | WebhookDeliveryAttemptCountEnum | WebhookDeliveryAttemptCountEnum | WebhookDeliveryAttemptCountEnum; + readonly "completedAt": string | null; + readonly "createdAt": string | null; + readonly "eventOccurredAt": string; + readonly "eventType": string; + readonly "id": string; + readonly "maxAttempts": number | WebhookDeliveryMaxAttemptsEnum | WebhookDeliveryMaxAttemptsEnum | WebhookDeliveryMaxAttemptsEnum; + readonly "nextAttemptAt": string | null; + readonly "payload": null; + readonly "projectId": string; + readonly "status": WebhookDeliveryStatusEnum | WebhookDeliveryStatusEnum | WebhookDeliveryStatusEnum | WebhookDeliveryStatusEnum | WebhookDeliveryStatusEnum; + readonly "webhookEndpointId": string +} + +export type WebhooksTestWebhookEndpoint500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError + +export type WebhooksListWebhookDeliveries200 = ReadonlyArray + +export type WebhooksListWebhookDeliveries500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError + +export type WebhookDeliveryWithAttemptsAttemptCountEnum = "-Infinity" + +export type WebhookDeliveryAttemptAttemptNumberEnum = "-Infinity" + +export type WebhookDeliveryAttemptDurationMsEnum = "-Infinity" + +export type WebhookDeliveryAttemptStatusCodeEnum = "-Infinity" + +export interface WebhookDeliveryAttempt { + readonly "attemptNumber": number | WebhookDeliveryAttemptAttemptNumberEnum | WebhookDeliveryAttemptAttemptNumberEnum | WebhookDeliveryAttemptAttemptNumberEnum; + readonly "createdAt": string | null; + readonly "durationMs": number | WebhookDeliveryAttemptDurationMsEnum | WebhookDeliveryAttemptDurationMsEnum | WebhookDeliveryAttemptDurationMsEnum | null; + readonly "errorMessage": string | null; + readonly "id": string; + readonly "responseBody": string | null; + readonly "statusCode": number | WebhookDeliveryAttemptStatusCodeEnum | WebhookDeliveryAttemptStatusCodeEnum | WebhookDeliveryAttemptStatusCodeEnum | null; + readonly "succeeded": boolean +} + +export type WebhookDeliveryWithAttemptsMaxAttemptsEnum = "-Infinity" + +export type WebhookDeliveryWithAttemptsStatusEnum = "exhausted" + +export interface WebhookDeliveryWithAttempts { + readonly "attemptCount": number | WebhookDeliveryWithAttemptsAttemptCountEnum | WebhookDeliveryWithAttemptsAttemptCountEnum | WebhookDeliveryWithAttemptsAttemptCountEnum; + readonly "attempts": ReadonlyArray; + readonly "completedAt": string | null; + readonly "createdAt": string | null; + readonly "eventOccurredAt": string; + readonly "eventType": string; + readonly "id": string; + readonly "maxAttempts": number | WebhookDeliveryWithAttemptsMaxAttemptsEnum | WebhookDeliveryWithAttemptsMaxAttemptsEnum | WebhookDeliveryWithAttemptsMaxAttemptsEnum; + readonly "nextAttemptAt": string | null; + readonly "payload": null; + readonly "projectId": string; + readonly "status": WebhookDeliveryWithAttemptsStatusEnum | WebhookDeliveryWithAttemptsStatusEnum | WebhookDeliveryWithAttemptsStatusEnum | WebhookDeliveryWithAttemptsStatusEnum | WebhookDeliveryWithAttemptsStatusEnum; + readonly "webhookEndpointId": string +} + +export type WebhookDeliveryNotFoundErrorTag = "WebhookDeliveryNotFoundError" + +export interface WebhookDeliveryNotFoundError { + readonly "_tag": WebhookDeliveryNotFoundErrorTag; + readonly "deliveryId": string +} + +export type WebhooksGetWebhookDelivery500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError + +export type WebhooksRetryWebhookDelivery400 = WebhookValidationError | EffectHttpApiSchemaError + +export type WebhooksRetryWebhookDelivery500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError + +export const make = ( + httpClient: HttpClient.HttpClient, + options: { + readonly transformClient?: ((client: HttpClient.HttpClient) => Effect.Effect) | undefined + } = {} +): VoidhashCoreClient => { + const unexpectedStatus = (response: HttpClientResponse.HttpClientResponse) => + Effect.flatMap( + Effect.orElseSucceed(response.json, () => "Unexpected status code"), + (description) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ + request: response.request, + response, + description: + typeof description === "string" + ? description + : JSON.stringify(description), + }), + }), + ), + ) + const withResponse: ( + f: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect, + ) => ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect = options.transformClient + ? (f) => (request) => + Effect.flatMap( + Effect.flatMap(options.transformClient!(httpClient), (client) => + client.execute(request), + ), + f, + ) + : (f) => (request) => Effect.flatMap(httpClient.execute(request), f) + const decodeSuccess =
(response: HttpClientResponse.HttpClientResponse) => + response.json as Effect.Effect + const decodeVoid = (_response: HttpClientResponse.HttpClientResponse) => + Effect.void + const decodeError = + (tag: Tag) => + ( + response: HttpClientResponse.HttpClientResponse, + ): Effect.Effect< + never, + VoidhashCoreClientError | HttpClientError.HttpClientError + > => + Effect.flatMap( + response.json as Effect.Effect, + (cause) => Effect.fail(VoidhashCoreClientError(tag, cause, response)), + ) + const onRequest = ( + successCodes: ReadonlyArray, + errorCodes?: Record, + ) => { + const cases: any = { orElse: unexpectedStatus } + for (const code of successCodes) { + cases[code] = decodeSuccess + } + if (errorCodes) { + for (const [code, tag] of Object.entries(errorCodes)) { + cases[code] = decodeError(tag) + } + } + if (successCodes.length === 0) { + cases["2xx"] = decodeVoid + } + return withResponse(HttpClientResponse.matchStatus(cases) as any) + } + return { + httpClient, + "authSession": () => HttpClientRequest.get(`/api/v1/auth/session`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"AuthSession500"}) + ), + "apiKeysListApiKeys": () => HttpClientRequest.get(`/api/v1/api-keys`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"ApiKeysListApiKeys500"}) + ), + "apiKeysCreateSecretKey": (options) => HttpClientRequest.post(`/api/v1/api-keys`).pipe( + HttpClientRequest.bodyJsonUnsafe(options), + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"ApiKeysCreateSecretKey500"}) + ), + "apiKeysGetApiKeyById": (apiKeyId) => HttpClientRequest.get(`/api/v1/api-keys/${apiKeyId}`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"ApiKeyNotFoundError","500":"ApiKeysGetApiKeyById500"}) + ), + "apiKeysDeleteApiKey": (apiKeyId) => HttpClientRequest.delete(`/api/v1/api-keys/${apiKeyId}`).pipe( + onRequest([], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"ApiKeyNotFoundError","500":"ApiKeysDeleteApiKey500"}) + ), + "apiKeysRotateSecretKey": (apiKeyId) => HttpClientRequest.post(`/api/v1/api-keys/${apiKeyId}/rotate`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"ApiKeyNotFoundError","500":"ApiKeysRotateSecretKey500"}) + ), + "customersListCustomers": () => HttpClientRequest.get(`/api/v1/customers`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"CustomersListCustomers500"}) + ), + "customersCreateCustomer": (options) => HttpClientRequest.post(`/api/v1/customers`).pipe( + HttpClientRequest.bodyJsonUnsafe(options), + onRequest(["2xx"], {"400":"CustomersCreateCustomer400","403":"ActionForbiddenError","500":"CustomersCreateCustomer500"}) + ), + "customersGetCustomerById": (customerId) => HttpClientRequest.get(`/api/v1/customers/${customerId}`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"CustomerNotFoundError","500":"CustomersGetCustomerById500"}) + ), + "customersByDistinctId": (distinctId) => HttpClientRequest.get(`/api/v1/customers/by-distinct-id/${distinctId}`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"CustomerNotFoundError","500":"CustomersByDistinctId500"}) + ), + "organizationsCreateOrganization": (options) => HttpClientRequest.post(`/api/v1/organizations`).pipe( + HttpClientRequest.bodyJsonUnsafe(options), + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","500":"OrganizationsCreateOrganization500"}) + ), + "perksListPerks": () => HttpClientRequest.get(`/api/v1/perks`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"PerksListPerks500"}) + ), + "paywallLocationsListPaywallLocations": () => HttpClientRequest.get(`/api/v1/paywall-locations`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"PaywallLocationsListPaywallLocations500"}) + ), + "projectsCreateProject": (options) => HttpClientRequest.post(`/api/v1/projects`).pipe( + HttpClientRequest.bodyJsonUnsafe(options), + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"ProjectsCreateProject500"}) + ), + "projectsListProjects": (organizationId) => HttpClientRequest.get(`/api/v1/projects/${organizationId}`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"ProjectsListProjects500"}) + ), + "productsListProducts": () => HttpClientRequest.get(`/api/v1/products`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"ProductsListProducts500"}) + ), + "productPerksListProductPerksByProductId": (productId) => HttpClientRequest.get(`/api/v1/product-perks/by-product-id/${productId}`).pipe( + onRequest(["2xx"], {"400":"ProductPerksListProductPerksByProductId400","403":"ActionForbiddenError","500":"ProductPerksListProductPerksByProductId500"}) + ), + "sdkGetCustomer": (options) => HttpClientRequest.get(`/api/v1/sdk/get-customer`).pipe( + HttpClientRequest.setHeaders({ "x-distinct-id": options?.["x-distinct-id"] ?? undefined, "x-publishable-key": options?.["x-publishable-key"] ?? undefined, "x-client-bundle-id": options?.["x-client-bundle-id"] ?? undefined, "x-client-locale": options?.["x-client-locale"] ?? undefined, "x-client-version": options?.["x-client-version"] ?? undefined, "x-is-backgrounded": options?.["x-is-backgrounded"] ?? undefined, "x-is-debug-build": options?.["x-is-debug-build"] ?? undefined, "x-nonce": options?.["x-nonce"] ?? undefined, "x-observer-mode": options?.["x-observer-mode"] ?? undefined, "x-platform": options?.["x-platform"] ?? undefined, "x-platform-brand": options?.["x-platform-brand"] ?? undefined, "x-platform-device": options?.["x-platform-device"] ?? undefined, "x-platform-flavor": options?.["x-platform-flavor"] ?? undefined, "x-platform-flavor-version": options?.["x-platform-flavor-version"] ?? undefined, "x-platform-version": options?.["x-platform-version"] ?? undefined, "x-preferred-locales": options?.["x-preferred-locales"] ?? undefined, "x-sdk": options?.["x-sdk"] ?? undefined, "x-sdk-version": options?.["x-sdk-version"] ?? undefined, "x-storefront": options?.["x-storefront"] ?? undefined }), + onRequest(["2xx"], {"400":"SdkGetCustomer400","404":"SdkCustomerNotFoundError","500":"SdkGetCustomer500"}) + ), + "sdkIdentify": (options) => HttpClientRequest.post(`/api/v1/sdk/identify`).pipe( + HttpClientRequest.setHeaders({ "x-distinct-id": options.params?.["x-distinct-id"] ?? undefined, "x-publishable-key": options.params?.["x-publishable-key"] ?? undefined, "x-client-bundle-id": options.params?.["x-client-bundle-id"] ?? undefined, "x-client-locale": options.params?.["x-client-locale"] ?? undefined, "x-client-version": options.params?.["x-client-version"] ?? undefined, "x-is-backgrounded": options.params?.["x-is-backgrounded"] ?? undefined, "x-is-debug-build": options.params?.["x-is-debug-build"] ?? undefined, "x-nonce": options.params?.["x-nonce"] ?? undefined, "x-observer-mode": options.params?.["x-observer-mode"] ?? undefined, "x-platform": options.params?.["x-platform"] ?? undefined, "x-platform-brand": options.params?.["x-platform-brand"] ?? undefined, "x-platform-device": options.params?.["x-platform-device"] ?? undefined, "x-platform-flavor": options.params?.["x-platform-flavor"] ?? undefined, "x-platform-flavor-version": options.params?.["x-platform-flavor-version"] ?? undefined, "x-platform-version": options.params?.["x-platform-version"] ?? undefined, "x-preferred-locales": options.params?.["x-preferred-locales"] ?? undefined, "x-sdk": options.params?.["x-sdk"] ?? undefined, "x-sdk-version": options.params?.["x-sdk-version"] ?? undefined, "x-storefront": options.params?.["x-storefront"] ?? undefined }), + HttpClientRequest.bodyJsonUnsafe(options.payload), + onRequest(["2xx"], {"400":"SdkIdentify400","409":"SdkCustomerAlreadyIdentifiedError","500":"SdkIdentify500"}) + ), + "sdkSyncCustomerAttributes": (options) => HttpClientRequest.post(`/api/v1/sdk/sync-customer-attributes`).pipe( + HttpClientRequest.setHeaders({ "x-distinct-id": options.params?.["x-distinct-id"] ?? undefined, "x-publishable-key": options.params?.["x-publishable-key"] ?? undefined, "x-client-bundle-id": options.params?.["x-client-bundle-id"] ?? undefined, "x-client-locale": options.params?.["x-client-locale"] ?? undefined, "x-client-version": options.params?.["x-client-version"] ?? undefined, "x-is-backgrounded": options.params?.["x-is-backgrounded"] ?? undefined, "x-is-debug-build": options.params?.["x-is-debug-build"] ?? undefined, "x-nonce": options.params?.["x-nonce"] ?? undefined, "x-observer-mode": options.params?.["x-observer-mode"] ?? undefined, "x-platform": options.params?.["x-platform"] ?? undefined, "x-platform-brand": options.params?.["x-platform-brand"] ?? undefined, "x-platform-device": options.params?.["x-platform-device"] ?? undefined, "x-platform-flavor": options.params?.["x-platform-flavor"] ?? undefined, "x-platform-flavor-version": options.params?.["x-platform-flavor-version"] ?? undefined, "x-platform-version": options.params?.["x-platform-version"] ?? undefined, "x-preferred-locales": options.params?.["x-preferred-locales"] ?? undefined, "x-sdk": options.params?.["x-sdk"] ?? undefined, "x-sdk-version": options.params?.["x-sdk-version"] ?? undefined, "x-storefront": options.params?.["x-storefront"] ?? undefined }), + HttpClientRequest.bodyJsonUnsafe(options.payload), + onRequest(["2xx"], {"400":"SdkSyncCustomerAttributes400","500":"SdkSyncCustomerAttributes500"}) + ), + "sdkSyncTransaction": (options) => HttpClientRequest.post(`/api/v1/sdk/sync-transaction`).pipe( + HttpClientRequest.setHeaders({ "x-distinct-id": options.params?.["x-distinct-id"] ?? undefined, "x-publishable-key": options.params?.["x-publishable-key"] ?? undefined, "x-client-bundle-id": options.params?.["x-client-bundle-id"] ?? undefined, "x-client-locale": options.params?.["x-client-locale"] ?? undefined, "x-client-version": options.params?.["x-client-version"] ?? undefined, "x-is-backgrounded": options.params?.["x-is-backgrounded"] ?? undefined, "x-is-debug-build": options.params?.["x-is-debug-build"] ?? undefined, "x-nonce": options.params?.["x-nonce"] ?? undefined, "x-observer-mode": options.params?.["x-observer-mode"] ?? undefined, "x-platform": options.params?.["x-platform"] ?? undefined, "x-platform-brand": options.params?.["x-platform-brand"] ?? undefined, "x-platform-device": options.params?.["x-platform-device"] ?? undefined, "x-platform-flavor": options.params?.["x-platform-flavor"] ?? undefined, "x-platform-flavor-version": options.params?.["x-platform-flavor-version"] ?? undefined, "x-platform-version": options.params?.["x-platform-version"] ?? undefined, "x-preferred-locales": options.params?.["x-preferred-locales"] ?? undefined, "x-sdk": options.params?.["x-sdk"] ?? undefined, "x-sdk-version": options.params?.["x-sdk-version"] ?? undefined, "x-storefront": options.params?.["x-storefront"] ?? undefined }), + HttpClientRequest.bodyJsonUnsafe(options.payload), + onRequest(["2xx"], {"400":"SdkSyncTransaction400","500":"SdkSyncTransaction500"}) + ), + "sdkEvaluateFeatureFlags": (options) => HttpClientRequest.post(`/api/v1/sdk/evaluate-flags`).pipe( + HttpClientRequest.setHeaders({ "x-distinct-id": options.params?.["x-distinct-id"] ?? undefined, "x-publishable-key": options.params?.["x-publishable-key"] ?? undefined, "x-client-bundle-id": options.params?.["x-client-bundle-id"] ?? undefined, "x-client-locale": options.params?.["x-client-locale"] ?? undefined, "x-client-version": options.params?.["x-client-version"] ?? undefined, "x-is-backgrounded": options.params?.["x-is-backgrounded"] ?? undefined, "x-is-debug-build": options.params?.["x-is-debug-build"] ?? undefined, "x-nonce": options.params?.["x-nonce"] ?? undefined, "x-observer-mode": options.params?.["x-observer-mode"] ?? undefined, "x-platform": options.params?.["x-platform"] ?? undefined, "x-platform-brand": options.params?.["x-platform-brand"] ?? undefined, "x-platform-device": options.params?.["x-platform-device"] ?? undefined, "x-platform-flavor": options.params?.["x-platform-flavor"] ?? undefined, "x-platform-flavor-version": options.params?.["x-platform-flavor-version"] ?? undefined, "x-platform-version": options.params?.["x-platform-version"] ?? undefined, "x-preferred-locales": options.params?.["x-preferred-locales"] ?? undefined, "x-sdk": options.params?.["x-sdk"] ?? undefined, "x-sdk-version": options.params?.["x-sdk-version"] ?? undefined, "x-storefront": options.params?.["x-storefront"] ?? undefined }), + HttpClientRequest.bodyJsonUnsafe(options.payload), + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","500":"SdkEvaluateFeatureFlags500"}) + ), + "sdkResolvePaywall": (options) => HttpClientRequest.post(`/api/v1/sdk/resolve-paywall`).pipe( + HttpClientRequest.setHeaders({ "x-distinct-id": options.params?.["x-distinct-id"] ?? undefined, "x-publishable-key": options.params?.["x-publishable-key"] ?? undefined, "x-client-bundle-id": options.params?.["x-client-bundle-id"] ?? undefined, "x-client-locale": options.params?.["x-client-locale"] ?? undefined, "x-client-version": options.params?.["x-client-version"] ?? undefined, "x-is-backgrounded": options.params?.["x-is-backgrounded"] ?? undefined, "x-is-debug-build": options.params?.["x-is-debug-build"] ?? undefined, "x-nonce": options.params?.["x-nonce"] ?? undefined, "x-observer-mode": options.params?.["x-observer-mode"] ?? undefined, "x-platform": options.params?.["x-platform"] ?? undefined, "x-platform-brand": options.params?.["x-platform-brand"] ?? undefined, "x-platform-device": options.params?.["x-platform-device"] ?? undefined, "x-platform-flavor": options.params?.["x-platform-flavor"] ?? undefined, "x-platform-flavor-version": options.params?.["x-platform-flavor-version"] ?? undefined, "x-platform-version": options.params?.["x-platform-version"] ?? undefined, "x-preferred-locales": options.params?.["x-preferred-locales"] ?? undefined, "x-sdk": options.params?.["x-sdk"] ?? undefined, "x-sdk-version": options.params?.["x-sdk-version"] ?? undefined, "x-storefront": options.params?.["x-storefront"] ?? undefined }), + HttpClientRequest.bodyJsonUnsafe(options.payload), + onRequest(["2xx"], {"400":"SdkResolvePaywall400","500":"SdkResolvePaywall500"}) + ), + "usersGetUser": () => HttpClientRequest.get(`/api/v1/users/current`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","500":"UsersGetUser500"}) + ), + "paymentProviderConfigurationsListPaymentProviderConfigurations": () => HttpClientRequest.get(`/api/v1/payment-provider-configurations`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"PaymentProviderConfigurationsListPaymentProviderConfigurations500"}) + ), + "paymentProviderProductsListPaymentProviderProducts": () => HttpClientRequest.get(`/api/v1/payment-provider-products`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"PaymentProviderProductsListPaymentProviderProducts500"}) + ), + "changesetsDeployChangeset": (options) => HttpClientRequest.post(`/api/v1/changesets/deploy`).pipe( + HttpClientRequest.bodyJsonUnsafe(options), + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"ChangesetsDeployChangeset500"}) + ), + "webhooksListWebhookEndpoints": () => HttpClientRequest.get(`/api/v1/webhooks/endpoints`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"WebhooksListWebhookEndpoints500"}) + ), + "webhooksCreateWebhookEndpoint": (options) => HttpClientRequest.post(`/api/v1/webhooks/endpoints`).pipe( + HttpClientRequest.bodyJsonUnsafe(options), + onRequest(["2xx"], {"400":"WebhooksCreateWebhookEndpoint400","403":"ActionForbiddenError","500":"WebhooksCreateWebhookEndpoint500"}) + ), + "webhooksGetWebhookEndpoint": (endpointId) => HttpClientRequest.get(`/api/v1/webhooks/endpoints/${endpointId}`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"WebhookEndpointNotFoundError","500":"WebhooksGetWebhookEndpoint500"}) + ), + "webhooksDeleteWebhookEndpoint": (endpointId) => HttpClientRequest.delete(`/api/v1/webhooks/endpoints/${endpointId}`).pipe( + onRequest([], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"WebhookEndpointNotFoundError","500":"WebhooksDeleteWebhookEndpoint500"}) + ), + "webhooksUpdateWebhookEndpoint": (endpointId, options) => HttpClientRequest.patch(`/api/v1/webhooks/endpoints/${endpointId}`).pipe( + HttpClientRequest.bodyJsonUnsafe(options), + onRequest(["2xx"], {"400":"WebhooksUpdateWebhookEndpoint400","403":"ActionForbiddenError","404":"WebhookEndpointNotFoundError","500":"WebhooksUpdateWebhookEndpoint500"}) + ), + "webhooksRotateWebhookSecret": (endpointId) => HttpClientRequest.post(`/api/v1/webhooks/endpoints/${endpointId}/rotate-secret`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"WebhookEndpointNotFoundError","500":"WebhooksRotateWebhookSecret500"}) + ), + "webhooksTestWebhookEndpoint": (endpointId) => HttpClientRequest.post(`/api/v1/webhooks/endpoints/${endpointId}/test`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"WebhookEndpointNotFoundError","500":"WebhooksTestWebhookEndpoint500"}) + ), + "webhooksListWebhookDeliveries": () => HttpClientRequest.get(`/api/v1/webhooks/deliveries`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"WebhooksListWebhookDeliveries500"}) + ), + "webhooksGetWebhookDelivery": (deliveryId) => HttpClientRequest.get(`/api/v1/webhooks/deliveries/${deliveryId}`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"WebhookDeliveryNotFoundError","500":"WebhooksGetWebhookDelivery500"}) + ), + "webhooksRetryWebhookDelivery": (deliveryId) => HttpClientRequest.post(`/api/v1/webhooks/deliveries/${deliveryId}/retry`).pipe( + onRequest(["2xx"], {"400":"WebhooksRetryWebhookDelivery400","403":"ActionForbiddenError","404":"WebhookDeliveryNotFoundError","500":"WebhooksRetryWebhookDelivery500"}) + ) + } +} + +export interface VoidhashCoreClient { + readonly httpClient: HttpClient.HttpClient + readonly "authSession": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"AuthSession500", AuthSession500>> + readonly "apiKeysListApiKeys": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ApiKeysListApiKeys500", ApiKeysListApiKeys500>> + readonly "apiKeysCreateSecretKey": (options: CreateSecretKeyBody) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ApiKeysCreateSecretKey500", ApiKeysCreateSecretKey500>> + readonly "apiKeysGetApiKeyById": (apiKeyId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ApiKeyNotFoundError", ApiKeyNotFoundError> | VoidhashCoreClientError<"ApiKeysGetApiKeyById500", ApiKeysGetApiKeyById500>> + readonly "apiKeysDeleteApiKey": (apiKeyId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ApiKeyNotFoundError", ApiKeyNotFoundError> | VoidhashCoreClientError<"ApiKeysDeleteApiKey500", ApiKeysDeleteApiKey500>> + readonly "apiKeysRotateSecretKey": (apiKeyId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ApiKeyNotFoundError", ApiKeyNotFoundError> | VoidhashCoreClientError<"ApiKeysRotateSecretKey500", ApiKeysRotateSecretKey500>> + readonly "customersListCustomers": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"CustomersListCustomers500", CustomersListCustomers500>> + readonly "customersCreateCustomer": (options: CreateCustomerBody) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"CustomersCreateCustomer500", CustomersCreateCustomer500>> + readonly "customersGetCustomerById": (customerId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"CustomerNotFoundError", CustomerNotFoundError> | VoidhashCoreClientError<"CustomersGetCustomerById500", CustomersGetCustomerById500>> + readonly "customersByDistinctId": (distinctId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"CustomerNotFoundError", CustomerNotFoundError> | VoidhashCoreClientError<"CustomersByDistinctId500", CustomersByDistinctId500>> + readonly "organizationsCreateOrganization": (options: CreateOrganizationBody) => Effect.Effect | VoidhashCoreClientError<"OrganizationsCreateOrganization500", OrganizationsCreateOrganization500>> + readonly "perksListPerks": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PerksListPerks500", PerksListPerks500>> + readonly "paywallLocationsListPaywallLocations": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PaywallLocationsListPaywallLocations500", PaywallLocationsListPaywallLocations500>> + readonly "projectsCreateProject": (options: CreateProjectBody) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ProjectsCreateProject500", ProjectsCreateProject500>> + readonly "projectsListProjects": (organizationId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ProjectsListProjects500", ProjectsListProjects500>> + readonly "productsListProducts": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ProductsListProducts500", ProductsListProducts500>> + readonly "productPerksListProductPerksByProductId": (productId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ProductPerksListProductPerksByProductId500", ProductPerksListProductPerksByProductId500>> + readonly "sdkGetCustomer": (options: SdkGetCustomerParams) => Effect.Effect | VoidhashCoreClientError<"SdkCustomerNotFoundError", SdkCustomerNotFoundError> | VoidhashCoreClientError<"SdkGetCustomer500", SdkGetCustomer500>> + readonly "sdkIdentify": (options: { readonly params: SdkIdentifyParams; readonly payload: SdkIdentifyBody }) => Effect.Effect | VoidhashCoreClientError<"SdkCustomerAlreadyIdentifiedError", SdkCustomerAlreadyIdentifiedError> | VoidhashCoreClientError<"SdkIdentify500", SdkIdentify500>> + readonly "sdkSyncCustomerAttributes": (options: { readonly params: SdkSyncCustomerAttributesParams; readonly payload: SdkSyncCustomerAttributesBody }) => Effect.Effect | VoidhashCoreClientError<"SdkSyncCustomerAttributes500", SdkSyncCustomerAttributes500>> + readonly "sdkSyncTransaction": (options: { readonly params: SdkSyncTransactionParams; readonly payload: SdkSyncTransactionRequest }) => Effect.Effect | VoidhashCoreClientError<"SdkSyncTransaction500", SdkSyncTransaction500>> + readonly "sdkEvaluateFeatureFlags": (options: { readonly params: SdkEvaluateFeatureFlagsParams; readonly payload: EvaluateFeatureFlagsBody }) => Effect.Effect | VoidhashCoreClientError<"SdkEvaluateFeatureFlags500", SdkEvaluateFeatureFlags500>> + readonly "sdkResolvePaywall": (options: { readonly params: SdkResolvePaywallParams; readonly payload: SdkResolvePaywallBody }) => Effect.Effect | VoidhashCoreClientError<"SdkResolvePaywall500", SdkResolvePaywall500>> + readonly "usersGetUser": () => Effect.Effect | VoidhashCoreClientError<"UsersGetUser500", UsersGetUser500>> + readonly "paymentProviderConfigurationsListPaymentProviderConfigurations": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PaymentProviderConfigurationsListPaymentProviderConfigurations500", PaymentProviderConfigurationsListPaymentProviderConfigurations500>> + readonly "paymentProviderProductsListPaymentProviderProducts": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PaymentProviderProductsListPaymentProviderProducts500", PaymentProviderProductsListPaymentProviderProducts500>> + readonly "changesetsDeployChangeset": (options: DeployChangesetBody) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ChangesetsDeployChangeset500", ChangesetsDeployChangeset500>> + readonly "webhooksListWebhookEndpoints": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhooksListWebhookEndpoints500", WebhooksListWebhookEndpoints500>> + readonly "webhooksCreateWebhookEndpoint": (options: CreateWebhookEndpointBody) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhooksCreateWebhookEndpoint500", WebhooksCreateWebhookEndpoint500>> + readonly "webhooksGetWebhookEndpoint": (endpointId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhookEndpointNotFoundError", WebhookEndpointNotFoundError> | VoidhashCoreClientError<"WebhooksGetWebhookEndpoint500", WebhooksGetWebhookEndpoint500>> + readonly "webhooksDeleteWebhookEndpoint": (endpointId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhookEndpointNotFoundError", WebhookEndpointNotFoundError> | VoidhashCoreClientError<"WebhooksDeleteWebhookEndpoint500", WebhooksDeleteWebhookEndpoint500>> + readonly "webhooksUpdateWebhookEndpoint": (endpointId: string, options: UpdateWebhookEndpointBody) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhookEndpointNotFoundError", WebhookEndpointNotFoundError> | VoidhashCoreClientError<"WebhooksUpdateWebhookEndpoint500", WebhooksUpdateWebhookEndpoint500>> + readonly "webhooksRotateWebhookSecret": (endpointId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhookEndpointNotFoundError", WebhookEndpointNotFoundError> | VoidhashCoreClientError<"WebhooksRotateWebhookSecret500", WebhooksRotateWebhookSecret500>> + readonly "webhooksTestWebhookEndpoint": (endpointId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhookEndpointNotFoundError", WebhookEndpointNotFoundError> | VoidhashCoreClientError<"WebhooksTestWebhookEndpoint500", WebhooksTestWebhookEndpoint500>> + readonly "webhooksListWebhookDeliveries": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhooksListWebhookDeliveries500", WebhooksListWebhookDeliveries500>> + readonly "webhooksGetWebhookDelivery": (deliveryId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhookDeliveryNotFoundError", WebhookDeliveryNotFoundError> | VoidhashCoreClientError<"WebhooksGetWebhookDelivery500", WebhooksGetWebhookDelivery500>> + readonly "webhooksRetryWebhookDelivery": (deliveryId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhookDeliveryNotFoundError", WebhookDeliveryNotFoundError> | VoidhashCoreClientError<"WebhooksRetryWebhookDelivery500", WebhooksRetryWebhookDelivery500>> +} + +export interface VoidhashCoreClientError extends Error { + readonly _tag: Tag + readonly request: HttpClientRequest.HttpClientRequest + readonly response: HttpClientResponse.HttpClientResponse + readonly data: E + readonly message: string +} + +class VoidhashCoreClientErrorImpl extends Data.Error<{ + _tag: string + data: any + message: string + request: HttpClientRequest.HttpClientRequest + response: HttpClientResponse.HttpClientResponse +}> { + name = "VoidhashCoreClientError" +} + +export const VoidhashCoreClientError = ( + tag: Tag, + data: E, + response: HttpClientResponse.HttpClientResponse, +): VoidhashCoreClientError => + new VoidhashCoreClientErrorImpl({ + _tag: tag, + data, + message: JSON.stringify(data), + response, + request: response.request, + }) as any diff --git a/packages/generated-clients/src/core/index.ts b/packages/generated-clients/src/core/index.ts new file mode 100644 index 000000000..e84c86c64 --- /dev/null +++ b/packages/generated-clients/src/core/index.ts @@ -0,0 +1 @@ +export * from "./generated"; diff --git a/packages/generated-clients/src/event-capture/generated.ts b/packages/generated-clients/src/event-capture/generated.ts new file mode 100644 index 000000000..bc2ad26e8 --- /dev/null +++ b/packages/generated-clients/src/event-capture/generated.ts @@ -0,0 +1,229 @@ +import type * as HttpClient from "effect/unstable/http/HttpClient" +import * as HttpClientError from "effect/unstable/http/HttpClientError" +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse" +import * as Data from "effect/Data" +import * as Effect from "effect/Effect" + +export interface EventCaptureCaptureRequest { + readonly "uuid": string; + readonly "event": string; + readonly "context": Record; + readonly "properties": Record; + readonly "distinct_id": string; + readonly "session_id"?: string | null | undefined; + readonly "timestamp"?: string | null | undefined; + readonly "sent_at": string; + readonly "token": string +} + +export interface CaptureAcceptedResponse { + readonly "accepted": number; + readonly "rejected": number +} + +export type CaptureInvalidRequestErrorTag = "CaptureInvalidRequestError" + +export type CaptureInvalidRequestErrorCode = "invalid_request" + +export interface CaptureInvalidRequestError { + readonly "_tag": CaptureInvalidRequestErrorTag; + readonly "error": string; + readonly "code": CaptureInvalidRequestErrorCode +} + +export type EffectHttpApiSchemaErrorTag = "HttpApiSchemaError" + +export interface EffectHttpApiSchemaError { + readonly "_tag": EffectHttpApiSchemaErrorTag; + readonly "message": string +} + +export type EventCaptureCapture400 = CaptureInvalidRequestError | EffectHttpApiSchemaError + +export type CaptureUnauthorizedErrorTag = "CaptureUnauthorizedError" + +export type CaptureUnauthorizedErrorCode = "unauthorized" + +export interface CaptureUnauthorizedError { + readonly "_tag": CaptureUnauthorizedErrorTag; + readonly "error": string; + readonly "code": CaptureUnauthorizedErrorCode +} + +export type CapturePayloadTooLargeErrorTag = "CapturePayloadTooLargeError" + +export type CapturePayloadTooLargeErrorCode = "payload_too_large" + +export interface CapturePayloadTooLargeError { + readonly "_tag": CapturePayloadTooLargeErrorTag; + readonly "error": string; + readonly "code": CapturePayloadTooLargeErrorCode +} + +export type CaptureRateLimitedErrorTag = "CaptureRateLimitedError" + +export type CaptureRateLimitedErrorCode = "rate_limited" + +export interface CaptureRateLimitedError { + readonly "_tag": CaptureRateLimitedErrorTag; + readonly "error": string; + readonly "code": CaptureRateLimitedErrorCode; + readonly "retry_after_ms"?: number | null | undefined +} + +export type CaptureInternalServerErrorTag = "CaptureInternalServerError" + +export type CaptureInternalServerErrorCode = "internal_error" + +export interface CaptureInternalServerError { + readonly "_tag": CaptureInternalServerErrorTag; + readonly "error": string; + readonly "code": CaptureInternalServerErrorCode +} + +export type CaptureDependencyUnavailableErrorTag = "CaptureDependencyUnavailableError" + +export type CaptureDependencyUnavailableErrorCode = "dependency_unavailable" + +export interface CaptureDependencyUnavailableError { + readonly "_tag": CaptureDependencyUnavailableErrorTag; + readonly "error": string; + readonly "code": CaptureDependencyUnavailableErrorCode +} + +export interface EventCaptureBatchRequest { + readonly "events": ReadonlyArray<{ + readonly "uuid": string; + readonly "event": string; + readonly "context": Record; + readonly "properties": Record; + readonly "distinct_id": string; + readonly "session_id"?: string | null | undefined; + readonly "timestamp"?: string | null | undefined +}>; + readonly "sent_at": string; + readonly "token": string +} + +export type EventCaptureBatch400 = CaptureInvalidRequestError | EffectHttpApiSchemaError + +export const make = ( + httpClient: HttpClient.HttpClient, + options: { + readonly transformClient?: ((client: HttpClient.HttpClient) => Effect.Effect) | undefined + } = {} +): VoidhashEventCaptureClient => { + const unexpectedStatus = (response: HttpClientResponse.HttpClientResponse) => + Effect.flatMap( + Effect.orElseSucceed(response.json, () => "Unexpected status code"), + (description) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ + request: response.request, + response, + description: + typeof description === "string" + ? description + : JSON.stringify(description), + }), + }), + ), + ) + const withResponse: ( + f: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect, + ) => ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect = options.transformClient + ? (f) => (request) => + Effect.flatMap( + Effect.flatMap(options.transformClient!(httpClient), (client) => + client.execute(request), + ), + f, + ) + : (f) => (request) => Effect.flatMap(httpClient.execute(request), f) + const decodeSuccess = (response: HttpClientResponse.HttpClientResponse) => + response.json as Effect.Effect + const decodeVoid = (_response: HttpClientResponse.HttpClientResponse) => + Effect.void + const decodeError = + (tag: Tag) => + ( + response: HttpClientResponse.HttpClientResponse, + ): Effect.Effect< + never, + VoidhashEventCaptureClientError | HttpClientError.HttpClientError + > => + Effect.flatMap( + response.json as Effect.Effect, + (cause) => Effect.fail(VoidhashEventCaptureClientError(tag, cause, response)), + ) + const onRequest = ( + successCodes: ReadonlyArray, + errorCodes?: Record, + ) => { + const cases: any = { orElse: unexpectedStatus } + for (const code of successCodes) { + cases[code] = decodeSuccess + } + if (errorCodes) { + for (const [code, tag] of Object.entries(errorCodes)) { + cases[code] = decodeError(tag) + } + } + if (successCodes.length === 0) { + cases["2xx"] = decodeVoid + } + return withResponse(HttpClientResponse.matchStatus(cases) as any) + } + return { + httpClient, + "eventCaptureCapture": (options) => HttpClientRequest.post(`/capture`).pipe( + HttpClientRequest.bodyJsonUnsafe(options), + onRequest(["2xx"], {"400":"EventCaptureCapture400","401":"CaptureUnauthorizedError","413":"CapturePayloadTooLargeError","429":"CaptureRateLimitedError","500":"CaptureInternalServerError","503":"CaptureDependencyUnavailableError"}) + ), + "eventCaptureBatch": (options) => HttpClientRequest.post(`/batch`).pipe( + HttpClientRequest.bodyJsonUnsafe(options), + onRequest(["2xx"], {"400":"EventCaptureBatch400","401":"CaptureUnauthorizedError","413":"CapturePayloadTooLargeError","429":"CaptureRateLimitedError","500":"CaptureInternalServerError","503":"CaptureDependencyUnavailableError"}) + ) + } +} + +export interface VoidhashEventCaptureClient { + readonly httpClient: HttpClient.HttpClient + readonly "eventCaptureCapture": (options: EventCaptureCaptureRequest) => Effect.Effect | VoidhashEventCaptureClientError<"CaptureUnauthorizedError", CaptureUnauthorizedError> | VoidhashEventCaptureClientError<"CapturePayloadTooLargeError", CapturePayloadTooLargeError> | VoidhashEventCaptureClientError<"CaptureRateLimitedError", CaptureRateLimitedError> | VoidhashEventCaptureClientError<"CaptureInternalServerError", CaptureInternalServerError> | VoidhashEventCaptureClientError<"CaptureDependencyUnavailableError", CaptureDependencyUnavailableError>> + readonly "eventCaptureBatch": (options: EventCaptureBatchRequest) => Effect.Effect | VoidhashEventCaptureClientError<"CaptureUnauthorizedError", CaptureUnauthorizedError> | VoidhashEventCaptureClientError<"CapturePayloadTooLargeError", CapturePayloadTooLargeError> | VoidhashEventCaptureClientError<"CaptureRateLimitedError", CaptureRateLimitedError> | VoidhashEventCaptureClientError<"CaptureInternalServerError", CaptureInternalServerError> | VoidhashEventCaptureClientError<"CaptureDependencyUnavailableError", CaptureDependencyUnavailableError>> +} + +export interface VoidhashEventCaptureClientError extends Error { + readonly _tag: Tag + readonly request: HttpClientRequest.HttpClientRequest + readonly response: HttpClientResponse.HttpClientResponse + readonly data: E + readonly message: string +} + +class VoidhashEventCaptureClientErrorImpl extends Data.Error<{ + _tag: string + data: any + message: string + request: HttpClientRequest.HttpClientRequest + response: HttpClientResponse.HttpClientResponse +}> { + name = "VoidhashEventCaptureClientError" +} + +export const VoidhashEventCaptureClientError = ( + tag: Tag, + data: E, + response: HttpClientResponse.HttpClientResponse, +): VoidhashEventCaptureClientError => + new VoidhashEventCaptureClientErrorImpl({ + _tag: tag, + data, + message: JSON.stringify(data), + response, + request: response.request, + }) as any diff --git a/packages/generated-clients/src/event-capture/index.ts b/packages/generated-clients/src/event-capture/index.ts new file mode 100644 index 000000000..b0ed30152 --- /dev/null +++ b/packages/generated-clients/src/event-capture/index.ts @@ -0,0 +1,37 @@ +import type { + CaptureDependencyUnavailableError, + CaptureInternalServerError, + CaptureInvalidRequestError, + CapturePayloadTooLargeError, + CaptureRateLimitedError, + CaptureUnauthorizedError, + EventCaptureBatchRequest, + EventCaptureCaptureRequest, +} from "./generated"; + +export * from "./generated"; + +export type CaptureBatchRequest = EventCaptureBatchRequest; +export type CaptureErrorResponse = + | CaptureDependencyUnavailableError + | CaptureInternalServerError + | CaptureInvalidRequestError + | CapturePayloadTooLargeError + | CaptureRateLimitedError + | CaptureUnauthorizedError; +export type CaptureEvent = EventCaptureBatchRequest["events"][number]; +export type CaptureSingleRequest = EventCaptureCaptureRequest; +export type EventContextField = + | string + | number + | boolean + | null + | ReadonlyArray + | { readonly [key: string]: EventContextField }; +export type EventPropertiesField = + | string + | number + | boolean + | null + | ReadonlyArray + | { readonly [key: string]: EventPropertiesField }; diff --git a/packages/generated-clients/src/index.ts b/packages/generated-clients/src/index.ts new file mode 100644 index 000000000..2d643a3e2 --- /dev/null +++ b/packages/generated-clients/src/index.ts @@ -0,0 +1,2 @@ +export * from "./core"; +export * as EventCapture from "./event-capture"; diff --git a/packages/api-spec/tsconfig.json b/packages/generated-clients/tsconfig.json similarity index 100% rename from packages/api-spec/tsconfig.json rename to packages/generated-clients/tsconfig.json diff --git a/packages/shared/src/admin.ts b/packages/shared/src/admin.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/admin.ts +++ b/packages/shared/src/admin.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/analytics.ts b/packages/shared/src/analytics.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/analytics.ts +++ b/packages/shared/src/analytics.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/api-key.ts b/packages/shared/src/api-key.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/api-key.ts +++ b/packages/shared/src/api-key.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/app-store.ts b/packages/shared/src/app-store.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/app-store.ts +++ b/packages/shared/src/app-store.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/billing.ts b/packages/shared/src/billing.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/billing.ts +++ b/packages/shared/src/billing.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/customer.ts b/packages/shared/src/customer.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/customer.ts +++ b/packages/shared/src/customer.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/deploy-changeset.ts b/packages/shared/src/deploy-changeset.ts index ccce9e9e1..04487fb49 100644 --- a/packages/shared/src/deploy-changeset.ts +++ b/packages/shared/src/deploy-changeset.ts @@ -1,7 +1,7 @@ import { Schema } from "effect"; // ChangesetDeploymentServiceError has been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) // Paywall Locations diff --git a/packages/shared/src/errors.ts b/packages/shared/src/errors.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/errors.ts +++ b/packages/shared/src/errors.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/google-play.ts b/packages/shared/src/google-play.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/google-play.ts +++ b/packages/shared/src/google-play.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/organization.ts b/packages/shared/src/organization.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/organization.ts +++ b/packages/shared/src/organization.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/payment-provider-configuration.ts b/packages/shared/src/payment-provider-configuration.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/payment-provider-configuration.ts +++ b/packages/shared/src/payment-provider-configuration.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/payment-provider-product.ts b/packages/shared/src/payment-provider-product.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/payment-provider-product.ts +++ b/packages/shared/src/payment-provider-product.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/paywall.ts b/packages/shared/src/paywall.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/paywall.ts +++ b/packages/shared/src/paywall.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/perk-grant.ts b/packages/shared/src/perk-grant.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/perk-grant.ts +++ b/packages/shared/src/perk-grant.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/perk.ts b/packages/shared/src/perk.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/perk.ts +++ b/packages/shared/src/perk.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/product-perk.ts b/packages/shared/src/product-perk.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/product-perk.ts +++ b/packages/shared/src/product-perk.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/product.ts b/packages/shared/src/product.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/product.ts +++ b/packages/shared/src/product.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/project.ts b/packages/shared/src/project.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/project.ts +++ b/packages/shared/src/project.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/sdk.ts b/packages/shared/src/sdk.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/sdk.ts +++ b/packages/shared/src/sdk.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/user.ts b/packages/shared/src/user.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/user.ts +++ b/packages/shared/src/user.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/packages/shared/src/webhook.ts b/packages/shared/src/webhook.ts index 82dc3803e..3bda060ec 100644 --- a/packages/shared/src/webhook.ts +++ b/packages/shared/src/webhook.ts @@ -1,3 +1,3 @@ // Errors have been moved to: -// - @voidhash/api-spec/errors (API layer) +// - @voidhash/generated-clients (API layer) // - @voidhash-internal/core/domain/errors (domain layer) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b24257888..8fb45882a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,9 +83,9 @@ importers: '@effect/platform-node': specifier: 4.0.0-beta.23 version: 4.0.0-beta.23(effect@4.0.0-beta.23)(ioredis@5.9.1) - '@voidhash/api-spec': + '@voidhash/generated-clients': specifier: workspace:* - version: link:../../packages/api-spec + version: link:../../packages/generated-clients '@voidhash/shared': specifier: workspace:* version: link:../../packages/shared @@ -226,9 +226,9 @@ importers: libraries/node: dependencies: - '@voidhash/api-spec': + '@voidhash/generated-clients': specifier: workspace:* - version: link:../../packages/api-spec + version: link:../../packages/generated-clients devDependencies: '@effect/platform': specifier: 'catalog:' @@ -260,9 +260,9 @@ importers: '@react-native-async-storage/async-storage': specifier: ^1.24.0 || ^2.0.0 version: 2.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) - '@voidhash/api-spec': + '@voidhash/generated-clients': specifier: workspace:* - version: link:../../packages/api-spec + version: link:../../packages/generated-clients effect: specifier: 4.0.0-beta.23 version: 4.0.0-beta.23 @@ -324,9 +324,9 @@ importers: '@effect/platform': specifier: 'catalog:' version: 0.94.5(effect@4.0.0-beta.23) - '@voidhash/api-spec': + '@voidhash/generated-clients': specifier: workspace:* - version: link:../../packages/api-spec + version: link:../../packages/generated-clients effect: specifier: 4.0.0-beta.23 version: 4.0.0-beta.23 @@ -365,7 +365,7 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - packages/api-spec: + packages/generated-clients: dependencies: effect: specifier: 4.0.0-beta.23 diff --git a/scripts/generate-node-grouped-client.mjs b/scripts/generate-node-grouped-client.mjs new file mode 100644 index 000000000..989a4459c --- /dev/null +++ b/scripts/generate-node-grouped-client.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const [specPathArg, outputPathArg] = process.argv.slice(2); + +if (!specPathArg || !outputPathArg) { + console.error( + "Usage: node ./scripts/generate-node-grouped-client.mjs " + ); + process.exit(1); +} + +const specPath = path.resolve(specPathArg); +const outputPath = path.resolve(outputPathArg); +mkdirSync(path.dirname(outputPath), { recursive: true }); +const spec = JSON.parse(readFileSync(specPath, "utf8")); + +const camelCase = (value) => + value.replace(/_([a-z])/g, (_, char) => char.toUpperCase()); + +const pascalCase = (value) => { + const camel = camelCase(value); + return camel.charAt(0).toUpperCase() + camel.slice(1); +}; + +const toMethodName = (groupName, methodName) => + `${camelCase(groupName)}${pascalCase(methodName)}`; + +const toTypeLiteral = (schema) => { + if (!schema) { + return "unknown"; + } + + if (schema.enum?.length) { + return schema.enum.map((entry) => JSON.stringify(entry)).join(" | "); + } + + if (Array.isArray(schema.type)) { + return schema.type.map((type) => toTypeLiteral({ ...schema, type })).join(" | "); + } + + switch (schema.type) { + case "string": + return "string"; + case "integer": + case "number": + return "number"; + case "boolean": + return "boolean"; + case "array": + return `ReadonlyArray<${toTypeLiteral(schema.items)}>`; + case "object": + if (schema.additionalProperties) { + return `{ readonly [key: string]: ${toTypeLiteral(schema.additionalProperties)} }`; + } + + if (schema.properties) { + const required = new Set(schema.required ?? []); + const entries = Object.entries(schema.properties).map(([key, value]) => { + const optional = required.has(key) ? "" : "?"; + return `readonly ${JSON.stringify(key)}${optional}: ${toTypeLiteral(value)}`; + }); + return `{ ${entries.join("; ")} }`; + } + + return "Record"; + default: + return "unknown"; + } +}; + +const formatRequestType = ({ methodName, parameterNames, parameterTypes, hasBody, hasParams }) => { + if (!hasBody && !hasParams) { + return null; + } + + if (hasBody && !hasParams) { + return `{ payload: Parameters[0] }`; + } + + const paramsType = + parameterNames.length === 1 + ? `{ ${parameterNames + .map((name, index) => `readonly ${JSON.stringify(name)}: ${parameterTypes[index]}`) + .join("; ")} }` + : `Parameters[0]`; + + if (!hasBody) { + return `{ params: ${paramsType} }`; + } + + return `{ params: ${paramsType}; payload: Parameters[1] }`; +}; + +const formatCall = ({ methodName, parameterNames, hasBody, hasParams }) => { + if (!hasBody && !hasParams) { + return `client.${methodName}()`; + } + + if (hasBody && !hasParams) { + return `client.${methodName}(request.payload)`; + } + + const paramsExpression = + parameterNames.length === 1 + ? `request.params[${JSON.stringify(parameterNames[0])}]` + : `request.params`; + + if (!hasBody) { + return `client.${methodName}(${paramsExpression})`; + } + + return `client.${methodName}(${paramsExpression}, request.payload)`; +}; + +const groups = new Map(); + +for (const [routePath, pathItem] of Object.entries(spec.paths)) { + for (const [httpMethod, operation] of Object.entries(pathItem)) { + if (!["get", "post", "patch", "delete", "put"].includes(httpMethod)) { + continue; + } + + const operationId = operation.operationId; + + if (!operationId || !operationId.includes(".")) { + continue; + } + + const [groupName, memberName] = operationId.split("."); + + if (groupName === "sdk") { + continue; + } + + const methodName = toMethodName(groupName, memberName); + const parameters = [...(pathItem.parameters ?? []), ...(operation.parameters ?? [])].filter( + (parameter) => parameter.in !== "header" + ); + const parameterNames = parameters.map((parameter) => parameter.name); + const parameterTypes = parameters.map((parameter) => toTypeLiteral(parameter.schema)); + const hasBody = Boolean(operation.requestBody); + const hasParams = parameterNames.length > 0; + const requestType = formatRequestType({ + hasBody, + hasParams, + methodName, + parameterNames, + parameterTypes, + }); + const callExpression = formatCall({ + hasBody, + hasParams, + methodName, + parameterNames, + }); + + const groupKey = camelCase(groupName); + const existing = groups.get(groupKey) ?? []; + existing.push({ + callExpression, + memberName, + requestType, + }); + groups.set(groupKey, existing); + } +} + +const groupEntries = [...groups.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([groupName, operations]) => { + const members = operations + .sort((left, right) => left.memberName.localeCompare(right.memberName)) + .map((operation) => { + if (!operation.requestType) { + return ` ${operation.memberName}: () => ${operation.callExpression},`; + } + + return ` ${operation.memberName}: (request: ${operation.requestType}) => ${operation.callExpression},`; + }) + .join("\n"); + + return ` ${groupName}: {\n${members}\n },`; + }) + .join("\n"); + +const output = `import type { VoidhashCoreClient } from "@voidhash/generated-clients"; + +export const groupCoreClient = (client: VoidhashCoreClient) => ({ +${groupEntries} +}); + +export type GroupedVoidhashNodeEffectClient = ReturnType; +`; + +writeFileSync(outputPath, output, "utf8"); diff --git a/scripts/generate-openapi-clients.mjs b/scripts/generate-openapi-clients.mjs new file mode 100644 index 000000000..ee53848f3 --- /dev/null +++ b/scripts/generate-openapi-clients.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node + +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, ".."); +const generatedClientsRoot = path.join(repoRoot, "packages/generated-clients"); +const nodeGeneratedRoot = path.join(repoRoot, "libraries/node/src/generated"); + +const mode = process.argv[2]; + +if (mode !== "preview" && mode !== "production") { + console.error("Usage: node ./scripts/generate-openapi-clients.mjs "); + process.exit(1); +} + +const resolveUrl = (name, fallback) => { + const value = process.env[name] ?? fallback; + + if (!value) { + console.error(`Missing required environment variable: ${name}`); + process.exit(1); + } + + return value; +}; + +const specsByMode = { + preview: { + core: resolveUrl("VOIDHASH_PREVIEW_CORE_OPENAPI_URL"), + eventCapture: resolveUrl("VOIDHASH_PREVIEW_EVENT_CAPTURE_OPENAPI_URL"), + }, + production: { + core: resolveUrl( + "VOIDHASH_PRODUCTION_CORE_OPENAPI_URL", + "https://api.voidhash.com/api/docs/openapi.json", + ), + eventCapture: resolveUrl("VOIDHASH_PRODUCTION_EVENT_CAPTURE_OPENAPI_URL"), + }, +}; + +const openapiDir = path.join(generatedClientsRoot, "openapi", mode); +mkdirSync(openapiDir, { recursive: true }); + +const fetchJson = async (url) => { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); + } + + return await response.text(); +}; + +const run = (command, args) => { + const result = spawnSync(command, args, { + cwd: repoRoot, + encoding: "utf8", + stdio: "pipe", + }); + + if (result.status !== 0) { + process.stderr.write(result.stderr); + process.stdout.write(result.stdout); + process.exit(result.status ?? 1); + } + + return result.stdout; +}; + +const main = async () => { + const specUrls = specsByMode[mode]; + const corePath = path.join(openapiDir, "core.json"); + const eventCapturePath = path.join(openapiDir, "event-capture.json"); + + writeFileSync(corePath, `${await fetchJson(specUrls.core)}\n`, "utf8"); + writeFileSync(eventCapturePath, `${await fetchJson(specUrls.eventCapture)}\n`, "utf8"); + + const coreOutput = run("pnpm", [ + "dlx", + "@tim-smart/openapi-gen@1.0.3", + "--spec", + corePath, + "--name", + "VoidhashCoreClient", + ]); + writeFileSync(path.join(generatedClientsRoot, "src/core/generated.ts"), coreOutput, "utf8"); + + const eventCaptureOutput = run("pnpm", [ + "dlx", + "@tim-smart/openapi-gen@1.0.3", + "--spec", + eventCapturePath, + "--name", + "VoidhashEventCaptureClient", + ]); + writeFileSync( + path.join(generatedClientsRoot, "src/event-capture/generated.ts"), + eventCaptureOutput, + "utf8", + ); + + mkdirSync(nodeGeneratedRoot, { recursive: true }); + run("node", [ + "./scripts/generate-node-grouped-client.mjs", + corePath, + path.join(nodeGeneratedRoot, "grouped-client.ts"), + ]); +}; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); From db51cbaeccf273b279ea91ea953fd60f0331ec7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Sun, 26 Apr 2026 23:04:44 +0200 Subject: [PATCH 010/129] wip --- examples/react-native-example/.env.example | 5 +- examples/react-native-example/babel.config.js | 11 +- examples/react-native-example/package.json | 1 + examples/react-native-example/tsconfig.json | 1 + .../utils/voidhash/client.ts | 10 +- .../utils/voidhash/local.client.ts | 78 ++++---- .../node/src/generated/grouped-client.ts | 12 +- libraries/node/tests/client.test.ts | 14 +- .../__tests__/helpers/effect-test-harness.ts | 4 +- libraries/react-native/src/core/event-bus.ts | 2 +- .../core/identity/customer-info-manager.ts | 2 +- .../src/core/networking/api-client.ts | 18 +- .../src/react/hooks/use-customer.ts | 2 +- .../web/src/core/networking/api-client.ts | 18 +- package.json | 5 +- packages/generated-clients/openapi/core.json | 1 + .../openapi/event-capture.json | 1 + packages/generated-clients/package.json | 1 + .../generated-clients/src/core/generated.ts | 176 +++++++++--------- .../src/event-capture/generated.ts | 4 +- scripts/generate-openapi-clients.mjs | 82 ++++---- 21 files changed, 233 insertions(+), 215 deletions(-) create mode 100644 packages/generated-clients/openapi/core.json create mode 100644 packages/generated-clients/openapi/event-capture.json diff --git a/examples/react-native-example/.env.example b/examples/react-native-example/.env.example index 2173b32c5..6aeba8757 100644 --- a/examples/react-native-example/.env.example +++ b/examples/react-native-example/.env.example @@ -2,7 +2,4 @@ NODE_ENV=production APP_ENV=production EXPO_PUBLIC_VOIDHASH_PUBLISHABLE_KEY=vh_pk_your_publishable_key # Optional explicit API URL override (e.g. http://192.168.1.100:5001) -EXPO_PUBLIC_VOIDHASH_API_URL= -# Optional local API switch: 1/true to infer local host on port 5001. -# In development, local API is used by default when EXPO_PUBLIC_VOIDHASH_API_URL is unset. -EXPO_PUBLIC_VOIDHASH_USE_LOCAL_API= +EXPO_PUBLIC_VOIDHASH_API_URL=http://localhost:8787 diff --git a/examples/react-native-example/babel.config.js b/examples/react-native-example/babel.config.js index f17c4638b..5f8c26eee 100644 --- a/examples/react-native-example/babel.config.js +++ b/examples/react-native-example/babel.config.js @@ -3,6 +3,15 @@ module.exports = (api) => { const plugins = []; return { plugins, - presets: ["babel-preset-expo"], + presets: [ + [ + "babel-preset-expo", + { + native: { + unstable_transformImportMeta: true, + }, + }, + ], + ], }; }; diff --git a/examples/react-native-example/package.json b/examples/react-native-example/package.json index bfb331f4d..2efde49e6 100644 --- a/examples/react-native-example/package.json +++ b/examples/react-native-example/package.json @@ -10,6 +10,7 @@ "start": "expo start --dev-client", "prebuild": "expo prebuild", "lint": "biome check .", + "typecheck": "tsc --noEmit", "format": "biome format .", "web": "expo start --web", "xcode": "xed ios", diff --git a/examples/react-native-example/tsconfig.json b/examples/react-native-example/tsconfig.json index 79279d1b6..4430a302c 100644 --- a/examples/react-native-example/tsconfig.json +++ b/examples/react-native-example/tsconfig.json @@ -4,6 +4,7 @@ "compilerOptions": { "strict": true, "jsx": "react-jsx", + "types": ["node"], "paths": { "*": ["./*"] } diff --git a/examples/react-native-example/utils/voidhash/client.ts b/examples/react-native-example/utils/voidhash/client.ts index 83da435a1..fb855fa2b 100644 --- a/examples/react-native-example/utils/voidhash/client.ts +++ b/examples/react-native-example/utils/voidhash/client.ts @@ -1,8 +1,8 @@ import { createVoidhashClient } from "@voidhash/react-native"; import * as schema from "./schema"; -export const voidhash = createVoidhashClient( - "vh_pk_PWIsDyqMEOOuHpmAsFQvwaFhGKBVlkHf", - schema, - {} -); +// export const voidhash = createVoidhashClient( +// "vh_pk_PWIsDyqMEOOuHpmAsFQvwaFhGKBVlkHf", +// schema, +// {} +// ); diff --git a/examples/react-native-example/utils/voidhash/local.client.ts b/examples/react-native-example/utils/voidhash/local.client.ts index 84a7eae8c..5a5d3316e 100644 --- a/examples/react-native-example/utils/voidhash/local.client.ts +++ b/examples/react-native-example/utils/voidhash/local.client.ts @@ -3,59 +3,47 @@ import Constants from "expo-constants"; import * as schema from "./_schema"; -const debuggerHost = - ( - Constants.expoConfig as { - debuggerHost?: string; - hostUri?: string; - } | null - )?.debuggerHost ?? - ( - Constants.expoConfig as { - debuggerHost?: string; - hostUri?: string; - } | null - )?.hostUri ?? - ( - Constants.manifest2 as { - extra?: { - expoGo?: { - debuggerHost?: string; +function resolveHostIp() { + const debuggerHost = + ( + Constants.expoConfig as { + debuggerHost?: string; + hostUri?: string; + } | null + )?.debuggerHost ?? + ( + Constants.expoConfig as { + debuggerHost?: string; + hostUri?: string; + } | null + )?.hostUri ?? + ( + Constants.manifest2 as { + extra?: { + expoGo?: { + debuggerHost?: string; + }; }; - }; - } | null - )?.extra?.expoGo?.debuggerHost; -const localhost = debuggerHost?.split(":")[0]; -const useLocalApiFromEnv = - process.env.EXPO_PUBLIC_VOIDHASH_USE_LOCAL_API === "1" || - process.env.EXPO_PUBLIC_VOIDHASH_USE_LOCAL_API === "true"; -const defaultToLocalApiInDev = - __DEV__ && - process.env.EXPO_PUBLIC_VOIDHASH_USE_LOCAL_API == null && - process.env.EXPO_PUBLIC_VOIDHASH_API_URL == null; -const useLocalApi = useLocalApiFromEnv || defaultToLocalApiInDev; -const inferredLocalBaseUrl = localhost - ? `http://${localhost}:5001` - : "http://localhost:5001"; + } | null + )?.extra?.expoGo?.debuggerHost; -const baseUrl = - process.env.EXPO_PUBLIC_VOIDHASH_API_URL ?? - (useLocalApi ? inferredLocalBaseUrl : undefined); - -if (__DEV__) { - console.log( - `[voidhash-example] Resolved API URL: ${baseUrl ?? "https://api.voidhash.com"}` - ); + return debuggerHost?.split(":")[0]; } -// Defaults to local API in development and Voidhash cloud API in production. -// Set EXPO_PUBLIC_VOIDHASH_API_URL to explicitly override base URL. -// Set EXPO_PUBLIC_VOIDHASH_USE_LOCAL_API=1/true to force inferred local URL. +const baseUrl = process.env.EXPO_PUBLIC_VOIDHASH_API_URL?.replace( + "localhost", + resolveHostIp() ?? "localhost", +); + const clientOptions = { debug: true, ...(baseUrl ? { baseUrl } : {}), }; -const publishableKey = "vh_pk_PWIsDyqMEOOuHpmAsFQvwaFhGKBVlkHf"; +const publishableKey = + process.env.EXPO_PUBLIC_VOIDHASH_PUBLISHABLE_KEY ?? + "vh_pk_hrvyOZJoxtonGGPtTnkMehrCoEPsAbwD"; + +console.log(baseUrl, publishableKey); export const voidhash = createVoidhashClient( publishableKey, diff --git a/libraries/node/src/generated/grouped-client.ts b/libraries/node/src/generated/grouped-client.ts index 80d9a880a..989908c25 100644 --- a/libraries/node/src/generated/grouped-client.ts +++ b/libraries/node/src/generated/grouped-client.ts @@ -14,12 +14,6 @@ export const groupCoreClient = (client: VoidhashCoreClient) => ({ changesets: { deployChangeset: (request: { payload: Parameters[0] }) => client.changesetsDeployChangeset(request.payload), }, - customers: { - byDistinctId: (request: { params: { readonly "distinctId": string } }) => client.customersByDistinctId(request.params["distinctId"]), - createCustomer: (request: { payload: Parameters[0] }) => client.customersCreateCustomer(request.payload), - getCustomerById: (request: { params: { readonly "customerId": string } }) => client.customersGetCustomerById(request.params["customerId"]), - listCustomers: () => client.customersListCustomers(), - }, organizations: { createOrganization: (request: { payload: Parameters[0] }) => client.organizationsCreateOrganization(request.payload), }, @@ -35,6 +29,12 @@ export const groupCoreClient = (client: VoidhashCoreClient) => ({ perks: { listPerks: () => client.perksListPerks(), }, + persons: { + createPerson: (request: { payload: Parameters[0] }) => client.personsCreatePerson(request.payload), + getPersonByDistinctId: (request: { params: { readonly "distinctId": string } }) => client.personsGetPersonByDistinctId(request.params["distinctId"]), + getPersonById: (request: { params: { readonly "personId": string } }) => client.personsGetPersonById(request.params["personId"]), + listPersons: () => client.personsListPersons(), + }, productPerks: { listProductPerksByProductId: (request: { params: { readonly "productId": string } }) => client.productPerksListProductPerksByProductId(request.params["productId"]), }, diff --git a/libraries/node/tests/client.test.ts b/libraries/node/tests/client.test.ts index 1e61fec24..d61efd1c9 100644 --- a/libraries/node/tests/client.test.ts +++ b/libraries/node/tests/client.test.ts @@ -23,12 +23,12 @@ const EXPECTED_GROUPS = [ "apiKeys", "auth", "changesets", - "customers", "organizations", "paymentProviderConfigurations", "paymentProviderProducts", "paywallLocations", "perks", + "persons", "productPerks", "products", "projects", @@ -181,10 +181,10 @@ describe("@voidhash/node", () => { expect(calls[0]?.headers["x-secret-key"]).toBe("vh_sk_test"); }); - it("supports POST bodies with customers.createCustomer({ payload })", async () => { + it("supports POST bodies with persons.createPerson({ payload })", async () => { const { calls } = installFetchMock(() => createJsonResponse({ - customerId: "customer_123", + personId: "person_123", distinctId: "user_123", email: "user@example.com", name: "Taylor", @@ -196,7 +196,7 @@ describe("@voidhash/node", () => { secretKey: "vh_sk_test", }); - const customer = await client.customers.createCustomer({ + const person = await client.persons.createPerson({ payload: { distinctId: "user_123", email: "user@example.com", @@ -204,14 +204,14 @@ describe("@voidhash/node", () => { }, }); - expect(customer).toEqual({ - customerId: "customer_123", + expect(person).toEqual({ + personId: "person_123", distinctId: "user_123", email: "user@example.com", name: "Taylor", }); expect(calls[0]?.method).toBe("POST"); - expect(calls[0]?.url).toBe("https://api.voidhash.test/api/v1/customers"); + expect(calls[0]?.url).toBe("https://api.voidhash.test/api/v1/persons"); expect(JSON.parse(calls[0]?.body ?? "{}")).toEqual({ distinctId: "user_123", email: "user@example.com", diff --git a/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts b/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts index f28bf4268..02ce3fc59 100644 --- a/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts +++ b/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts @@ -1,4 +1,4 @@ -import type { SdkCustomer } from "@voidhash/generated-clients"; +import type { SdkPerson as SdkCustomer } from "@voidhash/generated-clients"; import { Effect, Layer, ManagedRuntime, pipe } from "effect"; import { CacheAdapter } from "../../core/caching/cache-adapter"; @@ -47,7 +47,7 @@ export interface ApiClientDoubleOptions { export function createSdkCustomer(distinctId: string) { return { distinctId, - customerId: `customer-${distinctId}`, + personId: `person-${distinctId}`, email: null, name: null, } as SdkCustomer; diff --git a/libraries/react-native/src/core/event-bus.ts b/libraries/react-native/src/core/event-bus.ts index c25535d21..f475f1bb6 100644 --- a/libraries/react-native/src/core/event-bus.ts +++ b/libraries/react-native/src/core/event-bus.ts @@ -1,4 +1,4 @@ -import type { SdkCustomer } from "@voidhash/generated-clients"; +import type { SdkPerson as SdkCustomer } from "@voidhash/generated-clients"; import { ServiceMap } from "effect"; export interface CustomerFetchedEvent { diff --git a/libraries/react-native/src/core/identity/customer-info-manager.ts b/libraries/react-native/src/core/identity/customer-info-manager.ts index e425bb7e5..a9cfbe3e5 100644 --- a/libraries/react-native/src/core/identity/customer-info-manager.ts +++ b/libraries/react-native/src/core/identity/customer-info-manager.ts @@ -1,4 +1,4 @@ -import type { SdkCustomer } from "@voidhash/generated-clients"; +import type { SdkPerson as SdkCustomer } from "@voidhash/generated-clients"; import { Effect, Layer, ServiceMap } from "effect"; import { CacheManager } from "../caching/cache-manager"; diff --git a/libraries/react-native/src/core/networking/api-client.ts b/libraries/react-native/src/core/networking/api-client.ts index dc260942f..3a8230e8e 100644 --- a/libraries/react-native/src/core/networking/api-client.ts +++ b/libraries/react-native/src/core/networking/api-client.ts @@ -4,10 +4,12 @@ import { type EvaluateFeatureFlagsBody, type SdkEvaluateFeatureFlagsParams, type SdkFeatureFlagsResponse, - type SdkGetCustomerParams, + type SdkGetPersonParams, + type SdkIdentifyPersonParams, type SdkIdentifyBody, type SdkResolvePaywallBody, - type SdkSyncCustomerAttributesBody, + type SdkSyncPersonAttributesBody, + type SdkSyncPersonAttributesParams, type SdkSyncTransactionRequest, } from "@voidhash/generated-clients"; import { Effect, Layer, ServiceMap } from "effect"; @@ -82,13 +84,13 @@ const bindReactNativeSdkClient = (client: VoidhashCoreClient) => ({ normalizeFeatureFlagsResponse ), getCustomer: (request: { headers: ReactNativeSdkHeaders }) => - client.sdkGetCustomer(request.headers as SdkGetCustomerParams), + client.sdkGetPerson(request.headers as SdkGetPersonParams), identify: (request: { headers: ReactNativeSdkHeaders; payload: SdkIdentifyBody; }) => - client.sdkIdentify({ - params: request.headers as Parameters[0]["params"], + client.sdkIdentifyPerson({ + params: request.headers as SdkIdentifyPersonParams, payload: request.payload, }), resolvePaywall: (request: { @@ -101,10 +103,10 @@ const bindReactNativeSdkClient = (client: VoidhashCoreClient) => ({ }), syncCustomerAttributes: (request: { headers: ReactNativeSdkHeaders; - payload: SdkSyncCustomerAttributesBody; + payload: SdkSyncPersonAttributesBody; }) => - client.sdkSyncCustomerAttributes({ - params: request.headers as Parameters[0]["params"], + client.sdkSyncPersonAttributes({ + params: request.headers as SdkSyncPersonAttributesParams, payload: request.payload, }), syncTransaction: (request: { diff --git a/libraries/react-native/src/react/hooks/use-customer.ts b/libraries/react-native/src/react/hooks/use-customer.ts index 5118a2479..9188b0ce6 100644 --- a/libraries/react-native/src/react/hooks/use-customer.ts +++ b/libraries/react-native/src/react/hooks/use-customer.ts @@ -1,4 +1,4 @@ -import type { SdkCustomer } from "@voidhash/generated-clients"; +import type { SdkPerson as SdkCustomer } from "@voidhash/generated-clients"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import type { VoidhashClient } from "../../client"; diff --git a/libraries/web/src/core/networking/api-client.ts b/libraries/web/src/core/networking/api-client.ts index 4d26174bc..bec06e743 100644 --- a/libraries/web/src/core/networking/api-client.ts +++ b/libraries/web/src/core/networking/api-client.ts @@ -4,10 +4,12 @@ import { type EvaluateFeatureFlagsBody, type SdkEvaluateFeatureFlagsParams, type SdkFeatureFlagsResponse, - type SdkGetCustomerParams, + type SdkGetPersonParams, + type SdkIdentifyPersonParams, type SdkIdentifyBody, type SdkResolvePaywallBody, - type SdkSyncCustomerAttributesBody, + type SdkSyncPersonAttributesBody, + type SdkSyncPersonAttributesParams, } from "@voidhash/generated-clients"; import { Effect, Layer, ServiceMap } from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; @@ -70,13 +72,13 @@ const bindWebSdkClient = (client: VoidhashCoreClient) => ({ normalizeFeatureFlagsResponse ), getCustomer: (request: { headers: WebSdkHeaders }) => - client.sdkGetCustomer(request.headers as SdkGetCustomerParams), + client.sdkGetPerson(request.headers as SdkGetPersonParams), identify: (request: { headers: WebSdkHeaders; payload: SdkIdentifyBody; }) => - client.sdkIdentify({ - params: request.headers as Parameters[0]["params"], + client.sdkIdentifyPerson({ + params: request.headers as SdkIdentifyPersonParams, payload: request.payload, }), resolvePaywall: (request: { @@ -89,10 +91,10 @@ const bindWebSdkClient = (client: VoidhashCoreClient) => ({ }), syncCustomerAttributes: (request: { headers: WebSdkHeaders; - payload: SdkSyncCustomerAttributesBody; + payload: SdkSyncPersonAttributesBody; }) => - client.sdkSyncCustomerAttributes({ - params: request.headers as Parameters[0]["params"], + client.sdkSyncPersonAttributes({ + params: request.headers as SdkSyncPersonAttributesParams, payload: request.payload, }), }, diff --git a/package.json b/package.json index 11307c757..79f950596 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,9 @@ "lint": "biome check .", "lint:fix": "biome check . --apply", "bump": "bumpp", - "openapi:generate:preview": "node ./scripts/generate-openapi-clients.mjs preview", - "openapi:generate:production": "node ./scripts/generate-openapi-clients.mjs production", + "openapi:generate": "node ./scripts/generate-openapi-clients.mjs", + "openapi:generate:dev": "pnpm openapi:generate -- localhost:8787", + "openapi:generate:prod": "pnpm openapi:generate -- api.voidhash.com", "test": "turbo test", "typecheck": "turbo typecheck", "lines-of-code": "npx cloc . --exclude-dir=.next,node_modules,.vercel,.turbo,.git,meta,.github,.expo,build,.expo-shared,nitrogen --vcs git --not-match-f=pnpm-lock.yaml,meta.json --exclude-ext=yaml,md" diff --git a/packages/generated-clients/openapi/core.json b/packages/generated-clients/openapi/core.json new file mode 100644 index 000000000..0336e4b39 --- /dev/null +++ b/packages/generated-clients/openapi/core.json @@ -0,0 +1 @@ +{"openapi":"3.1.0","info":{"title":"Api","version":"0.0.1"},"paths":{"/api/v1/auth/session":{"get":{"tags":["auth"],"operationId":"auth.session","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"method":{"anyOf":[{"type":"string","enum":["api-key"]},{"type":"string","enum":["publishable-key"]},{"type":"string","enum":["secret-key"]}]},"name":{"type":"string"},"organizations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false}},"projects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"organizationId":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","organizationId","slug"],"additionalProperties":false}}},"required":["method","name","organizations","projects"],"additionalProperties":false}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"Error","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}}}}}}},"/api/v1/api-keys":{"post":{"tags":["api_keys"],"operationId":"api_keys.createSecretKey","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"ApiKeyWithRawKey","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyWithRawKey"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSecretKeyBody"}}},"required":true}},"get":{"tags":["api_keys"],"operationId":"api_keys.listApiKeys","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/api-keys/{apiKeyId}":{"get":{"tags":["api_keys"],"operationId":"api_keys.getApiKeyById","parameters":[{"name":"apiKeyId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"ApiKey","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKey"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"ApiKeyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyNotFoundError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}},"delete":{"tags":["api_keys"],"operationId":"api_keys.deleteApiKey","parameters":[{"name":"apiKeyId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"204":{"description":""},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"ApiKeyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyNotFoundError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/api-keys/{apiKeyId}/rotate":{"post":{"tags":["api_keys"],"operationId":"api_keys.rotateSecretKey","parameters":[{"name":"apiKeyId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"ApiKeyWithRawKey","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyWithRawKey"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"ApiKeyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyNotFoundError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/persons":{"post":{"tags":["persons"],"operationId":"persons.createPerson","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Person","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"400":{"description":"PersonInvalidAnonymousIdError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonInvalidAnonymousIdError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePersonBody"}}},"required":true}},"get":{"tags":["persons"],"operationId":"persons.listPersons","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Person"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/persons/{personId}":{"get":{"tags":["persons"],"operationId":"persons.getPersonById","parameters":[{"name":"personId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Person","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"PersonNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonNotFoundError"}}}},"500":{"description":"PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/persons/by-distinct-id/{distinctId}":{"get":{"tags":["persons"],"operationId":"persons.getPersonByDistinctId","parameters":[{"name":"distinctId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Person","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"PersonNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonNotFoundError"}}}},"500":{"description":"PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/organizations":{"post":{"tags":["organizations"],"operationId":"organizations.createOrganization","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Organization","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"500":{"description":"OrganizationServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/OrganizationServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrganizationBody"}}},"required":true}}},"/api/v1/perks":{"get":{"tags":["perks"],"operationId":"perks.listPerks","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Perk"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PerkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PerkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/paywall-locations":{"get":{"tags":["paywall_locations"],"operationId":"paywall_locations.listPaywallLocations","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PaywallLocation"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PaywallLocationServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PaywallLocationServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/projects":{"post":{"tags":["projects"],"operationId":"projects.createProject","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Project","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Project"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"AuthenticationError | ProjectServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/ProjectServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectBody"}}},"required":true}}},"/api/v1/projects/{organizationId}":{"get":{"tags":["projects"],"operationId":"projects.listProjects","parameters":[{"name":"organizationId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Project"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ProjectServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProjectServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/products":{"get":{"tags":["products"],"operationId":"products.listProducts","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Product"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ProductServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProductServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/product-perks/by-product-id/{productId}":{"get":{"tags":["product_perks"],"operationId":"product_perks.listProductPerksByProductId","parameters":[{"name":"productId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProductPerk"}}}}},"400":{"description":"ProductPerkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProductPerkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ProductPerkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProductPerkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/sdk/person":{"get":{"tags":["sdk"],"operationId":"sdk.getPerson","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"SdkPerson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPerson"}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"404":{"description":"SdkPersonNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPersonNotFoundError"}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/sdk/identify":{"post":{"tags":["sdk"],"operationId":"sdk.identifyPerson","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"SdkPerson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPerson"}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"409":{"description":"SdkPersonAlreadyIdentifiedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPersonAlreadyIdentifiedError"}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkIdentifyBody"}}},"required":true}}},"/api/v1/sdk/person/traits":{"post":{"tags":["sdk"],"operationId":"sdk.syncPersonAttributes","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"SdkPerson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPerson"}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkSyncPersonAttributesBody"}}},"required":true}}},"/api/v1/sdk/sync-transaction":{"post":{"tags":["sdk"],"operationId":"sdk.syncTransaction","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"SdkSyncTransactionResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkSyncTransactionResponse"}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"anyOf":[{"type":"string","enum":["ios"]},{"type":"string","enum":["android"]}]},"productId":{"type":"string"},"purchaseDate":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"purchaseToken":{"anyOf":[{"type":"string"},{"type":"null"}]},"quantity":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"receipt":{"anyOf":[{"type":"string"},{"type":"null"}]},"transactionId":{"type":"string"}},"required":["platform","productId","purchaseDate","quantity","transactionId"],"additionalProperties":false}}},"required":true}}},"/api/v1/sdk/evaluate-flags":{"post":{"tags":["sdk"],"operationId":"sdk.evaluateFeatureFlags","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"SdkFeatureFlagsResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkFeatureFlagsResponse"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluateFeatureFlagsBody"}}},"required":true}}},"/api/v1/sdk/resolve-paywall":{"post":{"tags":["sdk"],"operationId":"sdk.resolvePaywall","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkResolvedPaywall"},{"type":"null"}]}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkResolvePaywallBody"}}},"required":true}}},"/api/v1/users/current":{"get":{"tags":["users"],"operationId":"users.getUser","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"User","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"500":{"description":"AuthenticationError | UserServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/UserServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/payment-provider-configurations":{"get":{"tags":["payment_provider_configurations"],"operationId":"payment_provider_configurations.listPaymentProviderConfigurations","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PaymentProviderConfiguration"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PaymentProviderConfigurationServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PaymentProviderConfigurationServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/payment-provider-products":{"get":{"tags":["payment_provider_products"],"operationId":"payment_provider_products.listPaymentProviderProducts","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PaymentProviderProduct"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PaymentProviderProductServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PaymentProviderProductServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/changesets/deploy":{"post":{"tags":["changesets"],"operationId":"changesets.deployChangeset","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"DeployChangesetResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeployChangesetResponse"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"AuthenticationError | ChangesetDeploymentServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/ChangesetDeploymentServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeployChangesetBody"}}},"required":true}}},"/api/v1/webhooks/endpoints":{"post":{"tags":["webhooks"],"operationId":"webhooks.createWebhookEndpoint","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"WebhookValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhookEndpointBody"}}},"required":true}},"get":{"tags":["webhooks"],"operationId":"webhooks.listWebhookEndpoints","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEndpoint"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/endpoints/{endpointId}":{"get":{"tags":["webhooks"],"operationId":"webhooks.getWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}},"patch":{"tags":["webhooks"],"operationId":"webhooks.updateWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"WebhookValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWebhookEndpointBody"}}},"required":true}},"delete":{"tags":["webhooks"],"operationId":"webhooks.deleteWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"204":{"description":""},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/endpoints/{endpointId}/rotate-secret":{"post":{"tags":["webhooks"],"operationId":"webhooks.rotateWebhookSecret","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/endpoints/{endpointId}/test":{"post":{"tags":["webhooks"],"operationId":"webhooks.testWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"WebhookDelivery","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDelivery"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/deliveries":{"get":{"tags":["webhooks"],"operationId":"webhooks.listWebhookDeliveries","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDelivery"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/deliveries/{deliveryId}":{"get":{"tags":["webhooks"],"operationId":"webhooks.getWebhookDelivery","parameters":[{"name":"deliveryId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"WebhookDeliveryWithAttempts","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryWithAttempts"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookDeliveryNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/deliveries/{deliveryId}/retry":{"post":{"tags":["webhooks"],"operationId":"webhooks.retryWebhookDelivery","parameters":[{"name":"deliveryId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"WebhookDelivery","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDelivery"}}}},"400":{"description":"WebhookValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookDeliveryNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}}},"components":{"schemas":{"ActionForbiddenError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ActionForbiddenError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"AuthenticationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["AuthenticationError"]},"cause":{"type":"string"},"message":{"type":"string"}},"required":["_tag","cause","message"],"additionalProperties":false},"NotAuthenticatedError":{"type":"object","properties":{"_tag":{"type":"string","enum":["NotAuthenticatedError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"effect_HttpApiSchemaError":{"type":"object","properties":{"_tag":{"type":"string","enum":["HttpApiSchemaError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"CreateSecretKeyBody":{"type":"object","properties":{"name":{"type":"string"},"projectId":{"type":"string"}},"required":["name","projectId"],"additionalProperties":false},"ApiKeyWithRawKey":{"type":"object","properties":{"end":{"type":"string"},"id":{"type":"string"},"isPublic":{"type":"boolean"},"name":{"type":"string"},"prefix":{"type":"string"},"projectId":{"type":"string"},"rawKey":{"type":"string"}},"required":["end","id","isPublic","name","prefix","projectId","rawKey"],"additionalProperties":false},"ApiKeyServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ApiKeyServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"ApiKey":{"type":"object","properties":{"end":{"type":"string"},"id":{"type":"string"},"isPublic":{"type":"boolean"},"name":{"type":"string"},"prefix":{"type":"string"},"projectId":{"type":"string"},"rawKey":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["end","id","isPublic","name","prefix","projectId"],"additionalProperties":false},"ApiKeyNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ApiKeyNotFoundError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"CreatePersonBody":{"type":"object","properties":{"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["distinctId"],"additionalProperties":false},"Person":{"type":"object","properties":{"personId":{"type":"string"},"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["personId","distinctId","email","name"],"additionalProperties":false},"PersonInvalidAnonymousIdError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PersonInvalidAnonymousIdError"]},"id":{"type":"string","allOf":[{"minLength":1}]}},"required":["_tag","id"],"additionalProperties":false},"PersonServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PersonServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PersonNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PersonNotFoundError"]},"id":{"type":"string","allOf":[{"minLength":1}]}},"required":["_tag","id"],"additionalProperties":false},"CreateOrganizationBody":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false},"Organization":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},"OrganizationServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["OrganizationServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"Perk":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","projectId","slug"],"additionalProperties":false},"PerkServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PerkServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PaywallLocation":{"type":"object","properties":{"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"slug":{"type":"string"}},"required":["description","id","name","projectId","slug"],"additionalProperties":false},"PaywallLocationServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PaywallLocationServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"CreateProjectBody":{"type":"object","properties":{"name":{"type":"string"},"organizationId":{"type":"string"}},"required":["name","organizationId"],"additionalProperties":false},"Project":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},"ProjectServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProjectServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"Product":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"slug":{"type":"string"},"type":{"anyOf":[{"type":"string","enum":["subscription"]},{"type":"string","enum":["one-time"]},{"type":"string","enum":["one-time-consumable"]}]}},"required":["id","name","projectId","slug","type"],"additionalProperties":false},"ProductServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProductServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"ProductPerk":{"type":"object","properties":{"id":{"type":"string"},"perkId":{"type":"string"},"productId":{"type":"string"}},"required":["id","perkId","productId"],"additionalProperties":false},"ProductPerkServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProductPerkServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"ProductPerkValidationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProductPerkValidationError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"SdkPerson":{"type":"object","properties":{"personId":{"type":"string"},"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["personId","distinctId","email","name"],"additionalProperties":false},"SdkServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["SdkServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"SdkPersonNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["SdkPersonNotFoundError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"SdkValidationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["SdkValidationError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"SdkIdentifyBody":{"type":"object","properties":{"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"traits":{"anyOf":[{"type":"object","additionalProperties":{"anyOf":[{"type":"string"},{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"boolean"},{"type":"null"}]}},{"type":"null"}]}},"required":["distinctId"],"additionalProperties":false},"SdkPersonAlreadyIdentifiedError":{"type":"object","properties":{"_tag":{"type":"string","enum":["SdkPersonAlreadyIdentifiedError"]},"distinctId":{"type":"string"}},"required":["_tag","distinctId"],"additionalProperties":false},"SdkSyncPersonAttributesBody":{"type":"object","properties":{"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"traits":{"anyOf":[{"type":"object","additionalProperties":{"anyOf":[{"type":"string"},{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"boolean"},{"type":"null"}]}},{"type":"null"}]}},"additionalProperties":false},"SdkSyncTransactionResponse":{"type":"object","properties":{"accepted":{"type":"boolean"}},"required":["accepted"],"additionalProperties":false},"EvaluateFeatureFlagsBody":{"type":"object","properties":{"flagKeys":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]}},"additionalProperties":false},"SdkFeatureFlagResult":{"type":"object","properties":{"enabled":{"type":"boolean"},"key":{"type":"string"},"payload":{"anyOf":[{"type":"null"},{"type":"null"}]},"variantKey":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["enabled","key","payload","variantKey"],"additionalProperties":false},"SdkFeatureFlagsResponse":{"type":"object","properties":{"flags":{"type":"array","items":{"$ref":"#/components/schemas/SdkFeatureFlagResult"}}},"required":["flags"],"additionalProperties":false},"SdkResolvePaywallBody":{"type":"object","properties":{"locationSlug":{"type":"string"}},"required":["locationSlug"],"additionalProperties":false},"SdkResolvedPaywallShowing":{"type":"object","properties":{"id":{"type":"string"},"paywall":{"anyOf":[{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},{"type":"null"}]},"paywallId":{"anyOf":[{"type":"string"},{"type":"null"}]},"paywallRelease":{"anyOf":[{"type":"object","properties":{"htmlUrl":{"type":"string"},"publishedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"releaseId":{"type":"string"},"version":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]}},"required":["htmlUrl","publishedAt","releaseId","version"],"additionalProperties":false},{"type":"null"}]},"paywallReleaseId":{"anyOf":[{"type":"string"},{"type":"null"}]},"startedAt":{"type":"string"},"type":{"anyOf":[{"type":"string","enum":["paywall_release"]},{"type":"string","enum":["feature_flag"]}]}},"required":["id","paywall","paywallId","paywallRelease","paywallReleaseId","startedAt","type"],"additionalProperties":false},"SdkResolvedPaywall":{"type":"object","properties":{"location":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},"showing":{"$ref":"#/components/schemas/SdkResolvedPaywallShowing"}},"required":["location","showing"],"additionalProperties":false},"User":{"type":"object","properties":{"createdAt":{"type":"string"},"email":{"type":"string"},"emailVerified":{"type":"boolean"},"id":{"type":"string"},"image":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"organizations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"logo":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","logo","name","slug"],"additionalProperties":false}},"projects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"logo":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"organizationId":{"type":"string"},"slug":{"type":"string"}},"required":["id","logo","name","organizationId","slug"],"additionalProperties":false}},"updatedAt":{"type":"string"}},"required":["createdAt","email","emailVerified","id","image","name","organizations","projects","updatedAt"],"additionalProperties":false},"UserServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["UserServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PaymentProviderConfiguration":{"type":"object","properties":{"enabled":{"type":"boolean"},"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"providerId":{"type":"string"}},"required":["enabled","id","name","projectId","providerId"],"additionalProperties":false},"PaymentProviderConfigurationServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PaymentProviderConfigurationServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PaymentProviderProduct":{"type":"object","properties":{"configuration":{"type":"object","additionalProperties":{"type":"null"}},"id":{"type":"string"},"paymentProviderConfigurationId":{"type":"string"},"productId":{"type":"string"},"providerId":{"type":"string"}},"required":["configuration","id","paymentProviderConfigurationId","productId","providerId"],"additionalProperties":false},"PaymentProviderProductServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PaymentProviderProductServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"DeployChangesetBody":{"type":"object","properties":{"changeset":{"type":"object","properties":{"changes":{"type":"array","items":{"anyOf":[{"type":"object","properties":{"changeType":{"type":"string","enum":["create-paywall-location"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"description":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"null"}]},{"type":"null"}]},"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["update-paywall-location"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"description":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"null"}]},{"type":"null"}]},"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["archive-paywall-location"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"slug":{"type":"string"}},"required":["slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["create-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["update-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["delete-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"slug":{"type":"string"}},"required":["slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["create-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["update-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["delete-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"slug":{"type":"string"}},"required":["slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["create-product-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"perkSlug":{"type":"string"},"productSlug":{"type":"string"}},"required":["perkSlug","productSlug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["delete-product-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"perkSlug":{"type":"string"},"productSlug":{"type":"string"}},"required":["perkSlug","productSlug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["create-payment-provider-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"configuration":{"type":"object","additionalProperties":{"type":"null"}},"productSlug":{"type":"string"},"providerId":{"type":"string"}},"required":["configuration","productSlug","providerId"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["update-payment-provider-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"configuration":{"type":"object","additionalProperties":{"type":"null"}},"productSlug":{"type":"string"},"providerId":{"type":"string"}},"required":["configuration","productSlug","providerId"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["delete-payment-provider-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"productSlug":{"type":"string"},"providerId":{"type":"string"}},"required":["productSlug","providerId"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false}]}}},"required":["changes"],"additionalProperties":false}},"required":["changeset"],"additionalProperties":false},"DeployChangesetResponse":{"type":"object","properties":{"deploymentId":{"type":"string"}},"required":["deploymentId"],"additionalProperties":false},"ChangesetDeploymentServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ChangesetDeploymentServiceError"]},"cause":{"type":"null"}},"required":["_tag","cause"],"additionalProperties":false},"CreateWebhookEndpointBody":{"type":"object","properties":{"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"events":{"type":"array","items":{"type":"string"}},"name":{"type":"string"},"url":{"type":"string"}},"required":["events","name","url"],"additionalProperties":false},"WebhookEndpoint":{"type":"object","properties":{"consecutiveFailures":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"events":{"type":"array","items":{"anyOf":[{"type":"string","enum":["person.created"]},{"type":"string","enum":["person.updated"]},{"type":"string","enum":["person.deleted"]},{"type":"string","enum":["subscription.created"]},{"type":"string","enum":["subscription.renewed"]},{"type":"string","enum":["subscription.cancelled"]},{"type":"string","enum":["subscription.expired"]},{"type":"string","enum":["purchase.completed"]},{"type":"string","enum":["purchase.refunded"]}]}},"id":{"type":"string"},"lastSuccessAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"projectId":{"type":"string"},"secret":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["active"]},{"type":"string","enum":["disabled"]},{"type":"string","enum":["failed"]}]},"url":{"type":"string"}},"required":["consecutiveFailures","createdAt","description","events","id","lastSuccessAt","name","projectId","secret","status","url"],"additionalProperties":false},"WebhookValidationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["WebhookValidationError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"WebhookServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["WebhookServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"WebhookEndpointNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["WebhookEndpointNotFoundError"]},"endpointId":{"type":"string"}},"required":["_tag","endpointId"],"additionalProperties":false},"UpdateWebhookEndpointBody":{"type":"object","properties":{"description":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"null"}]},{"type":"null"}]},"events":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"status":{"anyOf":[{"anyOf":[{"type":"string","enum":["active"]},{"type":"string","enum":["disabled"]}]},{"type":"null"}]},"url":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},"WebhookDelivery":{"type":"object","properties":{"attemptCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"completedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"eventOccurredAt":{"type":"string"},"eventType":{"type":"string"},"id":{"type":"string"},"maxAttempts":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"nextAttemptAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"payload":{"type":"null"},"projectId":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["pending"]},{"type":"string","enum":["in_progress"]},{"type":"string","enum":["succeeded"]},{"type":"string","enum":["failed"]},{"type":"string","enum":["exhausted"]}]},"webhookEndpointId":{"type":"string"}},"required":["attemptCount","completedAt","createdAt","eventOccurredAt","eventType","id","maxAttempts","nextAttemptAt","payload","projectId","status","webhookEndpointId"],"additionalProperties":false},"WebhookDeliveryAttempt":{"type":"object","properties":{"attemptNumber":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"durationMs":{"anyOf":[{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"null"}]},"errorMessage":{"anyOf":[{"type":"string"},{"type":"null"}]},"id":{"type":"string"},"responseBody":{"anyOf":[{"type":"string"},{"type":"null"}]},"statusCode":{"anyOf":[{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"null"}]},"succeeded":{"type":"boolean"}},"required":["attemptNumber","createdAt","durationMs","errorMessage","id","responseBody","statusCode","succeeded"],"additionalProperties":false},"WebhookDeliveryWithAttempts":{"type":"object","properties":{"attemptCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"attempts":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDeliveryAttempt"}},"completedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"eventOccurredAt":{"type":"string"},"eventType":{"type":"string"},"id":{"type":"string"},"maxAttempts":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"nextAttemptAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"payload":{"type":"null"},"projectId":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["pending"]},{"type":"string","enum":["in_progress"]},{"type":"string","enum":["succeeded"]},{"type":"string","enum":["failed"]},{"type":"string","enum":["exhausted"]}]},"webhookEndpointId":{"type":"string"}},"required":["attemptCount","attempts","completedAt","createdAt","eventOccurredAt","eventType","id","maxAttempts","nextAttemptAt","payload","projectId","status","webhookEndpointId"],"additionalProperties":false},"WebhookDeliveryNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["WebhookDeliveryNotFoundError"]},"deliveryId":{"type":"string"}},"required":["_tag","deliveryId"],"additionalProperties":false}},"securitySchemes":{"apiKey":{"type":"apiKey","name":"x-api-key","in":"header"},"betterAuthCookie":{"type":"apiKey","name":"__Secure-better-auth.session_token","in":"cookie"},"publishableKey":{"type":"apiKey","name":"x-publishable-key","in":"header"},"secretKey":{"type":"apiKey","name":"x-secret-key","in":"header"}}},"security":[],"tags":[{"name":"auth"},{"name":"api_keys"},{"name":"persons"},{"name":"organizations"},{"name":"perks"},{"name":"paywall_locations"},{"name":"projects"},{"name":"products"},{"name":"product_perks"},{"name":"sdk"},{"name":"users"},{"name":"payment_provider_configurations"},{"name":"payment_provider_products"},{"name":"changesets"},{"name":"webhooks"}]} diff --git a/packages/generated-clients/openapi/event-capture.json b/packages/generated-clients/openapi/event-capture.json new file mode 100644 index 000000000..0cc0bf2f5 --- /dev/null +++ b/packages/generated-clients/openapi/event-capture.json @@ -0,0 +1 @@ +{"openapi":"3.1.0","info":{"title":"Api","version":"0.0.1"},"paths":{"/i/v1/capture":{"post":{"tags":["event_capture"],"operationId":"event_capture.capture","parameters":[],"security":[],"responses":{"200":{"description":"CaptureAcceptedResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CaptureAcceptedResponse"}}}},"400":{"description":"CaptureInvalidRequestError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/CaptureInvalidRequestError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"401":{"description":"CaptureUnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CaptureUnauthorizedError"}}}},"413":{"description":"CapturePayloadTooLargeError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CapturePayloadTooLargeError"}}}},"429":{"description":"CaptureRateLimitedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CaptureRateLimitedError"}}}},"500":{"description":"CaptureInternalServerError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CaptureInternalServerError"}}}},"503":{"description":"CaptureDependencyUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CaptureDependencyUnavailableError"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"uuid":{"type":"string","allOf":[{"minLength":1}]},"event":{"type":"string","allOf":[{"minLength":1}]},"context":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Union_"}},"properties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Union_1"}},"distinct_id":{"type":"string","allOf":[{"minLength":1}]},"session_id":{"anyOf":[{"type":"string","allOf":[{"minLength":1}]},{"type":"null"}]},"timestamp":{"anyOf":[{"type":"string","allOf":[{"format":"date-time"}]},{"type":"null"}]},"sent_at":{"type":"string","allOf":[{"format":"date-time"}]},"token":{"type":"string","allOf":[{"minLength":1}]}},"required":["uuid","event","context","properties","distinct_id","sent_at","token"],"additionalProperties":false}}},"required":true}}},"/i/v1/batch":{"post":{"tags":["event_capture"],"operationId":"event_capture.batch","parameters":[],"security":[],"responses":{"200":{"description":"CaptureAcceptedResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CaptureAcceptedResponse"}}}},"400":{"description":"CaptureInvalidRequestError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/CaptureInvalidRequestError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"401":{"description":"CaptureUnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CaptureUnauthorizedError"}}}},"413":{"description":"CapturePayloadTooLargeError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CapturePayloadTooLargeError"}}}},"429":{"description":"CaptureRateLimitedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CaptureRateLimitedError"}}}},"500":{"description":"CaptureInternalServerError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CaptureInternalServerError"}}}},"503":{"description":"CaptureDependencyUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CaptureDependencyUnavailableError"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"events":{"type":"array","prefixItems":[{"type":"object","properties":{"uuid":{"type":"string","allOf":[{"minLength":1}]},"event":{"type":"string","allOf":[{"minLength":1}]},"context":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Union_"}},"properties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Union_1"}},"distinct_id":{"type":"string","allOf":[{"minLength":1}]},"session_id":{"anyOf":[{"type":"string","allOf":[{"minLength":1}]},{"type":"null"}]},"timestamp":{"anyOf":[{"type":"string","allOf":[{"format":"date-time"}]},{"type":"null"}]}},"required":["uuid","event","context","properties","distinct_id"],"additionalProperties":false}],"minItems":1,"items":{"type":"object","properties":{"uuid":{"type":"string","allOf":[{"minLength":1}]},"event":{"type":"string","allOf":[{"minLength":1}]},"context":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Union_"}},"properties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Union_1"}},"distinct_id":{"type":"string","allOf":[{"minLength":1}]},"session_id":{"anyOf":[{"type":"string","allOf":[{"minLength":1}]},{"type":"null"}]},"timestamp":{"anyOf":[{"type":"string","allOf":[{"format":"date-time"}]},{"type":"null"}]}},"required":["uuid","event","context","properties","distinct_id"],"additionalProperties":false}},"sent_at":{"type":"string","allOf":[{"format":"date-time"}]},"token":{"type":"string","allOf":[{"minLength":1}]}},"required":["events","sent_at","token"],"additionalProperties":false}}},"required":true}}}},"components":{"schemas":{"Union_":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{"$ref":"#/components/schemas/Union_"}},{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Union_"}}]},"Union_1":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{"$ref":"#/components/schemas/Union_1"}},{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Union_1"}}]},"CaptureAcceptedResponse":{"type":"object","properties":{"accepted":{"type":"integer"},"rejected":{"type":"integer"}},"required":["accepted","rejected"],"additionalProperties":false},"CaptureInvalidRequestError":{"type":"object","properties":{"_tag":{"type":"string","enum":["CaptureInvalidRequestError"]},"error":{"type":"string","allOf":[{"minLength":1}]},"code":{"type":"string","enum":["invalid_request"]}},"required":["_tag","error","code"],"additionalProperties":false},"effect_HttpApiSchemaError":{"type":"object","properties":{"_tag":{"type":"string","enum":["HttpApiSchemaError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"CaptureUnauthorizedError":{"type":"object","properties":{"_tag":{"type":"string","enum":["CaptureUnauthorizedError"]},"error":{"type":"string","allOf":[{"minLength":1}]},"code":{"type":"string","enum":["unauthorized"]}},"required":["_tag","error","code"],"additionalProperties":false},"CapturePayloadTooLargeError":{"type":"object","properties":{"_tag":{"type":"string","enum":["CapturePayloadTooLargeError"]},"error":{"type":"string","allOf":[{"minLength":1}]},"code":{"type":"string","enum":["payload_too_large"]}},"required":["_tag","error","code"],"additionalProperties":false},"CaptureRateLimitedError":{"type":"object","properties":{"_tag":{"type":"string","enum":["CaptureRateLimitedError"]},"error":{"type":"string","allOf":[{"minLength":1}]},"code":{"type":"string","enum":["rate_limited"]},"retry_after_ms":{"anyOf":[{"type":"integer"},{"type":"null"}]}},"required":["_tag","error","code"],"additionalProperties":false},"CaptureDependencyUnavailableError":{"type":"object","properties":{"_tag":{"type":"string","enum":["CaptureDependencyUnavailableError"]},"error":{"type":"string","allOf":[{"minLength":1}]},"code":{"type":"string","enum":["dependency_unavailable"]}},"required":["_tag","error","code"],"additionalProperties":false},"CaptureInternalServerError":{"type":"object","properties":{"_tag":{"type":"string","enum":["CaptureInternalServerError"]},"error":{"type":"string","allOf":[{"minLength":1}]},"code":{"type":"string","enum":["internal_error"]}},"required":["_tag","error","code"],"additionalProperties":false}},"securitySchemes":{}},"security":[],"tags":[{"name":"event_capture"}]} diff --git a/packages/generated-clients/package.json b/packages/generated-clients/package.json index de8743462..2e9083ee4 100644 --- a/packages/generated-clients/package.json +++ b/packages/generated-clients/package.json @@ -9,6 +9,7 @@ "./event-capture": "./src/event-capture/index.ts" }, "scripts": { + "openapi:generate": "node ../../scripts/generate-openapi-clients.mjs", "typecheck": "tsgo --noEmit" }, "dependencies": {}, diff --git a/packages/generated-clients/src/core/generated.ts b/packages/generated-clients/src/core/generated.ts index d936f4b12..96bc2254e 100644 --- a/packages/generated-clients/src/core/generated.ts +++ b/packages/generated-clients/src/core/generated.ts @@ -105,51 +105,51 @@ export type ApiKeysDeleteApiKey500 = ApiKeyServiceError | AuthenticationError | export type ApiKeysRotateSecretKey500 = ApiKeyServiceError | AuthenticationError | NotAuthenticatedError -export interface Customer { - readonly "customerId": string; +export interface Person { + readonly "personId": string; readonly "distinctId": string; readonly "email": string | null; readonly "name": string | null } -export type CustomersListCustomers200 = ReadonlyArray +export type PersonsListPersons200 = ReadonlyArray -export type CustomerServiceErrorTag = "CustomerServiceError" +export type PersonServiceErrorTag = "PersonServiceError" -export interface CustomerServiceError { - readonly "_tag": CustomerServiceErrorTag; +export interface PersonServiceError { + readonly "_tag": PersonServiceErrorTag; readonly "cause": string } -export type CustomersListCustomers500 = CustomerServiceError | AuthenticationError | NotAuthenticatedError +export type PersonsListPersons500 = PersonServiceError | AuthenticationError | NotAuthenticatedError -export interface CreateCustomerBody { +export interface CreatePersonBody { readonly "distinctId": string; readonly "email"?: string | null | undefined; readonly "name"?: string | null | undefined } -export type CustomerInvalidAnonymousIdErrorTag = "CustomerInvalidAnonymousIdError" +export type PersonInvalidAnonymousIdErrorTag = "PersonInvalidAnonymousIdError" -export interface CustomerInvalidAnonymousIdError { - readonly "_tag": CustomerInvalidAnonymousIdErrorTag; +export interface PersonInvalidAnonymousIdError { + readonly "_tag": PersonInvalidAnonymousIdErrorTag; readonly "id": string } -export type CustomersCreateCustomer400 = CustomerInvalidAnonymousIdError | EffectHttpApiSchemaError +export type PersonsCreatePerson400 = PersonInvalidAnonymousIdError | EffectHttpApiSchemaError -export type CustomersCreateCustomer500 = CustomerServiceError | AuthenticationError | NotAuthenticatedError +export type PersonsCreatePerson500 = PersonServiceError | AuthenticationError | NotAuthenticatedError -export type CustomerNotFoundErrorTag = "CustomerNotFoundError" +export type PersonNotFoundErrorTag = "PersonNotFoundError" -export interface CustomerNotFoundError { - readonly "_tag": CustomerNotFoundErrorTag; +export interface PersonNotFoundError { + readonly "_tag": PersonNotFoundErrorTag; readonly "id": string } -export type CustomersGetCustomerById500 = CustomerServiceError | AuthenticationError | NotAuthenticatedError +export type PersonsGetPersonById500 = PersonServiceError | AuthenticationError | NotAuthenticatedError -export type CustomersByDistinctId500 = CustomerServiceError | AuthenticationError | NotAuthenticatedError +export type PersonsGetPersonByDistinctId500 = PersonServiceError | AuthenticationError | NotAuthenticatedError export interface CreateOrganizationBody { readonly "name": string @@ -278,40 +278,40 @@ export interface ProductPerkServiceError { export type ProductPerksListProductPerksByProductId500 = ProductPerkServiceError | AuthenticationError | NotAuthenticatedError -export type SdkGetCustomerParamsXIsBackgrounded = "false" +export type SdkGetPersonParamsXIsBackgrounded = "false" -export type SdkGetCustomerParamsXIsDebugBuildEnum = "false" +export type SdkGetPersonParamsXIsDebugBuildEnum = "false" -export type SdkGetCustomerParamsXObserverModeEnum = "false" +export type SdkGetPersonParamsXObserverModeEnum = "false" -export type SdkGetCustomerParamsXPlatformFlavorEnum = "browser" +export type SdkGetPersonParamsXPlatformFlavorEnum = "browser" -export type SdkGetCustomerParamsXSdkEnum = "web" +export type SdkGetPersonParamsXSdkEnum = "web" -export interface SdkGetCustomerParams { +export interface SdkGetPersonParams { readonly "x-distinct-id": string; readonly "x-publishable-key": string; readonly "x-client-bundle-id": string; readonly "x-client-locale"?: string | null | undefined; readonly "x-client-version"?: string | null | undefined; - readonly "x-is-backgrounded": SdkGetCustomerParamsXIsBackgrounded; - readonly "x-is-debug-build": SdkGetCustomerParamsXIsDebugBuildEnum | SdkGetCustomerParamsXIsDebugBuildEnum; + readonly "x-is-backgrounded": SdkGetPersonParamsXIsBackgrounded; + readonly "x-is-debug-build": SdkGetPersonParamsXIsDebugBuildEnum | SdkGetPersonParamsXIsDebugBuildEnum; readonly "x-nonce"?: string | null | undefined; - readonly "x-observer-mode": SdkGetCustomerParamsXObserverModeEnum | SdkGetCustomerParamsXObserverModeEnum; + readonly "x-observer-mode": SdkGetPersonParamsXObserverModeEnum | SdkGetPersonParamsXObserverModeEnum; readonly "x-platform": string; readonly "x-platform-brand"?: string | null | undefined; readonly "x-platform-device"?: string | null | undefined; - readonly "x-platform-flavor": SdkGetCustomerParamsXPlatformFlavorEnum | SdkGetCustomerParamsXPlatformFlavorEnum; + readonly "x-platform-flavor": SdkGetPersonParamsXPlatformFlavorEnum | SdkGetPersonParamsXPlatformFlavorEnum; readonly "x-platform-flavor-version"?: string | null | undefined; readonly "x-platform-version"?: string | null | undefined; readonly "x-preferred-locales"?: string | null | undefined; - readonly "x-sdk": SdkGetCustomerParamsXSdkEnum | SdkGetCustomerParamsXSdkEnum; + readonly "x-sdk": SdkGetPersonParamsXSdkEnum | SdkGetPersonParamsXSdkEnum; readonly "x-sdk-version": string; readonly "x-storefront"?: string | null | undefined } -export interface SdkCustomer { - readonly "customerId": string; +export interface SdkPerson { + readonly "personId": string; readonly "distinctId": string; readonly "email": string | null; readonly "name": string | null @@ -324,12 +324,12 @@ export interface SdkValidationError { readonly "message": string } -export type SdkGetCustomer400 = SdkValidationError | EffectHttpApiSchemaError +export type SdkGetPerson400 = SdkValidationError | EffectHttpApiSchemaError -export type SdkCustomerNotFoundErrorTag = "SdkCustomerNotFoundError" +export type SdkPersonNotFoundErrorTag = "SdkPersonNotFoundError" -export interface SdkCustomerNotFoundError { - readonly "_tag": SdkCustomerNotFoundErrorTag; +export interface SdkPersonNotFoundError { + readonly "_tag": SdkPersonNotFoundErrorTag; readonly "message": string } @@ -340,36 +340,36 @@ export interface SdkServiceError { readonly "cause": string } -export type SdkGetCustomer500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError +export type SdkGetPerson500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError -export type SdkIdentifyParamsXIsBackgrounded = "false" +export type SdkIdentifyPersonParamsXIsBackgrounded = "false" -export type SdkIdentifyParamsXIsDebugBuildEnum = "false" +export type SdkIdentifyPersonParamsXIsDebugBuildEnum = "false" -export type SdkIdentifyParamsXObserverModeEnum = "false" +export type SdkIdentifyPersonParamsXObserverModeEnum = "false" -export type SdkIdentifyParamsXPlatformFlavorEnum = "browser" +export type SdkIdentifyPersonParamsXPlatformFlavorEnum = "browser" -export type SdkIdentifyParamsXSdkEnum = "web" +export type SdkIdentifyPersonParamsXSdkEnum = "web" -export interface SdkIdentifyParams { +export interface SdkIdentifyPersonParams { readonly "x-distinct-id": string; readonly "x-publishable-key": string; readonly "x-client-bundle-id": string; readonly "x-client-locale"?: string | null | undefined; readonly "x-client-version"?: string | null | undefined; - readonly "x-is-backgrounded": SdkIdentifyParamsXIsBackgrounded; - readonly "x-is-debug-build": SdkIdentifyParamsXIsDebugBuildEnum | SdkIdentifyParamsXIsDebugBuildEnum; + readonly "x-is-backgrounded": SdkIdentifyPersonParamsXIsBackgrounded; + readonly "x-is-debug-build": SdkIdentifyPersonParamsXIsDebugBuildEnum | SdkIdentifyPersonParamsXIsDebugBuildEnum; readonly "x-nonce"?: string | null | undefined; - readonly "x-observer-mode": SdkIdentifyParamsXObserverModeEnum | SdkIdentifyParamsXObserverModeEnum; + readonly "x-observer-mode": SdkIdentifyPersonParamsXObserverModeEnum | SdkIdentifyPersonParamsXObserverModeEnum; readonly "x-platform": string; readonly "x-platform-brand"?: string | null | undefined; readonly "x-platform-device"?: string | null | undefined; - readonly "x-platform-flavor": SdkIdentifyParamsXPlatformFlavorEnum | SdkIdentifyParamsXPlatformFlavorEnum; + readonly "x-platform-flavor": SdkIdentifyPersonParamsXPlatformFlavorEnum | SdkIdentifyPersonParamsXPlatformFlavorEnum; readonly "x-platform-flavor-version"?: string | null | undefined; readonly "x-platform-version"?: string | null | undefined; readonly "x-preferred-locales"?: string | null | undefined; - readonly "x-sdk": SdkIdentifyParamsXSdkEnum | SdkIdentifyParamsXSdkEnum; + readonly "x-sdk": SdkIdentifyPersonParamsXSdkEnum | SdkIdentifyPersonParamsXSdkEnum; readonly "x-sdk-version": string; readonly "x-storefront"?: string | null | undefined } @@ -381,58 +381,58 @@ export interface SdkIdentifyBody { readonly "traits"?: Record | null | undefined } -export type SdkIdentify400 = SdkValidationError | EffectHttpApiSchemaError +export type SdkIdentifyPerson400 = SdkValidationError | EffectHttpApiSchemaError -export type SdkCustomerAlreadyIdentifiedErrorTag = "SdkCustomerAlreadyIdentifiedError" +export type SdkPersonAlreadyIdentifiedErrorTag = "SdkPersonAlreadyIdentifiedError" -export interface SdkCustomerAlreadyIdentifiedError { - readonly "_tag": SdkCustomerAlreadyIdentifiedErrorTag; +export interface SdkPersonAlreadyIdentifiedError { + readonly "_tag": SdkPersonAlreadyIdentifiedErrorTag; readonly "distinctId": string } -export type SdkIdentify500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError +export type SdkIdentifyPerson500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError -export type SdkSyncCustomerAttributesParamsXIsBackgrounded = "false" +export type SdkSyncPersonAttributesParamsXIsBackgrounded = "false" -export type SdkSyncCustomerAttributesParamsXIsDebugBuildEnum = "false" +export type SdkSyncPersonAttributesParamsXIsDebugBuildEnum = "false" -export type SdkSyncCustomerAttributesParamsXObserverModeEnum = "false" +export type SdkSyncPersonAttributesParamsXObserverModeEnum = "false" -export type SdkSyncCustomerAttributesParamsXPlatformFlavorEnum = "browser" +export type SdkSyncPersonAttributesParamsXPlatformFlavorEnum = "browser" -export type SdkSyncCustomerAttributesParamsXSdkEnum = "web" +export type SdkSyncPersonAttributesParamsXSdkEnum = "web" -export interface SdkSyncCustomerAttributesParams { +export interface SdkSyncPersonAttributesParams { readonly "x-distinct-id": string; readonly "x-publishable-key": string; readonly "x-client-bundle-id": string; readonly "x-client-locale"?: string | null | undefined; readonly "x-client-version"?: string | null | undefined; - readonly "x-is-backgrounded": SdkSyncCustomerAttributesParamsXIsBackgrounded; - readonly "x-is-debug-build": SdkSyncCustomerAttributesParamsXIsDebugBuildEnum | SdkSyncCustomerAttributesParamsXIsDebugBuildEnum; + readonly "x-is-backgrounded": SdkSyncPersonAttributesParamsXIsBackgrounded; + readonly "x-is-debug-build": SdkSyncPersonAttributesParamsXIsDebugBuildEnum | SdkSyncPersonAttributesParamsXIsDebugBuildEnum; readonly "x-nonce"?: string | null | undefined; - readonly "x-observer-mode": SdkSyncCustomerAttributesParamsXObserverModeEnum | SdkSyncCustomerAttributesParamsXObserverModeEnum; + readonly "x-observer-mode": SdkSyncPersonAttributesParamsXObserverModeEnum | SdkSyncPersonAttributesParamsXObserverModeEnum; readonly "x-platform": string; readonly "x-platform-brand"?: string | null | undefined; readonly "x-platform-device"?: string | null | undefined; - readonly "x-platform-flavor": SdkSyncCustomerAttributesParamsXPlatformFlavorEnum | SdkSyncCustomerAttributesParamsXPlatformFlavorEnum; + readonly "x-platform-flavor": SdkSyncPersonAttributesParamsXPlatformFlavorEnum | SdkSyncPersonAttributesParamsXPlatformFlavorEnum; readonly "x-platform-flavor-version"?: string | null | undefined; readonly "x-platform-version"?: string | null | undefined; readonly "x-preferred-locales"?: string | null | undefined; - readonly "x-sdk": SdkSyncCustomerAttributesParamsXSdkEnum | SdkSyncCustomerAttributesParamsXSdkEnum; + readonly "x-sdk": SdkSyncPersonAttributesParamsXSdkEnum | SdkSyncPersonAttributesParamsXSdkEnum; readonly "x-sdk-version": string; readonly "x-storefront"?: string | null | undefined } -export interface SdkSyncCustomerAttributesBody { +export interface SdkSyncPersonAttributesBody { readonly "email"?: string | null | undefined; readonly "name"?: string | null | undefined; readonly "traits"?: Record | null | undefined } -export type SdkSyncCustomerAttributes400 = SdkValidationError | EffectHttpApiSchemaError +export type SdkSyncPersonAttributes400 = SdkValidationError | EffectHttpApiSchemaError -export type SdkSyncCustomerAttributes500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError +export type SdkSyncPersonAttributes500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError export type SdkSyncTransactionParamsXIsBackgrounded = "false" @@ -808,7 +808,7 @@ export interface WebhookEndpoint { readonly "consecutiveFailures": number | WebhookEndpointConsecutiveFailuresEnum | WebhookEndpointConsecutiveFailuresEnum | WebhookEndpointConsecutiveFailuresEnum; readonly "createdAt": string | null; readonly "description": string | null; - readonly "events": ReadonlyArray<"customer.created" | "customer.updated" | "customer.deleted" | "subscription.created" | "subscription.renewed" | "subscription.cancelled" | "subscription.expired" | "purchase.completed" | "purchase.refunded">; + readonly "events": ReadonlyArray<"person.created" | "person.updated" | "person.deleted" | "subscription.created" | "subscription.renewed" | "subscription.cancelled" | "subscription.expired" | "purchase.completed" | "purchase.refunded">; readonly "id": string; readonly "lastSuccessAt": string | null; readonly "name": string; @@ -1044,18 +1044,18 @@ export const make = ( "apiKeysRotateSecretKey": (apiKeyId) => HttpClientRequest.post(`/api/v1/api-keys/${apiKeyId}/rotate`).pipe( onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"ApiKeyNotFoundError","500":"ApiKeysRotateSecretKey500"}) ), - "customersListCustomers": () => HttpClientRequest.get(`/api/v1/customers`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"CustomersListCustomers500"}) + "personsListPersons": () => HttpClientRequest.get(`/api/v1/persons`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"PersonsListPersons500"}) ), - "customersCreateCustomer": (options) => HttpClientRequest.post(`/api/v1/customers`).pipe( + "personsCreatePerson": (options) => HttpClientRequest.post(`/api/v1/persons`).pipe( HttpClientRequest.bodyJsonUnsafe(options), - onRequest(["2xx"], {"400":"CustomersCreateCustomer400","403":"ActionForbiddenError","500":"CustomersCreateCustomer500"}) + onRequest(["2xx"], {"400":"PersonsCreatePerson400","403":"ActionForbiddenError","500":"PersonsCreatePerson500"}) ), - "customersGetCustomerById": (customerId) => HttpClientRequest.get(`/api/v1/customers/${customerId}`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"CustomerNotFoundError","500":"CustomersGetCustomerById500"}) + "personsGetPersonById": (personId) => HttpClientRequest.get(`/api/v1/persons/${personId}`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"PersonNotFoundError","500":"PersonsGetPersonById500"}) ), - "customersByDistinctId": (distinctId) => HttpClientRequest.get(`/api/v1/customers/by-distinct-id/${distinctId}`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"CustomerNotFoundError","500":"CustomersByDistinctId500"}) + "personsGetPersonByDistinctId": (distinctId) => HttpClientRequest.get(`/api/v1/persons/by-distinct-id/${distinctId}`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"PersonNotFoundError","500":"PersonsGetPersonByDistinctId500"}) ), "organizationsCreateOrganization": (options) => HttpClientRequest.post(`/api/v1/organizations`).pipe( HttpClientRequest.bodyJsonUnsafe(options), @@ -1080,19 +1080,19 @@ export const make = ( "productPerksListProductPerksByProductId": (productId) => HttpClientRequest.get(`/api/v1/product-perks/by-product-id/${productId}`).pipe( onRequest(["2xx"], {"400":"ProductPerksListProductPerksByProductId400","403":"ActionForbiddenError","500":"ProductPerksListProductPerksByProductId500"}) ), - "sdkGetCustomer": (options) => HttpClientRequest.get(`/api/v1/sdk/get-customer`).pipe( + "sdkGetPerson": (options) => HttpClientRequest.get(`/api/v1/sdk/person`).pipe( HttpClientRequest.setHeaders({ "x-distinct-id": options?.["x-distinct-id"] ?? undefined, "x-publishable-key": options?.["x-publishable-key"] ?? undefined, "x-client-bundle-id": options?.["x-client-bundle-id"] ?? undefined, "x-client-locale": options?.["x-client-locale"] ?? undefined, "x-client-version": options?.["x-client-version"] ?? undefined, "x-is-backgrounded": options?.["x-is-backgrounded"] ?? undefined, "x-is-debug-build": options?.["x-is-debug-build"] ?? undefined, "x-nonce": options?.["x-nonce"] ?? undefined, "x-observer-mode": options?.["x-observer-mode"] ?? undefined, "x-platform": options?.["x-platform"] ?? undefined, "x-platform-brand": options?.["x-platform-brand"] ?? undefined, "x-platform-device": options?.["x-platform-device"] ?? undefined, "x-platform-flavor": options?.["x-platform-flavor"] ?? undefined, "x-platform-flavor-version": options?.["x-platform-flavor-version"] ?? undefined, "x-platform-version": options?.["x-platform-version"] ?? undefined, "x-preferred-locales": options?.["x-preferred-locales"] ?? undefined, "x-sdk": options?.["x-sdk"] ?? undefined, "x-sdk-version": options?.["x-sdk-version"] ?? undefined, "x-storefront": options?.["x-storefront"] ?? undefined }), - onRequest(["2xx"], {"400":"SdkGetCustomer400","404":"SdkCustomerNotFoundError","500":"SdkGetCustomer500"}) + onRequest(["2xx"], {"400":"SdkGetPerson400","404":"SdkPersonNotFoundError","500":"SdkGetPerson500"}) ), - "sdkIdentify": (options) => HttpClientRequest.post(`/api/v1/sdk/identify`).pipe( + "sdkIdentifyPerson": (options) => HttpClientRequest.post(`/api/v1/sdk/identify`).pipe( HttpClientRequest.setHeaders({ "x-distinct-id": options.params?.["x-distinct-id"] ?? undefined, "x-publishable-key": options.params?.["x-publishable-key"] ?? undefined, "x-client-bundle-id": options.params?.["x-client-bundle-id"] ?? undefined, "x-client-locale": options.params?.["x-client-locale"] ?? undefined, "x-client-version": options.params?.["x-client-version"] ?? undefined, "x-is-backgrounded": options.params?.["x-is-backgrounded"] ?? undefined, "x-is-debug-build": options.params?.["x-is-debug-build"] ?? undefined, "x-nonce": options.params?.["x-nonce"] ?? undefined, "x-observer-mode": options.params?.["x-observer-mode"] ?? undefined, "x-platform": options.params?.["x-platform"] ?? undefined, "x-platform-brand": options.params?.["x-platform-brand"] ?? undefined, "x-platform-device": options.params?.["x-platform-device"] ?? undefined, "x-platform-flavor": options.params?.["x-platform-flavor"] ?? undefined, "x-platform-flavor-version": options.params?.["x-platform-flavor-version"] ?? undefined, "x-platform-version": options.params?.["x-platform-version"] ?? undefined, "x-preferred-locales": options.params?.["x-preferred-locales"] ?? undefined, "x-sdk": options.params?.["x-sdk"] ?? undefined, "x-sdk-version": options.params?.["x-sdk-version"] ?? undefined, "x-storefront": options.params?.["x-storefront"] ?? undefined }), HttpClientRequest.bodyJsonUnsafe(options.payload), - onRequest(["2xx"], {"400":"SdkIdentify400","409":"SdkCustomerAlreadyIdentifiedError","500":"SdkIdentify500"}) + onRequest(["2xx"], {"400":"SdkIdentifyPerson400","409":"SdkPersonAlreadyIdentifiedError","500":"SdkIdentifyPerson500"}) ), - "sdkSyncCustomerAttributes": (options) => HttpClientRequest.post(`/api/v1/sdk/sync-customer-attributes`).pipe( + "sdkSyncPersonAttributes": (options) => HttpClientRequest.post(`/api/v1/sdk/person/traits`).pipe( HttpClientRequest.setHeaders({ "x-distinct-id": options.params?.["x-distinct-id"] ?? undefined, "x-publishable-key": options.params?.["x-publishable-key"] ?? undefined, "x-client-bundle-id": options.params?.["x-client-bundle-id"] ?? undefined, "x-client-locale": options.params?.["x-client-locale"] ?? undefined, "x-client-version": options.params?.["x-client-version"] ?? undefined, "x-is-backgrounded": options.params?.["x-is-backgrounded"] ?? undefined, "x-is-debug-build": options.params?.["x-is-debug-build"] ?? undefined, "x-nonce": options.params?.["x-nonce"] ?? undefined, "x-observer-mode": options.params?.["x-observer-mode"] ?? undefined, "x-platform": options.params?.["x-platform"] ?? undefined, "x-platform-brand": options.params?.["x-platform-brand"] ?? undefined, "x-platform-device": options.params?.["x-platform-device"] ?? undefined, "x-platform-flavor": options.params?.["x-platform-flavor"] ?? undefined, "x-platform-flavor-version": options.params?.["x-platform-flavor-version"] ?? undefined, "x-platform-version": options.params?.["x-platform-version"] ?? undefined, "x-preferred-locales": options.params?.["x-preferred-locales"] ?? undefined, "x-sdk": options.params?.["x-sdk"] ?? undefined, "x-sdk-version": options.params?.["x-sdk-version"] ?? undefined, "x-storefront": options.params?.["x-storefront"] ?? undefined }), HttpClientRequest.bodyJsonUnsafe(options.payload), - onRequest(["2xx"], {"400":"SdkSyncCustomerAttributes400","500":"SdkSyncCustomerAttributes500"}) + onRequest(["2xx"], {"400":"SdkSyncPersonAttributes400","500":"SdkSyncPersonAttributes500"}) ), "sdkSyncTransaction": (options) => HttpClientRequest.post(`/api/v1/sdk/sync-transaction`).pipe( HttpClientRequest.setHeaders({ "x-distinct-id": options.params?.["x-distinct-id"] ?? undefined, "x-publishable-key": options.params?.["x-publishable-key"] ?? undefined, "x-client-bundle-id": options.params?.["x-client-bundle-id"] ?? undefined, "x-client-locale": options.params?.["x-client-locale"] ?? undefined, "x-client-version": options.params?.["x-client-version"] ?? undefined, "x-is-backgrounded": options.params?.["x-is-backgrounded"] ?? undefined, "x-is-debug-build": options.params?.["x-is-debug-build"] ?? undefined, "x-nonce": options.params?.["x-nonce"] ?? undefined, "x-observer-mode": options.params?.["x-observer-mode"] ?? undefined, "x-platform": options.params?.["x-platform"] ?? undefined, "x-platform-brand": options.params?.["x-platform-brand"] ?? undefined, "x-platform-device": options.params?.["x-platform-device"] ?? undefined, "x-platform-flavor": options.params?.["x-platform-flavor"] ?? undefined, "x-platform-flavor-version": options.params?.["x-platform-flavor-version"] ?? undefined, "x-platform-version": options.params?.["x-platform-version"] ?? undefined, "x-preferred-locales": options.params?.["x-preferred-locales"] ?? undefined, "x-sdk": options.params?.["x-sdk"] ?? undefined, "x-sdk-version": options.params?.["x-sdk-version"] ?? undefined, "x-storefront": options.params?.["x-storefront"] ?? undefined }), @@ -1165,10 +1165,10 @@ export interface VoidhashCoreClient { readonly "apiKeysGetApiKeyById": (apiKeyId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ApiKeyNotFoundError", ApiKeyNotFoundError> | VoidhashCoreClientError<"ApiKeysGetApiKeyById500", ApiKeysGetApiKeyById500>> readonly "apiKeysDeleteApiKey": (apiKeyId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ApiKeyNotFoundError", ApiKeyNotFoundError> | VoidhashCoreClientError<"ApiKeysDeleteApiKey500", ApiKeysDeleteApiKey500>> readonly "apiKeysRotateSecretKey": (apiKeyId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ApiKeyNotFoundError", ApiKeyNotFoundError> | VoidhashCoreClientError<"ApiKeysRotateSecretKey500", ApiKeysRotateSecretKey500>> - readonly "customersListCustomers": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"CustomersListCustomers500", CustomersListCustomers500>> - readonly "customersCreateCustomer": (options: CreateCustomerBody) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"CustomersCreateCustomer500", CustomersCreateCustomer500>> - readonly "customersGetCustomerById": (customerId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"CustomerNotFoundError", CustomerNotFoundError> | VoidhashCoreClientError<"CustomersGetCustomerById500", CustomersGetCustomerById500>> - readonly "customersByDistinctId": (distinctId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"CustomerNotFoundError", CustomerNotFoundError> | VoidhashCoreClientError<"CustomersByDistinctId500", CustomersByDistinctId500>> + readonly "personsListPersons": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PersonsListPersons500", PersonsListPersons500>> + readonly "personsCreatePerson": (options: CreatePersonBody) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PersonsCreatePerson500", PersonsCreatePerson500>> + readonly "personsGetPersonById": (personId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PersonNotFoundError", PersonNotFoundError> | VoidhashCoreClientError<"PersonsGetPersonById500", PersonsGetPersonById500>> + readonly "personsGetPersonByDistinctId": (distinctId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PersonNotFoundError", PersonNotFoundError> | VoidhashCoreClientError<"PersonsGetPersonByDistinctId500", PersonsGetPersonByDistinctId500>> readonly "organizationsCreateOrganization": (options: CreateOrganizationBody) => Effect.Effect | VoidhashCoreClientError<"OrganizationsCreateOrganization500", OrganizationsCreateOrganization500>> readonly "perksListPerks": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PerksListPerks500", PerksListPerks500>> readonly "paywallLocationsListPaywallLocations": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PaywallLocationsListPaywallLocations500", PaywallLocationsListPaywallLocations500>> @@ -1176,9 +1176,9 @@ export interface VoidhashCoreClient { readonly "projectsListProjects": (organizationId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ProjectsListProjects500", ProjectsListProjects500>> readonly "productsListProducts": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ProductsListProducts500", ProductsListProducts500>> readonly "productPerksListProductPerksByProductId": (productId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ProductPerksListProductPerksByProductId500", ProductPerksListProductPerksByProductId500>> - readonly "sdkGetCustomer": (options: SdkGetCustomerParams) => Effect.Effect | VoidhashCoreClientError<"SdkCustomerNotFoundError", SdkCustomerNotFoundError> | VoidhashCoreClientError<"SdkGetCustomer500", SdkGetCustomer500>> - readonly "sdkIdentify": (options: { readonly params: SdkIdentifyParams; readonly payload: SdkIdentifyBody }) => Effect.Effect | VoidhashCoreClientError<"SdkCustomerAlreadyIdentifiedError", SdkCustomerAlreadyIdentifiedError> | VoidhashCoreClientError<"SdkIdentify500", SdkIdentify500>> - readonly "sdkSyncCustomerAttributes": (options: { readonly params: SdkSyncCustomerAttributesParams; readonly payload: SdkSyncCustomerAttributesBody }) => Effect.Effect | VoidhashCoreClientError<"SdkSyncCustomerAttributes500", SdkSyncCustomerAttributes500>> + readonly "sdkGetPerson": (options: SdkGetPersonParams) => Effect.Effect | VoidhashCoreClientError<"SdkPersonNotFoundError", SdkPersonNotFoundError> | VoidhashCoreClientError<"SdkGetPerson500", SdkGetPerson500>> + readonly "sdkIdentifyPerson": (options: { readonly params: SdkIdentifyPersonParams; readonly payload: SdkIdentifyBody }) => Effect.Effect | VoidhashCoreClientError<"SdkPersonAlreadyIdentifiedError", SdkPersonAlreadyIdentifiedError> | VoidhashCoreClientError<"SdkIdentifyPerson500", SdkIdentifyPerson500>> + readonly "sdkSyncPersonAttributes": (options: { readonly params: SdkSyncPersonAttributesParams; readonly payload: SdkSyncPersonAttributesBody }) => Effect.Effect | VoidhashCoreClientError<"SdkSyncPersonAttributes500", SdkSyncPersonAttributes500>> readonly "sdkSyncTransaction": (options: { readonly params: SdkSyncTransactionParams; readonly payload: SdkSyncTransactionRequest }) => Effect.Effect | VoidhashCoreClientError<"SdkSyncTransaction500", SdkSyncTransaction500>> readonly "sdkEvaluateFeatureFlags": (options: { readonly params: SdkEvaluateFeatureFlagsParams; readonly payload: EvaluateFeatureFlagsBody }) => Effect.Effect | VoidhashCoreClientError<"SdkEvaluateFeatureFlags500", SdkEvaluateFeatureFlags500>> readonly "sdkResolvePaywall": (options: { readonly params: SdkResolvePaywallParams; readonly payload: SdkResolvePaywallBody }) => Effect.Effect | VoidhashCoreClientError<"SdkResolvePaywall500", SdkResolvePaywall500>> diff --git a/packages/generated-clients/src/event-capture/generated.ts b/packages/generated-clients/src/event-capture/generated.ts index bc2ad26e8..ba2ab714d 100644 --- a/packages/generated-clients/src/event-capture/generated.ts +++ b/packages/generated-clients/src/event-capture/generated.ts @@ -180,11 +180,11 @@ export const make = ( } return { httpClient, - "eventCaptureCapture": (options) => HttpClientRequest.post(`/capture`).pipe( + "eventCaptureCapture": (options) => HttpClientRequest.post(`/i/v1/capture`).pipe( HttpClientRequest.bodyJsonUnsafe(options), onRequest(["2xx"], {"400":"EventCaptureCapture400","401":"CaptureUnauthorizedError","413":"CapturePayloadTooLargeError","429":"CaptureRateLimitedError","500":"CaptureInternalServerError","503":"CaptureDependencyUnavailableError"}) ), - "eventCaptureBatch": (options) => HttpClientRequest.post(`/batch`).pipe( + "eventCaptureBatch": (options) => HttpClientRequest.post(`/i/v1/batch`).pipe( HttpClientRequest.bodyJsonUnsafe(options), onRequest(["2xx"], {"400":"EventCaptureBatch400","401":"CaptureUnauthorizedError","413":"CapturePayloadTooLargeError","429":"CaptureRateLimitedError","500":"CaptureInternalServerError","503":"CaptureDependencyUnavailableError"}) ) diff --git a/scripts/generate-openapi-clients.mjs b/scripts/generate-openapi-clients.mjs index ee53848f3..f1be75d2d 100644 --- a/scripts/generate-openapi-clients.mjs +++ b/scripts/generate-openapi-clients.mjs @@ -1,51 +1,43 @@ #!/usr/bin/env node import { mkdirSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; -import { spawnSync } from "node:child_process"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, ".."); const generatedClientsRoot = path.join(repoRoot, "packages/generated-clients"); const nodeGeneratedRoot = path.join(repoRoot, "libraries/node/src/generated"); +const openapiRoot = path.join(generatedClientsRoot, "openapi"); -const mode = process.argv[2]; +const rawHost = process.argv.slice(2).find((arg) => arg !== "--")?.trim(); -if (mode !== "preview" && mode !== "production") { - console.error("Usage: node ./scripts/generate-openapi-clients.mjs "); +if (!rawHost) { + console.error( + "Usage: node ./scripts/generate-openapi-clients.mjs \nExample: node ./scripts/generate-openapi-clients.mjs localhost:8787", + ); process.exit(1); } -const resolveUrl = (name, fallback) => { - const value = process.env[name] ?? fallback; - - if (!value) { - console.error(`Missing required environment variable: ${name}`); - process.exit(1); +const normalizeHost = (host) => { + if (host.startsWith("http://") || host.startsWith("https://")) { + return new URL(host).toString(); } - return value; -}; + const protocol = + host.startsWith("localhost") || host.startsWith("127.0.0.1") ? "http://" : "https://"; -const specsByMode = { - preview: { - core: resolveUrl("VOIDHASH_PREVIEW_CORE_OPENAPI_URL"), - eventCapture: resolveUrl("VOIDHASH_PREVIEW_EVENT_CAPTURE_OPENAPI_URL"), - }, - production: { - core: resolveUrl( - "VOIDHASH_PRODUCTION_CORE_OPENAPI_URL", - "https://api.voidhash.com/api/docs/openapi.json", - ), - eventCapture: resolveUrl("VOIDHASH_PRODUCTION_EVENT_CAPTURE_OPENAPI_URL"), - }, + return new URL(`${protocol}${host}`).toString(); }; -const openapiDir = path.join(generatedClientsRoot, "openapi", mode); -mkdirSync(openapiDir, { recursive: true }); +const baseUrl = normalizeHost(rawHost); +const coreSpecUrl = new URL("/api/docs/openapi.json", baseUrl).toString(); +const eventCaptureSpecUrl = new URL("/i/docs/openapi.json", baseUrl).toString(); +const coreSpecPath = path.join(openapiRoot, "core.json"); +const eventCaptureSpecPath = path.join(openapiRoot, "event-capture.json"); const fetchJson = async (url) => { const response = await fetch(url); @@ -57,6 +49,22 @@ const fetchJson = async (url) => { return await response.text(); }; +const assertCoreSpec = (spec) => { + const paths = Object.keys(spec.paths ?? {}); + + if (!paths.some((specPath) => specPath.startsWith("/api/v1/"))) { + throw new Error("The core OpenAPI schema is missing /api/v1/* paths."); + } +}; + +const assertEventCaptureSpec = (spec) => { + const paths = Object.keys(spec.paths ?? {}); + + if (!paths.includes("/i/v1/capture") || !paths.includes("/i/v1/batch")) { + throw new Error("The event-capture OpenAPI schema is missing /i/v1/capture or /i/v1/batch."); + } +}; + const run = (command, args) => { const result = spawnSync(command, args, { cwd: repoRoot, @@ -74,18 +82,24 @@ const run = (command, args) => { }; const main = async () => { - const specUrls = specsByMode[mode]; - const corePath = path.join(openapiDir, "core.json"); - const eventCapturePath = path.join(openapiDir, "event-capture.json"); + mkdirSync(openapiRoot, { recursive: true }); + + const [coreSpecText, eventCaptureSpecText] = await Promise.all([ + fetchJson(coreSpecUrl), + fetchJson(eventCaptureSpecUrl), + ]); + + assertCoreSpec(JSON.parse(coreSpecText)); + assertEventCaptureSpec(JSON.parse(eventCaptureSpecText)); - writeFileSync(corePath, `${await fetchJson(specUrls.core)}\n`, "utf8"); - writeFileSync(eventCapturePath, `${await fetchJson(specUrls.eventCapture)}\n`, "utf8"); + writeFileSync(coreSpecPath, `${coreSpecText}\n`, "utf8"); + writeFileSync(eventCaptureSpecPath, `${eventCaptureSpecText}\n`, "utf8"); const coreOutput = run("pnpm", [ "dlx", "@tim-smart/openapi-gen@1.0.3", "--spec", - corePath, + coreSpecPath, "--name", "VoidhashCoreClient", ]); @@ -95,7 +109,7 @@ const main = async () => { "dlx", "@tim-smart/openapi-gen@1.0.3", "--spec", - eventCapturePath, + eventCaptureSpecPath, "--name", "VoidhashEventCaptureClient", ]); @@ -108,7 +122,7 @@ const main = async () => { mkdirSync(nodeGeneratedRoot, { recursive: true }); run("node", [ "./scripts/generate-node-grouped-client.mjs", - corePath, + coreSpecPath, path.join(nodeGeneratedRoot, "grouped-client.ts"), ]); }; From 7dccd7a3eaea4d6c985b37bc79dd4aae95a6d5de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Sun, 10 May 2026 21:14:37 +0200 Subject: [PATCH 011/129] wip: update schema --- packages/generated-clients/openapi/core.json | 2 +- .../generated-clients/src/core/generated.ts | 67 ++++++++++++++++++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/packages/generated-clients/openapi/core.json b/packages/generated-clients/openapi/core.json index 0336e4b39..58e3cf7e3 100644 --- a/packages/generated-clients/openapi/core.json +++ b/packages/generated-clients/openapi/core.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Api","version":"0.0.1"},"paths":{"/api/v1/auth/session":{"get":{"tags":["auth"],"operationId":"auth.session","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"method":{"anyOf":[{"type":"string","enum":["api-key"]},{"type":"string","enum":["publishable-key"]},{"type":"string","enum":["secret-key"]}]},"name":{"type":"string"},"organizations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false}},"projects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"organizationId":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","organizationId","slug"],"additionalProperties":false}}},"required":["method","name","organizations","projects"],"additionalProperties":false}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"Error","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}}}}}}},"/api/v1/api-keys":{"post":{"tags":["api_keys"],"operationId":"api_keys.createSecretKey","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"ApiKeyWithRawKey","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyWithRawKey"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSecretKeyBody"}}},"required":true}},"get":{"tags":["api_keys"],"operationId":"api_keys.listApiKeys","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/api-keys/{apiKeyId}":{"get":{"tags":["api_keys"],"operationId":"api_keys.getApiKeyById","parameters":[{"name":"apiKeyId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"ApiKey","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKey"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"ApiKeyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyNotFoundError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}},"delete":{"tags":["api_keys"],"operationId":"api_keys.deleteApiKey","parameters":[{"name":"apiKeyId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"204":{"description":""},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"ApiKeyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyNotFoundError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/api-keys/{apiKeyId}/rotate":{"post":{"tags":["api_keys"],"operationId":"api_keys.rotateSecretKey","parameters":[{"name":"apiKeyId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"ApiKeyWithRawKey","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyWithRawKey"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"ApiKeyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyNotFoundError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/persons":{"post":{"tags":["persons"],"operationId":"persons.createPerson","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Person","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"400":{"description":"PersonInvalidAnonymousIdError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonInvalidAnonymousIdError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePersonBody"}}},"required":true}},"get":{"tags":["persons"],"operationId":"persons.listPersons","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Person"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/persons/{personId}":{"get":{"tags":["persons"],"operationId":"persons.getPersonById","parameters":[{"name":"personId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Person","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"PersonNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonNotFoundError"}}}},"500":{"description":"PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/persons/by-distinct-id/{distinctId}":{"get":{"tags":["persons"],"operationId":"persons.getPersonByDistinctId","parameters":[{"name":"distinctId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Person","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"PersonNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonNotFoundError"}}}},"500":{"description":"PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/organizations":{"post":{"tags":["organizations"],"operationId":"organizations.createOrganization","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Organization","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"500":{"description":"OrganizationServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/OrganizationServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrganizationBody"}}},"required":true}}},"/api/v1/perks":{"get":{"tags":["perks"],"operationId":"perks.listPerks","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Perk"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PerkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PerkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/paywall-locations":{"get":{"tags":["paywall_locations"],"operationId":"paywall_locations.listPaywallLocations","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PaywallLocation"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PaywallLocationServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PaywallLocationServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/projects":{"post":{"tags":["projects"],"operationId":"projects.createProject","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Project","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Project"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"AuthenticationError | ProjectServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/ProjectServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectBody"}}},"required":true}}},"/api/v1/projects/{organizationId}":{"get":{"tags":["projects"],"operationId":"projects.listProjects","parameters":[{"name":"organizationId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Project"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ProjectServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProjectServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/products":{"get":{"tags":["products"],"operationId":"products.listProducts","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Product"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ProductServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProductServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/product-perks/by-product-id/{productId}":{"get":{"tags":["product_perks"],"operationId":"product_perks.listProductPerksByProductId","parameters":[{"name":"productId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProductPerk"}}}}},"400":{"description":"ProductPerkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProductPerkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ProductPerkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProductPerkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/sdk/person":{"get":{"tags":["sdk"],"operationId":"sdk.getPerson","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"SdkPerson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPerson"}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"404":{"description":"SdkPersonNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPersonNotFoundError"}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/sdk/identify":{"post":{"tags":["sdk"],"operationId":"sdk.identifyPerson","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"SdkPerson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPerson"}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"409":{"description":"SdkPersonAlreadyIdentifiedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPersonAlreadyIdentifiedError"}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkIdentifyBody"}}},"required":true}}},"/api/v1/sdk/person/traits":{"post":{"tags":["sdk"],"operationId":"sdk.syncPersonAttributes","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"SdkPerson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPerson"}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkSyncPersonAttributesBody"}}},"required":true}}},"/api/v1/sdk/sync-transaction":{"post":{"tags":["sdk"],"operationId":"sdk.syncTransaction","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"SdkSyncTransactionResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkSyncTransactionResponse"}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"anyOf":[{"type":"string","enum":["ios"]},{"type":"string","enum":["android"]}]},"productId":{"type":"string"},"purchaseDate":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"purchaseToken":{"anyOf":[{"type":"string"},{"type":"null"}]},"quantity":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"receipt":{"anyOf":[{"type":"string"},{"type":"null"}]},"transactionId":{"type":"string"}},"required":["platform","productId","purchaseDate","quantity","transactionId"],"additionalProperties":false}}},"required":true}}},"/api/v1/sdk/evaluate-flags":{"post":{"tags":["sdk"],"operationId":"sdk.evaluateFeatureFlags","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"SdkFeatureFlagsResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkFeatureFlagsResponse"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluateFeatureFlagsBody"}}},"required":true}}},"/api/v1/sdk/resolve-paywall":{"post":{"tags":["sdk"],"operationId":"sdk.resolvePaywall","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkResolvedPaywall"},{"type":"null"}]}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkResolvePaywallBody"}}},"required":true}}},"/api/v1/users/current":{"get":{"tags":["users"],"operationId":"users.getUser","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"User","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"500":{"description":"AuthenticationError | UserServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/UserServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/payment-provider-configurations":{"get":{"tags":["payment_provider_configurations"],"operationId":"payment_provider_configurations.listPaymentProviderConfigurations","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PaymentProviderConfiguration"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PaymentProviderConfigurationServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PaymentProviderConfigurationServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/payment-provider-products":{"get":{"tags":["payment_provider_products"],"operationId":"payment_provider_products.listPaymentProviderProducts","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PaymentProviderProduct"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PaymentProviderProductServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PaymentProviderProductServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/changesets/deploy":{"post":{"tags":["changesets"],"operationId":"changesets.deployChangeset","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"DeployChangesetResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeployChangesetResponse"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"AuthenticationError | ChangesetDeploymentServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/ChangesetDeploymentServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeployChangesetBody"}}},"required":true}}},"/api/v1/webhooks/endpoints":{"post":{"tags":["webhooks"],"operationId":"webhooks.createWebhookEndpoint","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"WebhookValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhookEndpointBody"}}},"required":true}},"get":{"tags":["webhooks"],"operationId":"webhooks.listWebhookEndpoints","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEndpoint"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/endpoints/{endpointId}":{"get":{"tags":["webhooks"],"operationId":"webhooks.getWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}},"patch":{"tags":["webhooks"],"operationId":"webhooks.updateWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"WebhookValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWebhookEndpointBody"}}},"required":true}},"delete":{"tags":["webhooks"],"operationId":"webhooks.deleteWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"204":{"description":""},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/endpoints/{endpointId}/rotate-secret":{"post":{"tags":["webhooks"],"operationId":"webhooks.rotateWebhookSecret","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/endpoints/{endpointId}/test":{"post":{"tags":["webhooks"],"operationId":"webhooks.testWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"WebhookDelivery","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDelivery"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/deliveries":{"get":{"tags":["webhooks"],"operationId":"webhooks.listWebhookDeliveries","parameters":[],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDelivery"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/deliveries/{deliveryId}":{"get":{"tags":["webhooks"],"operationId":"webhooks.getWebhookDelivery","parameters":[{"name":"deliveryId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"WebhookDeliveryWithAttempts","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryWithAttempts"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookDeliveryNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/deliveries/{deliveryId}/retry":{"post":{"tags":["webhooks"],"operationId":"webhooks.retryWebhookDelivery","parameters":[{"name":"deliveryId","in":"path","schema":{"type":"string"},"required":true}],"security":[{"apiKey":[]},{"betterAuthCookie":[]},{"publishableKey":[]},{"secretKey":[]}],"responses":{"200":{"description":"WebhookDelivery","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDelivery"}}}},"400":{"description":"WebhookValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookDeliveryNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}}},"components":{"schemas":{"ActionForbiddenError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ActionForbiddenError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"AuthenticationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["AuthenticationError"]},"cause":{"type":"string"},"message":{"type":"string"}},"required":["_tag","cause","message"],"additionalProperties":false},"NotAuthenticatedError":{"type":"object","properties":{"_tag":{"type":"string","enum":["NotAuthenticatedError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"effect_HttpApiSchemaError":{"type":"object","properties":{"_tag":{"type":"string","enum":["HttpApiSchemaError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"CreateSecretKeyBody":{"type":"object","properties":{"name":{"type":"string"},"projectId":{"type":"string"}},"required":["name","projectId"],"additionalProperties":false},"ApiKeyWithRawKey":{"type":"object","properties":{"end":{"type":"string"},"id":{"type":"string"},"isPublic":{"type":"boolean"},"name":{"type":"string"},"prefix":{"type":"string"},"projectId":{"type":"string"},"rawKey":{"type":"string"}},"required":["end","id","isPublic","name","prefix","projectId","rawKey"],"additionalProperties":false},"ApiKeyServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ApiKeyServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"ApiKey":{"type":"object","properties":{"end":{"type":"string"},"id":{"type":"string"},"isPublic":{"type":"boolean"},"name":{"type":"string"},"prefix":{"type":"string"},"projectId":{"type":"string"},"rawKey":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["end","id","isPublic","name","prefix","projectId"],"additionalProperties":false},"ApiKeyNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ApiKeyNotFoundError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"CreatePersonBody":{"type":"object","properties":{"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["distinctId"],"additionalProperties":false},"Person":{"type":"object","properties":{"personId":{"type":"string"},"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["personId","distinctId","email","name"],"additionalProperties":false},"PersonInvalidAnonymousIdError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PersonInvalidAnonymousIdError"]},"id":{"type":"string","allOf":[{"minLength":1}]}},"required":["_tag","id"],"additionalProperties":false},"PersonServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PersonServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PersonNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PersonNotFoundError"]},"id":{"type":"string","allOf":[{"minLength":1}]}},"required":["_tag","id"],"additionalProperties":false},"CreateOrganizationBody":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false},"Organization":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},"OrganizationServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["OrganizationServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"Perk":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","projectId","slug"],"additionalProperties":false},"PerkServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PerkServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PaywallLocation":{"type":"object","properties":{"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"slug":{"type":"string"}},"required":["description","id","name","projectId","slug"],"additionalProperties":false},"PaywallLocationServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PaywallLocationServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"CreateProjectBody":{"type":"object","properties":{"name":{"type":"string"},"organizationId":{"type":"string"}},"required":["name","organizationId"],"additionalProperties":false},"Project":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},"ProjectServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProjectServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"Product":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"slug":{"type":"string"},"type":{"anyOf":[{"type":"string","enum":["subscription"]},{"type":"string","enum":["one-time"]},{"type":"string","enum":["one-time-consumable"]}]}},"required":["id","name","projectId","slug","type"],"additionalProperties":false},"ProductServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProductServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"ProductPerk":{"type":"object","properties":{"id":{"type":"string"},"perkId":{"type":"string"},"productId":{"type":"string"}},"required":["id","perkId","productId"],"additionalProperties":false},"ProductPerkServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProductPerkServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"ProductPerkValidationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProductPerkValidationError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"SdkPerson":{"type":"object","properties":{"personId":{"type":"string"},"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["personId","distinctId","email","name"],"additionalProperties":false},"SdkServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["SdkServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"SdkPersonNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["SdkPersonNotFoundError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"SdkValidationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["SdkValidationError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"SdkIdentifyBody":{"type":"object","properties":{"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"traits":{"anyOf":[{"type":"object","additionalProperties":{"anyOf":[{"type":"string"},{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"boolean"},{"type":"null"}]}},{"type":"null"}]}},"required":["distinctId"],"additionalProperties":false},"SdkPersonAlreadyIdentifiedError":{"type":"object","properties":{"_tag":{"type":"string","enum":["SdkPersonAlreadyIdentifiedError"]},"distinctId":{"type":"string"}},"required":["_tag","distinctId"],"additionalProperties":false},"SdkSyncPersonAttributesBody":{"type":"object","properties":{"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"traits":{"anyOf":[{"type":"object","additionalProperties":{"anyOf":[{"type":"string"},{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"boolean"},{"type":"null"}]}},{"type":"null"}]}},"additionalProperties":false},"SdkSyncTransactionResponse":{"type":"object","properties":{"accepted":{"type":"boolean"}},"required":["accepted"],"additionalProperties":false},"EvaluateFeatureFlagsBody":{"type":"object","properties":{"flagKeys":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]}},"additionalProperties":false},"SdkFeatureFlagResult":{"type":"object","properties":{"enabled":{"type":"boolean"},"key":{"type":"string"},"payload":{"anyOf":[{"type":"null"},{"type":"null"}]},"variantKey":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["enabled","key","payload","variantKey"],"additionalProperties":false},"SdkFeatureFlagsResponse":{"type":"object","properties":{"flags":{"type":"array","items":{"$ref":"#/components/schemas/SdkFeatureFlagResult"}}},"required":["flags"],"additionalProperties":false},"SdkResolvePaywallBody":{"type":"object","properties":{"locationSlug":{"type":"string"}},"required":["locationSlug"],"additionalProperties":false},"SdkResolvedPaywallShowing":{"type":"object","properties":{"id":{"type":"string"},"paywall":{"anyOf":[{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},{"type":"null"}]},"paywallId":{"anyOf":[{"type":"string"},{"type":"null"}]},"paywallRelease":{"anyOf":[{"type":"object","properties":{"htmlUrl":{"type":"string"},"publishedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"releaseId":{"type":"string"},"version":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]}},"required":["htmlUrl","publishedAt","releaseId","version"],"additionalProperties":false},{"type":"null"}]},"paywallReleaseId":{"anyOf":[{"type":"string"},{"type":"null"}]},"startedAt":{"type":"string"},"type":{"anyOf":[{"type":"string","enum":["paywall_release"]},{"type":"string","enum":["feature_flag"]}]}},"required":["id","paywall","paywallId","paywallRelease","paywallReleaseId","startedAt","type"],"additionalProperties":false},"SdkResolvedPaywall":{"type":"object","properties":{"location":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},"showing":{"$ref":"#/components/schemas/SdkResolvedPaywallShowing"}},"required":["location","showing"],"additionalProperties":false},"User":{"type":"object","properties":{"createdAt":{"type":"string"},"email":{"type":"string"},"emailVerified":{"type":"boolean"},"id":{"type":"string"},"image":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"organizations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"logo":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","logo","name","slug"],"additionalProperties":false}},"projects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"logo":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"organizationId":{"type":"string"},"slug":{"type":"string"}},"required":["id","logo","name","organizationId","slug"],"additionalProperties":false}},"updatedAt":{"type":"string"}},"required":["createdAt","email","emailVerified","id","image","name","organizations","projects","updatedAt"],"additionalProperties":false},"UserServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["UserServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PaymentProviderConfiguration":{"type":"object","properties":{"enabled":{"type":"boolean"},"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"providerId":{"type":"string"}},"required":["enabled","id","name","projectId","providerId"],"additionalProperties":false},"PaymentProviderConfigurationServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PaymentProviderConfigurationServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PaymentProviderProduct":{"type":"object","properties":{"configuration":{"type":"object","additionalProperties":{"type":"null"}},"id":{"type":"string"},"paymentProviderConfigurationId":{"type":"string"},"productId":{"type":"string"},"providerId":{"type":"string"}},"required":["configuration","id","paymentProviderConfigurationId","productId","providerId"],"additionalProperties":false},"PaymentProviderProductServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PaymentProviderProductServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"DeployChangesetBody":{"type":"object","properties":{"changeset":{"type":"object","properties":{"changes":{"type":"array","items":{"anyOf":[{"type":"object","properties":{"changeType":{"type":"string","enum":["create-paywall-location"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"description":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"null"}]},{"type":"null"}]},"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["update-paywall-location"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"description":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"null"}]},{"type":"null"}]},"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["archive-paywall-location"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"slug":{"type":"string"}},"required":["slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["create-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["update-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["delete-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"slug":{"type":"string"}},"required":["slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["create-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["update-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["delete-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"slug":{"type":"string"}},"required":["slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["create-product-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"perkSlug":{"type":"string"},"productSlug":{"type":"string"}},"required":["perkSlug","productSlug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["delete-product-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"perkSlug":{"type":"string"},"productSlug":{"type":"string"}},"required":["perkSlug","productSlug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["create-payment-provider-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"configuration":{"type":"object","additionalProperties":{"type":"null"}},"productSlug":{"type":"string"},"providerId":{"type":"string"}},"required":["configuration","productSlug","providerId"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["update-payment-provider-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"configuration":{"type":"object","additionalProperties":{"type":"null"}},"productSlug":{"type":"string"},"providerId":{"type":"string"}},"required":["configuration","productSlug","providerId"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["delete-payment-provider-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"productSlug":{"type":"string"},"providerId":{"type":"string"}},"required":["productSlug","providerId"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false}]}}},"required":["changes"],"additionalProperties":false}},"required":["changeset"],"additionalProperties":false},"DeployChangesetResponse":{"type":"object","properties":{"deploymentId":{"type":"string"}},"required":["deploymentId"],"additionalProperties":false},"ChangesetDeploymentServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ChangesetDeploymentServiceError"]},"cause":{"type":"null"}},"required":["_tag","cause"],"additionalProperties":false},"CreateWebhookEndpointBody":{"type":"object","properties":{"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"events":{"type":"array","items":{"type":"string"}},"name":{"type":"string"},"url":{"type":"string"}},"required":["events","name","url"],"additionalProperties":false},"WebhookEndpoint":{"type":"object","properties":{"consecutiveFailures":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"events":{"type":"array","items":{"anyOf":[{"type":"string","enum":["person.created"]},{"type":"string","enum":["person.updated"]},{"type":"string","enum":["person.deleted"]},{"type":"string","enum":["subscription.created"]},{"type":"string","enum":["subscription.renewed"]},{"type":"string","enum":["subscription.cancelled"]},{"type":"string","enum":["subscription.expired"]},{"type":"string","enum":["purchase.completed"]},{"type":"string","enum":["purchase.refunded"]}]}},"id":{"type":"string"},"lastSuccessAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"projectId":{"type":"string"},"secret":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["active"]},{"type":"string","enum":["disabled"]},{"type":"string","enum":["failed"]}]},"url":{"type":"string"}},"required":["consecutiveFailures","createdAt","description","events","id","lastSuccessAt","name","projectId","secret","status","url"],"additionalProperties":false},"WebhookValidationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["WebhookValidationError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"WebhookServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["WebhookServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"WebhookEndpointNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["WebhookEndpointNotFoundError"]},"endpointId":{"type":"string"}},"required":["_tag","endpointId"],"additionalProperties":false},"UpdateWebhookEndpointBody":{"type":"object","properties":{"description":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"null"}]},{"type":"null"}]},"events":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"status":{"anyOf":[{"anyOf":[{"type":"string","enum":["active"]},{"type":"string","enum":["disabled"]}]},{"type":"null"}]},"url":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},"WebhookDelivery":{"type":"object","properties":{"attemptCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"completedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"eventOccurredAt":{"type":"string"},"eventType":{"type":"string"},"id":{"type":"string"},"maxAttempts":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"nextAttemptAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"payload":{"type":"null"},"projectId":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["pending"]},{"type":"string","enum":["in_progress"]},{"type":"string","enum":["succeeded"]},{"type":"string","enum":["failed"]},{"type":"string","enum":["exhausted"]}]},"webhookEndpointId":{"type":"string"}},"required":["attemptCount","completedAt","createdAt","eventOccurredAt","eventType","id","maxAttempts","nextAttemptAt","payload","projectId","status","webhookEndpointId"],"additionalProperties":false},"WebhookDeliveryAttempt":{"type":"object","properties":{"attemptNumber":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"durationMs":{"anyOf":[{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"null"}]},"errorMessage":{"anyOf":[{"type":"string"},{"type":"null"}]},"id":{"type":"string"},"responseBody":{"anyOf":[{"type":"string"},{"type":"null"}]},"statusCode":{"anyOf":[{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"null"}]},"succeeded":{"type":"boolean"}},"required":["attemptNumber","createdAt","durationMs","errorMessage","id","responseBody","statusCode","succeeded"],"additionalProperties":false},"WebhookDeliveryWithAttempts":{"type":"object","properties":{"attemptCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"attempts":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDeliveryAttempt"}},"completedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"eventOccurredAt":{"type":"string"},"eventType":{"type":"string"},"id":{"type":"string"},"maxAttempts":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"nextAttemptAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"payload":{"type":"null"},"projectId":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["pending"]},{"type":"string","enum":["in_progress"]},{"type":"string","enum":["succeeded"]},{"type":"string","enum":["failed"]},{"type":"string","enum":["exhausted"]}]},"webhookEndpointId":{"type":"string"}},"required":["attemptCount","attempts","completedAt","createdAt","eventOccurredAt","eventType","id","maxAttempts","nextAttemptAt","payload","projectId","status","webhookEndpointId"],"additionalProperties":false},"WebhookDeliveryNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["WebhookDeliveryNotFoundError"]},"deliveryId":{"type":"string"}},"required":["_tag","deliveryId"],"additionalProperties":false}},"securitySchemes":{"apiKey":{"type":"apiKey","name":"x-api-key","in":"header"},"betterAuthCookie":{"type":"apiKey","name":"__Secure-better-auth.session_token","in":"cookie"},"publishableKey":{"type":"apiKey","name":"x-publishable-key","in":"header"},"secretKey":{"type":"apiKey","name":"x-secret-key","in":"header"}}},"security":[],"tags":[{"name":"auth"},{"name":"api_keys"},{"name":"persons"},{"name":"organizations"},{"name":"perks"},{"name":"paywall_locations"},{"name":"projects"},{"name":"products"},{"name":"product_perks"},{"name":"sdk"},{"name":"users"},{"name":"payment_provider_configurations"},{"name":"payment_provider_products"},{"name":"changesets"},{"name":"webhooks"}]} +{"openapi":"3.1.0","info":{"title":"Api","version":"0.0.1"},"paths":{"/api/v1/auth/session":{"get":{"tags":["auth"],"operationId":"auth.session","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"method":{"anyOf":[{"type":"string","enum":["api-key"]},{"type":"string","enum":["publishable-key"]},{"type":"string","enum":["secret-key"]}]},"name":{"type":"string"},"organizations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false}},"projects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"organizationId":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","organizationId","slug"],"additionalProperties":false}}},"required":["method","name","organizations","projects"],"additionalProperties":false}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"Error","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}}}}}}},"/api/v1/api-keys":{"post":{"tags":["api_keys"],"operationId":"api_keys.createSecretKey","parameters":[],"security":[],"responses":{"200":{"description":"ApiKeyWithRawKey","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyWithRawKey"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSecretKeyBody"}}},"required":true}},"get":{"tags":["api_keys"],"operationId":"api_keys.listApiKeys","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/api-keys/{apiKeyId}":{"get":{"tags":["api_keys"],"operationId":"api_keys.getApiKeyById","parameters":[{"name":"apiKeyId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"ApiKey","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKey"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"ApiKeyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyNotFoundError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}},"delete":{"tags":["api_keys"],"operationId":"api_keys.deleteApiKey","parameters":[{"name":"apiKeyId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"ApiKeyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyNotFoundError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/api-keys/{apiKeyId}/rotate":{"post":{"tags":["api_keys"],"operationId":"api_keys.rotateSecretKey","parameters":[{"name":"apiKeyId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"ApiKeyWithRawKey","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyWithRawKey"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"ApiKeyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyNotFoundError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/persons":{"post":{"tags":["persons"],"operationId":"persons.createPerson","parameters":[],"security":[],"responses":{"200":{"description":"Person","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"400":{"description":"PersonInvalidAnonymousIdError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonInvalidAnonymousIdError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePersonBody"}}},"required":true}},"get":{"tags":["persons"],"operationId":"persons.listPersons","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Person"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/persons/{personId}":{"get":{"tags":["persons"],"operationId":"persons.getPersonById","parameters":[{"name":"personId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"Person","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"PersonNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonNotFoundError"}}}},"500":{"description":"PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/persons/by-distinct-id/{distinctId}":{"get":{"tags":["persons"],"operationId":"persons.getPersonByDistinctId","parameters":[{"name":"distinctId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"Person","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"PersonNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonNotFoundError"}}}},"500":{"description":"PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/organizations":{"post":{"tags":["organizations"],"operationId":"organizations.createOrganization","parameters":[],"security":[],"responses":{"200":{"description":"Organization","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"500":{"description":"OrganizationServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/OrganizationServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrganizationBody"}}},"required":true}}},"/api/v1/perks":{"get":{"tags":["perks"],"operationId":"perks.listPerks","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Perk"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PerkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PerkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/paywall-locations":{"get":{"tags":["paywall_locations"],"operationId":"paywall_locations.listPaywallLocations","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PaywallLocation"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PaywallLocationServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PaywallLocationServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/projects":{"post":{"tags":["projects"],"operationId":"projects.createProject","parameters":[],"security":[],"responses":{"200":{"description":"Project","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Project"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"AuthenticationError | ProjectServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/ProjectServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectBody"}}},"required":true}}},"/api/v1/projects/{organizationId}":{"get":{"tags":["projects"],"operationId":"projects.listProjects","parameters":[{"name":"organizationId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Project"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ProjectServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProjectServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/products":{"get":{"tags":["products"],"operationId":"products.listProducts","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Product"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ProductServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProductServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/product-perks/by-product-id/{productId}":{"get":{"tags":["product_perks"],"operationId":"product_perks.listProductPerksByProductId","parameters":[{"name":"productId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProductPerk"}}}}},"400":{"description":"ProductPerkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProductPerkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ProductPerkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProductPerkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/sdk/person":{"get":{"tags":["sdk"],"operationId":"sdk.getPerson","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SdkPerson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPerson"}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"404":{"description":"SdkPersonNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPersonNotFoundError"}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/sdk/identify":{"post":{"tags":["sdk"],"operationId":"sdk.identifyPerson","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SdkPerson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPerson"}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"409":{"description":"SdkPersonAlreadyIdentifiedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPersonAlreadyIdentifiedError"}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkIdentifyBody"}}},"required":true}}},"/api/v1/sdk/person/traits":{"post":{"tags":["sdk"],"operationId":"sdk.syncPersonAttributes","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SdkPerson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPerson"}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkSyncPersonAttributesBody"}}},"required":true}}},"/api/v1/sdk/sync-transaction":{"post":{"tags":["sdk"],"operationId":"sdk.syncTransaction","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SdkSyncTransactionResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkSyncTransactionResponse"}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"anyOf":[{"type":"string","enum":["ios"]},{"type":"string","enum":["android"]}]},"productId":{"type":"string"},"purchaseDate":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"purchaseToken":{"anyOf":[{"type":"string"},{"type":"null"}]},"quantity":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"receipt":{"anyOf":[{"type":"string"},{"type":"null"}]},"transactionId":{"type":"string"}},"required":["platform","productId","purchaseDate","quantity","transactionId"],"additionalProperties":false}}},"required":true}}},"/api/v1/sdk/evaluate-flags":{"post":{"tags":["sdk"],"operationId":"sdk.evaluateFeatureFlags","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SdkFeatureFlagsResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkFeatureFlagsResponse"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluateFeatureFlagsBody"}}},"required":true}}},"/api/v1/sdk/resolve-paywall":{"post":{"tags":["sdk"],"operationId":"sdk.resolvePaywall","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkResolvedPaywall"},{"type":"null"}]}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkResolvePaywallBody"}}},"required":true}}},"/api/v1/users/current":{"get":{"tags":["users"],"operationId":"users.getUser","parameters":[],"security":[],"responses":{"200":{"description":"User","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"500":{"description":"AuthenticationError | UserServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/UserServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/payment-provider-configurations":{"get":{"tags":["payment_provider_configurations"],"operationId":"payment_provider_configurations.listPaymentProviderConfigurations","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PaymentProviderConfiguration"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PaymentProviderConfigurationServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PaymentProviderConfigurationServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/payment-provider-products":{"get":{"tags":["payment_provider_products"],"operationId":"payment_provider_products.listPaymentProviderProducts","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PaymentProviderProduct"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PaymentProviderProductServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PaymentProviderProductServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/changesets/deploy":{"post":{"tags":["changesets"],"operationId":"changesets.deployChangeset","parameters":[],"security":[],"responses":{"200":{"description":"DeployChangesetResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeployChangesetResponse"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"AuthenticationError | ChangesetDeploymentServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/ChangesetDeploymentServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeployChangesetBody"}}},"required":true}}},"/api/v1/webhooks/endpoints":{"post":{"tags":["webhooks"],"operationId":"webhooks.createWebhookEndpoint","parameters":[],"security":[],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"WebhookValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhookEndpointBody"}}},"required":true}},"get":{"tags":["webhooks"],"operationId":"webhooks.listWebhookEndpoints","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEndpoint"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/endpoints/{endpointId}":{"get":{"tags":["webhooks"],"operationId":"webhooks.getWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}},"patch":{"tags":["webhooks"],"operationId":"webhooks.updateWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"WebhookValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWebhookEndpointBody"}}},"required":true}},"delete":{"tags":["webhooks"],"operationId":"webhooks.deleteWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/endpoints/{endpointId}/rotate-secret":{"post":{"tags":["webhooks"],"operationId":"webhooks.rotateWebhookSecret","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/endpoints/{endpointId}/test":{"post":{"tags":["webhooks"],"operationId":"webhooks.testWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookDelivery","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDelivery"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/deliveries":{"get":{"tags":["webhooks"],"operationId":"webhooks.listWebhookDeliveries","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDelivery"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/deliveries/{deliveryId}":{"get":{"tags":["webhooks"],"operationId":"webhooks.getWebhookDelivery","parameters":[{"name":"deliveryId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookDeliveryWithAttempts","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryWithAttempts"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookDeliveryNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/deliveries/{deliveryId}/retry":{"post":{"tags":["webhooks"],"operationId":"webhooks.retryWebhookDelivery","parameters":[{"name":"deliveryId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookDelivery","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDelivery"}}}},"400":{"description":"WebhookValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookDeliveryNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}}},"components":{"schemas":{"ActionForbiddenError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ActionForbiddenError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"AuthenticationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["AuthenticationError"]},"cause":{"type":"string"},"message":{"type":"string"}},"required":["_tag","cause","message"],"additionalProperties":false},"NotAuthenticatedError":{"type":"object","properties":{"_tag":{"type":"string","enum":["NotAuthenticatedError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"effect_HttpApiSchemaError":{"type":"object","properties":{"_tag":{"type":"string","enum":["HttpApiSchemaError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"CreateSecretKeyBody":{"type":"object","properties":{"name":{"type":"string"},"projectId":{"type":"string"}},"required":["name","projectId"],"additionalProperties":false},"ApiKeyWithRawKey":{"type":"object","properties":{"end":{"type":"string"},"id":{"type":"string"},"isPublic":{"type":"boolean"},"name":{"type":"string"},"prefix":{"type":"string"},"projectId":{"type":"string"},"rawKey":{"type":"string"}},"required":["end","id","isPublic","name","prefix","projectId","rawKey"],"additionalProperties":false},"ApiKeyServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ApiKeyServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"ApiKey":{"type":"object","properties":{"end":{"type":"string"},"id":{"type":"string"},"isPublic":{"type":"boolean"},"name":{"type":"string"},"prefix":{"type":"string"},"projectId":{"type":"string"},"rawKey":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["end","id","isPublic","name","prefix","projectId"],"additionalProperties":false},"ApiKeyNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ApiKeyNotFoundError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"CreatePersonBody":{"type":"object","properties":{"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["distinctId"],"additionalProperties":false},"Person":{"type":"object","properties":{"personId":{"type":"string"},"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["personId","distinctId","email","name"],"additionalProperties":false},"PersonInvalidAnonymousIdError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PersonInvalidAnonymousIdError"]},"id":{"type":"string","allOf":[{"minLength":1}]}},"required":["_tag","id"],"additionalProperties":false},"PersonServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PersonServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PersonNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PersonNotFoundError"]},"id":{"type":"string","allOf":[{"minLength":1}]}},"required":["_tag","id"],"additionalProperties":false},"CreateOrganizationBody":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false},"Organization":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},"OrganizationServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["OrganizationServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"Perk":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","projectId","slug"],"additionalProperties":false},"PerkServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PerkServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PaywallLocation":{"type":"object","properties":{"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"slug":{"type":"string"}},"required":["description","id","name","projectId","slug"],"additionalProperties":false},"PaywallLocationServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PaywallLocationServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"CreateProjectBody":{"type":"object","properties":{"name":{"type":"string"},"organizationId":{"type":"string"}},"required":["name","organizationId"],"additionalProperties":false},"Project":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},"ProjectServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProjectServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"Product":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"slug":{"type":"string"},"type":{"anyOf":[{"type":"string","enum":["subscription"]},{"type":"string","enum":["one-time"]},{"type":"string","enum":["one-time-consumable"]}]}},"required":["id","name","projectId","slug","type"],"additionalProperties":false},"ProductServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProductServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"ProductPerk":{"type":"object","properties":{"id":{"type":"string"},"perkId":{"type":"string"},"productId":{"type":"string"}},"required":["id","perkId","productId"],"additionalProperties":false},"ProductPerkServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProductPerkServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"ProductPerkValidationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProductPerkValidationError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"SdkEntitlementGrant":{"type":"object","properties":{"expiresAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"perkId":{"type":"string"},"source":{"anyOf":[{"type":"string","enum":["subscription"]},{"type":"string","enum":["purchase"]},{"type":"string","enum":["manual"]}]},"sourceId":{"anyOf":[{"type":"string"},{"type":"null"}]},"sourcePersonId":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["active"]},{"type":"string","enum":["expired"]}]}},"required":["expiresAt","perkId","source","sourceId","sourcePersonId","status"],"additionalProperties":false},"SdkPurchaseHistoryEntry":{"type":"object","properties":{"createdAt":{"type":"string"},"productId":{"anyOf":[{"type":"string"},{"type":"null"}]},"providerKey":{"type":"string"},"purchaseId":{"type":"string"},"sourcePersonId":{"type":"string"},"type":{"anyOf":[{"type":"string","enum":["one_time"]},{"type":"string","enum":["subscription"]}]}},"required":["createdAt","productId","providerKey","purchaseId","sourcePersonId","type"],"additionalProperties":false},"SdkCurrentSubscription":{"type":"object","properties":{"expiresAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"productId":{"anyOf":[{"type":"string"},{"type":"null"}]},"status":{"anyOf":[{"type":"string","enum":["none"]},{"type":"string","enum":["active"]},{"type":"string","enum":["canceled"]},{"type":"string","enum":["past_due"]},{"type":"string","enum":["trialing"]}]},"subscriptionId":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["expiresAt","productId","status","subscriptionId"],"additionalProperties":false},"SdkSubscriptionHistoryEntry":{"type":"object","properties":{"canceledAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"expiresAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"isTrial":{"type":"boolean"},"productId":{"anyOf":[{"type":"string"},{"type":"null"}]},"sourcePersonId":{"type":"string"},"startsAt":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["active"]},{"type":"string","enum":["canceled"]},{"type":"string","enum":["expired"]},{"type":"string","enum":["trialing"]},{"type":"string","enum":["past_due"]}]},"subscriptionId":{"type":"string"}},"required":["canceledAt","expiresAt","isTrial","productId","sourcePersonId","startsAt","status","subscriptionId"],"additionalProperties":false},"SdkPerson":{"type":"object","properties":{"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"entitlements":{"type":"object","properties":{"grants":{"type":"array","items":{"$ref":"#/components/schemas/SdkEntitlementGrant"}}},"required":["grants"],"additionalProperties":false},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"personId":{"type":"string"},"purchases":{"type":"object","properties":{"history":{"type":"array","items":{"$ref":"#/components/schemas/SdkPurchaseHistoryEntry"}}},"required":["history"],"additionalProperties":false},"snapshotContext":{"type":"object","properties":{"includedPersonIds":{"type":"array","items":{"type":"string"}},"migrationJobId":{"anyOf":[{"type":"string"},{"type":"null"}]},"mode":{"anyOf":[{"type":"string","enum":["persisted"]},{"type":"string","enum":["temporary_pending_transfer"]}]}},"required":["includedPersonIds","migrationJobId","mode"],"additionalProperties":false},"subscriptions":{"type":"object","properties":{"current":{"anyOf":[{"$ref":"#/components/schemas/SdkCurrentSubscription"},{"type":"null"}]},"history":{"type":"array","items":{"$ref":"#/components/schemas/SdkSubscriptionHistoryEntry"}}},"required":["current","history"],"additionalProperties":false}},"required":["distinctId","email","entitlements","name","personId","purchases","snapshotContext","subscriptions"],"additionalProperties":false},"SdkServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["SdkServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"SdkPersonNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["SdkPersonNotFoundError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"SdkValidationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["SdkValidationError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"SdkIdentifyBody":{"type":"object","properties":{"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"traits":{"anyOf":[{"type":"object","additionalProperties":{"anyOf":[{"type":"string"},{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"boolean"},{"type":"null"}]}},{"type":"null"}]}},"required":["distinctId"],"additionalProperties":false},"SdkPersonAlreadyIdentifiedError":{"type":"object","properties":{"_tag":{"type":"string","enum":["SdkPersonAlreadyIdentifiedError"]},"distinctId":{"type":"string"}},"required":["_tag","distinctId"],"additionalProperties":false},"SdkSyncPersonAttributesBody":{"type":"object","properties":{"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"traits":{"anyOf":[{"type":"object","additionalProperties":{"anyOf":[{"type":"string"},{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"boolean"},{"type":"null"}]}},{"type":"null"}]}},"additionalProperties":false},"SdkSyncTransactionResponse":{"type":"object","properties":{"accepted":{"type":"boolean"}},"required":["accepted"],"additionalProperties":false},"EvaluateFeatureFlagsBody":{"type":"object","properties":{"flagKeys":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]}},"additionalProperties":false},"SdkFeatureFlagResult":{"type":"object","properties":{"enabled":{"type":"boolean"},"key":{"type":"string"},"payload":{"anyOf":[{"type":"null"},{"type":"null"}]},"variantKey":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["enabled","key","payload","variantKey"],"additionalProperties":false},"SdkFeatureFlagsResponse":{"type":"object","properties":{"flags":{"type":"array","items":{"$ref":"#/components/schemas/SdkFeatureFlagResult"}}},"required":["flags"],"additionalProperties":false},"SdkResolvePaywallBody":{"type":"object","properties":{"locationSlug":{"type":"string"}},"required":["locationSlug"],"additionalProperties":false},"SdkResolvedPaywallShowing":{"type":"object","properties":{"id":{"type":"string"},"paywall":{"anyOf":[{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},{"type":"null"}]},"paywallId":{"anyOf":[{"type":"string"},{"type":"null"}]},"paywallRelease":{"anyOf":[{"type":"object","properties":{"htmlUrl":{"type":"string"},"publishedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"releaseId":{"type":"string"},"version":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]}},"required":["htmlUrl","publishedAt","releaseId","version"],"additionalProperties":false},{"type":"null"}]},"paywallReleaseId":{"anyOf":[{"type":"string"},{"type":"null"}]},"startedAt":{"type":"string"},"type":{"anyOf":[{"type":"string","enum":["paywall_release"]},{"type":"string","enum":["feature_flag"]}]}},"required":["id","paywall","paywallId","paywallRelease","paywallReleaseId","startedAt","type"],"additionalProperties":false},"SdkResolvedPaywall":{"type":"object","properties":{"location":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},"showing":{"$ref":"#/components/schemas/SdkResolvedPaywallShowing"}},"required":["location","showing"],"additionalProperties":false},"User":{"type":"object","properties":{"createdAt":{"type":"string"},"email":{"type":"string"},"emailVerified":{"type":"boolean"},"id":{"type":"string"},"image":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"organizations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"logo":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","logo","name","slug"],"additionalProperties":false}},"projects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"logo":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"organizationId":{"type":"string"},"slug":{"type":"string"}},"required":["id","logo","name","organizationId","slug"],"additionalProperties":false}},"updatedAt":{"type":"string"}},"required":["createdAt","email","emailVerified","id","image","name","organizations","projects","updatedAt"],"additionalProperties":false},"UserServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["UserServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PaymentProviderConfiguration":{"type":"object","properties":{"enabled":{"type":"boolean"},"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"providerId":{"type":"string"}},"required":["enabled","id","name","projectId","providerId"],"additionalProperties":false},"PaymentProviderConfigurationServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PaymentProviderConfigurationServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PaymentProviderProduct":{"type":"object","properties":{"configuration":{"type":"object","additionalProperties":{"type":"null"}},"id":{"type":"string"},"paymentProviderConfigurationId":{"type":"string"},"productId":{"type":"string"},"providerId":{"type":"string"}},"required":["configuration","id","paymentProviderConfigurationId","productId","providerId"],"additionalProperties":false},"PaymentProviderProductServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PaymentProviderProductServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"DeployChangesetBody":{"type":"object","properties":{"changeset":{"type":"object","properties":{"changes":{"type":"array","items":{"anyOf":[{"type":"object","properties":{"changeType":{"type":"string","enum":["create-paywall-location"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"description":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"null"}]},{"type":"null"}]},"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["update-paywall-location"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"description":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"null"}]},{"type":"null"}]},"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["archive-paywall-location"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"slug":{"type":"string"}},"required":["slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["create-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["update-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["delete-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"slug":{"type":"string"}},"required":["slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["create-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["update-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["delete-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"slug":{"type":"string"}},"required":["slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["create-product-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"perkSlug":{"type":"string"},"productSlug":{"type":"string"}},"required":["perkSlug","productSlug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["delete-product-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"perkSlug":{"type":"string"},"productSlug":{"type":"string"}},"required":["perkSlug","productSlug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["create-payment-provider-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"configuration":{"type":"object","additionalProperties":{"type":"null"}},"productSlug":{"type":"string"},"providerId":{"type":"string"}},"required":["configuration","productSlug","providerId"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["update-payment-provider-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"configuration":{"type":"object","additionalProperties":{"type":"null"}},"productSlug":{"type":"string"},"providerId":{"type":"string"}},"required":["configuration","productSlug","providerId"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["delete-payment-provider-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"productSlug":{"type":"string"},"providerId":{"type":"string"}},"required":["productSlug","providerId"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false}]}}},"required":["changes"],"additionalProperties":false}},"required":["changeset"],"additionalProperties":false},"DeployChangesetResponse":{"type":"object","properties":{"deploymentId":{"type":"string"}},"required":["deploymentId"],"additionalProperties":false},"ChangesetDeploymentServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ChangesetDeploymentServiceError"]},"cause":{"type":"null"}},"required":["_tag","cause"],"additionalProperties":false},"CreateWebhookEndpointBody":{"type":"object","properties":{"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"events":{"type":"array","items":{"type":"string"}},"name":{"type":"string"},"url":{"type":"string"}},"required":["events","name","url"],"additionalProperties":false},"WebhookEndpoint":{"type":"object","properties":{"consecutiveFailures":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"events":{"type":"array","items":{"anyOf":[{"type":"string","enum":["person.created"]},{"type":"string","enum":["person.updated"]},{"type":"string","enum":["person.deleted"]},{"type":"string","enum":["subscription.created"]},{"type":"string","enum":["subscription.renewed"]},{"type":"string","enum":["subscription.cancelled"]},{"type":"string","enum":["subscription.expired"]},{"type":"string","enum":["purchase.completed"]},{"type":"string","enum":["purchase.refunded"]}]}},"id":{"type":"string"},"lastSuccessAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"projectId":{"type":"string"},"secret":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["active"]},{"type":"string","enum":["disabled"]},{"type":"string","enum":["failed"]}]},"url":{"type":"string"}},"required":["consecutiveFailures","createdAt","description","events","id","lastSuccessAt","name","projectId","secret","status","url"],"additionalProperties":false},"WebhookValidationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["WebhookValidationError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"WebhookServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["WebhookServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"WebhookEndpointNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["WebhookEndpointNotFoundError"]},"endpointId":{"type":"string"}},"required":["_tag","endpointId"],"additionalProperties":false},"UpdateWebhookEndpointBody":{"type":"object","properties":{"description":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"null"}]},{"type":"null"}]},"events":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"status":{"anyOf":[{"anyOf":[{"type":"string","enum":["active"]},{"type":"string","enum":["disabled"]}]},{"type":"null"}]},"url":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},"WebhookDelivery":{"type":"object","properties":{"attemptCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"completedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"eventOccurredAt":{"type":"string"},"eventType":{"type":"string"},"id":{"type":"string"},"maxAttempts":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"nextAttemptAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"payload":{"type":"null"},"projectId":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["pending"]},{"type":"string","enum":["in_progress"]},{"type":"string","enum":["succeeded"]},{"type":"string","enum":["failed"]},{"type":"string","enum":["exhausted"]}]},"webhookEndpointId":{"type":"string"}},"required":["attemptCount","completedAt","createdAt","eventOccurredAt","eventType","id","maxAttempts","nextAttemptAt","payload","projectId","status","webhookEndpointId"],"additionalProperties":false},"WebhookDeliveryAttempt":{"type":"object","properties":{"attemptNumber":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"durationMs":{"anyOf":[{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"null"}]},"errorMessage":{"anyOf":[{"type":"string"},{"type":"null"}]},"id":{"type":"string"},"responseBody":{"anyOf":[{"type":"string"},{"type":"null"}]},"statusCode":{"anyOf":[{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"null"}]},"succeeded":{"type":"boolean"}},"required":["attemptNumber","createdAt","durationMs","errorMessage","id","responseBody","statusCode","succeeded"],"additionalProperties":false},"WebhookDeliveryWithAttempts":{"type":"object","properties":{"attemptCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"attempts":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDeliveryAttempt"}},"completedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"eventOccurredAt":{"type":"string"},"eventType":{"type":"string"},"id":{"type":"string"},"maxAttempts":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"nextAttemptAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"payload":{"type":"null"},"projectId":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["pending"]},{"type":"string","enum":["in_progress"]},{"type":"string","enum":["succeeded"]},{"type":"string","enum":["failed"]},{"type":"string","enum":["exhausted"]}]},"webhookEndpointId":{"type":"string"}},"required":["attemptCount","attempts","completedAt","createdAt","eventOccurredAt","eventType","id","maxAttempts","nextAttemptAt","payload","projectId","status","webhookEndpointId"],"additionalProperties":false},"WebhookDeliveryNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["WebhookDeliveryNotFoundError"]},"deliveryId":{"type":"string"}},"required":["_tag","deliveryId"],"additionalProperties":false}},"securitySchemes":{}},"security":[],"tags":[{"name":"auth"},{"name":"api_keys"},{"name":"persons"},{"name":"organizations"},{"name":"perks"},{"name":"paywall_locations"},{"name":"projects"},{"name":"products"},{"name":"product_perks"},{"name":"sdk"},{"name":"users"},{"name":"payment_provider_configurations"},{"name":"payment_provider_products"},{"name":"changesets"},{"name":"webhooks"}]} diff --git a/packages/generated-clients/src/core/generated.ts b/packages/generated-clients/src/core/generated.ts index 96bc2254e..e738e0727 100644 --- a/packages/generated-clients/src/core/generated.ts +++ b/packages/generated-clients/src/core/generated.ts @@ -310,11 +310,74 @@ export interface SdkGetPersonParams { readonly "x-storefront"?: string | null | undefined } +export type SdkEntitlementGrantSourceEnum = "manual" + +export type SdkEntitlementGrantStatusEnum = "expired" + +export interface SdkEntitlementGrant { + readonly "expiresAt": string | null; + readonly "perkId": string; + readonly "source": SdkEntitlementGrantSourceEnum | SdkEntitlementGrantSourceEnum | SdkEntitlementGrantSourceEnum; + readonly "sourceId": string | null; + readonly "sourcePersonId": string; + readonly "status": SdkEntitlementGrantStatusEnum | SdkEntitlementGrantStatusEnum +} + +export type SdkPurchaseHistoryEntryTypeEnum = "subscription" + +export interface SdkPurchaseHistoryEntry { + readonly "createdAt": string; + readonly "productId": string | null; + readonly "providerKey": string; + readonly "purchaseId": string; + readonly "sourcePersonId": string; + readonly "type": SdkPurchaseHistoryEntryTypeEnum | SdkPurchaseHistoryEntryTypeEnum +} + +export type SdkPersonSnapshotContextModeEnum = "temporary_pending_transfer" + +export type SdkCurrentSubscriptionStatusEnum = "trialing" + +export interface SdkCurrentSubscription { + readonly "expiresAt": string | null; + readonly "productId": string | null; + readonly "status": SdkCurrentSubscriptionStatusEnum | SdkCurrentSubscriptionStatusEnum | SdkCurrentSubscriptionStatusEnum | SdkCurrentSubscriptionStatusEnum | SdkCurrentSubscriptionStatusEnum; + readonly "subscriptionId": string | null +} + +export type SdkSubscriptionHistoryEntryStatusEnum = "past_due" + +export interface SdkSubscriptionHistoryEntry { + readonly "canceledAt": string | null; + readonly "expiresAt": string | null; + readonly "isTrial": boolean; + readonly "productId": string | null; + readonly "sourcePersonId": string; + readonly "startsAt": string; + readonly "status": SdkSubscriptionHistoryEntryStatusEnum | SdkSubscriptionHistoryEntryStatusEnum | SdkSubscriptionHistoryEntryStatusEnum | SdkSubscriptionHistoryEntryStatusEnum | SdkSubscriptionHistoryEntryStatusEnum; + readonly "subscriptionId": string +} + export interface SdkPerson { - readonly "personId": string; readonly "distinctId": string; readonly "email": string | null; - readonly "name": string | null + readonly "entitlements": { + readonly "grants": ReadonlyArray +}; + readonly "name": string | null; + readonly "personId": string; + readonly "purchases": { + readonly "history": ReadonlyArray +}; + readonly "snapshotContext": { + readonly "includedPersonIds": ReadonlyArray; + readonly "migrationJobId": string | null; + readonly "mode": SdkPersonSnapshotContextModeEnum | SdkPersonSnapshotContextModeEnum +}; + readonly "subscriptions": { + readonly "current": SdkCurrentSubscription | null; + readonly "history": ReadonlyArray +} } export type SdkValidationErrorTag = "SdkValidationError" From b250210c99e0539c824325a1f1a54f08e1a674d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Sun, 10 May 2026 22:41:04 +0200 Subject: [PATCH 012/129] feat: refactor voidhash cli / sdk to typegen instead of DSL --- apps/cli/src/cli/commands/init.ts | 309 +++--- apps/cli/src/cli/commands/schema-check.ts | 181 ---- apps/cli/src/cli/commands/schema-pull.ts | 99 -- apps/cli/src/cli/commands/schema-push.ts | 203 ---- apps/cli/src/cli/commands/schema.ts | 18 - apps/cli/src/cli/commands/types-check.ts | 107 ++ apps/cli/src/cli/commands/types-generate.ts | 141 +++ apps/cli/src/cli/commands/types.ts | 17 + apps/cli/src/cli/index.ts | 4 +- apps/cli/src/domain/errors/schema.ts | 31 - apps/cli/src/domain/schema/voidhash-config.ts | 21 +- apps/cli/src/domain/services/codegen.ts | 219 ++-- apps/cli/src/domain/services/schema.ts | 378 +++---- apps/cli/src/index.ts | 6 +- .../cli/src/utils/schema/changeset-builder.ts | 257 ----- apps/cli/src/utils/schema/diff.ts | 222 ---- .../src/utils/schema/local-schema-loader.ts | 339 ------ apps/cli/src/utils/schema/version.ts | 66 ++ apps/cli/tests/fixtures/empty-schema.ts | 6 - apps/cli/tests/fixtures/valid-schema.ts | 44 - .../utils/schema/changeset-builder.test.ts | 991 ------------------ apps/cli/tests/utils/schema/diff.test.ts | 648 ------------ .../utils/schema/local-schema-loader.test.ts | 433 -------- docs/server-first-schema-server-spec.md | 363 +++++++ examples/react-native-example/app/_layout.tsx | 2 +- examples/react-native-example/app/index.tsx | 2 +- .../app/menu/customer.tsx | 2 +- .../react-native-example/app/menu/paywall.tsx | 2 +- .../react-native-example/app/menu/sign-in.tsx | 2 +- examples/react-native-example/package.json | 2 + .../utils/voidhash/_schema.ts | 44 - .../utils/voidhash/client.ts | 22 +- .../utils/voidhash/local.client.ts | 52 - .../utils/voidhash/schema.ts | 27 - .../react-native-example/voidhash.config.ts | 7 +- .../react-native-example/voidhash.gen.d.ts | 15 + libraries/react-native/package.json | 12 +- .../react-native/src/__tests__/client.test.ts | 5 +- .../src/__tests__/core/client-effect.test.ts | 10 +- .../src/__tests__/helpers/test-schema.ts | 78 +- .../react/use-paywall-by-location.test.tsx | 3 +- libraries/react-native/src/client-effect.ts | 250 +++-- .../react-native/src/client-react-native.ts | 21 +- libraries/react-native/src/client.tsx | 158 +-- .../src/core/networking/api-client.ts | 30 + .../payment-adapters/app-store-adapter.ts | 56 +- .../payment-adapters/google-play-adapter.ts | 40 +- .../core/payment-adapters/payment-adapter.ts | 104 +- .../react-native/src/core/schema/builder.ts | 139 --- .../react-native/src/core/schema/constants.ts | 15 - .../src/core/schema/definitions.ts | 13 - .../react-native/src/core/schema/index.ts | 12 +- .../src/core/schema/paywall-location.ts | 19 - .../react-native/src/core/schema/perk.ts | 18 - .../src/core/schema/products/base.ts | 106 -- .../src/core/schema/products/subscription.ts | 52 - .../react-native/src/core/schema/registry.ts | 49 + .../react-native/src/core/schema/runtime.ts | 65 ++ .../react-native/src/core/schema/types.ts | 234 ----- .../react-native/src/core/schema/utils.ts | 70 -- .../src/core/testing/payment-adapter.ts | 16 +- libraries/react-native/src/index.ts | 43 - libraries/react-native/src/metro/index.ts | 146 +++ .../src/react/components/provider.tsx | 32 +- .../src/react/hooks/use-customer.ts | 7 +- .../src/react/hooks/use-feature-flags.ts | 7 +- .../react/hooks/use-paywall-by-location.ts | 45 +- .../src/react/hooks/use-products.ts | 46 +- .../src/react/hooks/use-purchase.ts | 14 +- 69 files changed, 1849 insertions(+), 5348 deletions(-) delete mode 100644 apps/cli/src/cli/commands/schema-check.ts delete mode 100644 apps/cli/src/cli/commands/schema-pull.ts delete mode 100644 apps/cli/src/cli/commands/schema-push.ts delete mode 100644 apps/cli/src/cli/commands/schema.ts create mode 100644 apps/cli/src/cli/commands/types-check.ts create mode 100644 apps/cli/src/cli/commands/types-generate.ts create mode 100644 apps/cli/src/cli/commands/types.ts delete mode 100644 apps/cli/src/utils/schema/changeset-builder.ts delete mode 100644 apps/cli/src/utils/schema/diff.ts delete mode 100644 apps/cli/src/utils/schema/local-schema-loader.ts create mode 100644 apps/cli/src/utils/schema/version.ts delete mode 100644 apps/cli/tests/fixtures/empty-schema.ts delete mode 100644 apps/cli/tests/fixtures/valid-schema.ts delete mode 100644 apps/cli/tests/utils/schema/changeset-builder.test.ts delete mode 100644 apps/cli/tests/utils/schema/diff.test.ts delete mode 100644 apps/cli/tests/utils/schema/local-schema-loader.test.ts create mode 100644 docs/server-first-schema-server-spec.md delete mode 100644 examples/react-native-example/utils/voidhash/_schema.ts delete mode 100644 examples/react-native-example/utils/voidhash/local.client.ts delete mode 100644 examples/react-native-example/utils/voidhash/schema.ts create mode 100644 examples/react-native-example/voidhash.gen.d.ts delete mode 100644 libraries/react-native/src/core/schema/builder.ts delete mode 100644 libraries/react-native/src/core/schema/constants.ts delete mode 100644 libraries/react-native/src/core/schema/definitions.ts delete mode 100644 libraries/react-native/src/core/schema/paywall-location.ts delete mode 100644 libraries/react-native/src/core/schema/perk.ts delete mode 100644 libraries/react-native/src/core/schema/products/base.ts delete mode 100644 libraries/react-native/src/core/schema/products/subscription.ts create mode 100644 libraries/react-native/src/core/schema/registry.ts create mode 100644 libraries/react-native/src/core/schema/runtime.ts delete mode 100644 libraries/react-native/src/core/schema/types.ts delete mode 100644 libraries/react-native/src/core/schema/utils.ts create mode 100644 libraries/react-native/src/metro/index.ts diff --git a/apps/cli/src/cli/commands/init.ts b/apps/cli/src/cli/commands/init.ts index 2aaf2e17c..473a1b14b 100644 --- a/apps/cli/src/cli/commands/init.ts +++ b/apps/cli/src/cli/commands/init.ts @@ -1,9 +1,10 @@ import { Command, Prompt } from "effect/unstable/cli"; import { Console, Effect, Path } from "effect"; -import { createInitialNormalizedSchema } from "../../domain/schema/normalized-schema"; +import { DEFAULT_TYPES_OUTPUT } from "../../domain/schema/voidhash-config"; import { Auth } from "../../domain/services/auth"; import { Codegen } from "../../domain/services/codegen"; +import { SchemaService } from "../../domain/services/schema"; import { SourceCode } from "../../domain/services/source-code"; import { ApiClient } from "../../utils/api-client"; import { userError } from "../../utils/error-formatter"; @@ -12,159 +13,157 @@ import { selectOrganization } from "../../utils/organizations/select-organizatio import { selectProject } from "../../utils/projects/select-project"; import { debugOption } from "../shared-options"; +/** + * `voidhash init` + * + * One-time setup: authenticate, select team/project, write `voidhash.config.ts` + * at the project root, and produce the initial `voidhash.gen.d.ts` so type + * autocomplete works immediately. + * + * Schema files are no longer generated — the dashboard is the source of truth + * after the server-first redesign. Likewise the client file is the user's to + * write (it's two lines now: import + `createVoidhashClient`). + */ export const initCommand = Command.make("init", { debug: debugOption }, () => - Effect.gen(function* initCommand() { - const auth = yield* Auth; - const apiClient = yield* ApiClient; - const sourceCode = yield* SourceCode; - const codegen = yield* Codegen; - const path = yield* Path.Path; - - const voidhashConfig = yield* sourceCode - .loadVoidhashConfig() - .pipe( - Effect.catchTag("VoidhashConfigNotFoundError", () => - Effect.succeed(null), - ), - ); - - if (voidhashConfig) { - const shouldContinue = yield* Prompt.run( - Prompt.confirm({ - message: - "Voidhash was already initialized in this project. This will overwrite the existing configuration. Do you want to continue?", - }), - ); - if (!shouldContinue) { - return yield* Console.log("Initialization cancelled."); - } - yield* sourceCode.deleteVoidhashConfig(); - } - - // Sign in - const session = yield* auth.getSignedInSession - .pipe( - Effect.catchTag("NoSignedInUserError", () => - Effect.gen(function* session() { - const shouldContinue = yield* Prompt.run( - Prompt.confirm({ - message: - "You are not logged in. In the next step, we will open a browser window to sign you in. Do you want to continue?", - }), - ); - if (!shouldContinue) { - return yield* Effect.fail(userError("Login cancelled.")); - } - return yield* auth.login.pipe( - Effect.andThen(auth.getSignedInSession), - ); - }), - ), - ) - .pipe( - Effect.catchTags({ - FailedToGetSessionError: (e) => - Effect.fail( - userError("Failed to get user session. Please try again."), - ).pipe(Effect.tapError(() => Effect.logDebug(e))), - NoSignedInUserError: () => - Effect.fail( - userError("We were unable to sign you in. Please try it again."), - ), - // TODO: handle other errors - }), - ); - - // Select team - const organization = yield* selectOrganization(session.organizations); - - // Select project - const project = yield* selectProject( - organization.id, - session.projects.filter((p) => p.organizationId === organization.id), - ); - const apiKeys = (yield* apiClient.apiKeysListApiKeys()) as readonly { - id: string; - isPublic: boolean; - projectId: string; - rawKey?: string; - }[]; - const publishableApiKey = apiKeys.find( - (apiKey) => apiKey.isPublic && apiKey.projectId === project.id, - ); - const publishableKey = publishableApiKey?.rawKey; - if (!publishableKey) { - return yield* Effect.fail( - userError( - "Could not retrieve raw publishable key from listApiKeys for the selected project.", - ), - ); - } - if (!publishableKey.startsWith("vh_pk_")) { - return yield* Effect.fail( - userError( - "Received an invalid publishable key from listApiKeys for the selected project.", - ), - ); - } - - // Select folder path - - const srcFolderPath = yield* sourceCode.retrieveSrcDir(); - const hasSrcDir = srcFolderPath.endsWith("src"); - - const voidhashFilesFolderPath = yield* Prompt.run( - Prompt.text({ - default: hasSrcDir ? "./src/utils/voidhash" : "./utils/voidhash", - message: - "Select the folder where you want to create the Voidhash schema and client", - }), - ); - - // File names - const language = yield* sourceCode.detectSrcLanguage(); - const schemaFileName = language === "ts" ? "schema.ts" : "schema.js"; - const clientFileName = language === "ts" ? "client.ts" : "client.js"; - const configFileName = - language === "ts" ? "voidhash.config.ts" : "voidhash.config.js"; - - // File paths - const schemaFilePath = path.resolve( - voidhashFilesFolderPath, - schemaFileName, - ); - const clientFilePath = path.resolve( - voidhashFilesFolderPath, - clientFileName, - ); - const configFilePath = path.resolve(configFileName); - - // Assert files can be created - yield* assertFileCanBeCreated(schemaFileName, schemaFilePath); - yield* assertFileCanBeCreated(clientFileName, clientFilePath); - yield* assertFileCanBeCreated(configFileName, configFilePath); - - // Generate files - yield* codegen.generateVoidhashConfigFile(configFilePath, { - project: project.slug, - schema: path.relative(path.resolve(), schemaFilePath), - team: organization.slug, - }); - - // Generate initial schema file - const initialSchema = createInitialNormalizedSchema(); - yield* codegen.generateSchemaFile(schemaFilePath, initialSchema); - yield* codegen.generateClientFile(clientFilePath, publishableKey); - - yield* Console.log("\nVoidhash initialized successfully!"); - yield* Console.log(` Config: ${configFilePath}`); - yield* Console.log(` Schema: ${schemaFilePath}`); - yield* Console.log(` Client: ${clientFilePath}`); - yield* Console.log(`\nNext steps:`); - yield* Console.log(` 1. Add your products and perks to the schema file.`); - yield* Console.log(` 2. Use the generated client in your app code.`); - yield* Console.log( - ` 3. Run \`voidhash-cli schema push\` to push your schema to the server.`, - ); - }), + Effect.gen(function* initCommand() { + const auth = yield* Auth; + const apiClient = yield* ApiClient; + const sourceCode = yield* SourceCode; + const codegen = yield* Codegen; + const schemaService = yield* SchemaService; + const path = yield* Path.Path; + + const voidhashConfig = yield* sourceCode.loadVoidhashConfig().pipe( + Effect.catchTag("VoidhashConfigNotFoundError", () => + Effect.succeed(null) + ) + ); + + if (voidhashConfig) { + const shouldContinue = yield* Prompt.run( + Prompt.confirm({ + message: + "Voidhash was already initialized in this project. This will overwrite the existing configuration. Do you want to continue?", + }) + ); + if (!shouldContinue) { + return yield* Console.log("Initialization cancelled."); + } + yield* sourceCode.deleteVoidhashConfig(); + } + + // Sign in + const session = yield* auth.getSignedInSession + .pipe( + Effect.catchTag("NoSignedInUserError", () => + Effect.gen(function* session() { + const shouldContinue = yield* Prompt.run( + Prompt.confirm({ + message: + "You are not logged in. In the next step, we will open a browser window to sign you in. Do you want to continue?", + }) + ); + if (!shouldContinue) { + return yield* Effect.fail(userError("Login cancelled.")); + } + return yield* auth.login.pipe( + Effect.andThen(auth.getSignedInSession) + ); + }) + ) + ) + .pipe( + Effect.catchTags({ + FailedToGetSessionError: (e) => + Effect.fail( + userError("Failed to get user session. Please try again.") + ).pipe(Effect.tapError(() => Effect.logDebug(e))), + NoSignedInUserError: () => + Effect.fail( + userError("We were unable to sign you in. Please try it again.") + ), + }) + ); + + // Select team + const organization = yield* selectOrganization(session.organizations); + + // Select project + const project = yield* selectProject( + organization.id, + session.projects.filter((p) => p.organizationId === organization.id) + ); + + // Sanity-check that a publishable key exists for this project; we don't + // need to write it anywhere (the user puts it in their app code), but a + // missing key is a configuration problem we should surface now. + const apiKeys = (yield* apiClient.apiKeysListApiKeys()) as readonly { + id: string; + isPublic: boolean; + projectId: string; + rawKey?: string; + }[]; + const publishableApiKey = apiKeys.find( + (apiKey) => apiKey.isPublic && apiKey.projectId === project.id + ); + if (!publishableApiKey?.rawKey?.startsWith("vh_pk_")) { + return yield* Effect.fail( + userError( + "Could not retrieve a valid publishable key for the selected project." + ) + ); + } + const publishableKey = publishableApiKey.rawKey; + + // Decide where the generated `.d.ts` lives. We default to the project + // root since module augmentation works from anywhere in `tsconfig.include`. + const language = yield* sourceCode.detectSrcLanguage(); + const configFileName = + language === "ts" ? "voidhash.config.ts" : "voidhash.config.js"; + + const configFilePath = path.resolve(configFileName); + const typesOutputPath = path.resolve(DEFAULT_TYPES_OUTPUT); + + yield* assertFileCanBeCreated(configFileName, configFilePath); + yield* assertFileCanBeCreated(DEFAULT_TYPES_OUTPUT, typesOutputPath); + + // Write the config. `typesOutput` is omitted from the generated file so + // it picks up the default (`voidhash.gen.d.ts`) — keeps the config terse. + yield* codegen.generateVoidhashConfigFile(configFilePath, { + project: project.slug, + team: organization.slug, + }); + + // Produce the initial declaration file so the user has working + // autocomplete on the first run. Failures here are non-fatal — the user + // can re-run `voidhash types generate` later. + const generatedVersion = yield* schemaService + .fetchRemoteSchema() + .pipe( + Effect.flatMap((schema) => + codegen.generateTypesDeclarationFile(typesOutputPath, schema) + ), + Effect.catch((e) => + Effect.logWarning( + `Failed to generate initial types: ${String(e)}. You can run 'voidhash types generate' later.` + ).pipe(Effect.as(null)) + ) + ); + + yield* Console.log("\nVoidhash initialized successfully!"); + yield* Console.log(` Config: ${configFilePath}`); + if (generatedVersion !== null) { + yield* Console.log(` Types: ${typesOutputPath}`); + } + yield* Console.log(`\nNext steps:`); + yield* Console.log( + ` 1. Add the publishable key (${publishableKey}) to your client code.` + ); + yield* Console.log( + ` 2. Create your products and paywall locations in the Voidhash dashboard.` + ); + yield* Console.log( + ` 3. Re-run 'voidhash types generate' whenever the dashboard schema changes.` + ); + }) ).pipe(Command.withDescription("Initialize a new Voidhash project.")); diff --git a/apps/cli/src/cli/commands/schema-check.ts b/apps/cli/src/cli/commands/schema-check.ts deleted file mode 100644 index 717da7683..000000000 --- a/apps/cli/src/cli/commands/schema-check.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { Command } from "effect/unstable/cli"; -import { Console, Effect } from "effect"; - -import { - MissingProviderConfigurationError, - SchemaCheckFailedError, -} from "../../domain/errors/schema"; -import { Auth } from "../../domain/services/auth"; -import { SchemaService } from "../../domain/services/schema"; -import { SourceCode } from "../../domain/services/source-code"; -import { userError } from "../../utils/error-formatter"; -import { debugOption } from "../shared-options"; - -export const schemaCheckCommand = Command.make("check", { debug: debugOption }, () => - Effect.gen(function* schemaCheckCommand() { - const auth = yield* Auth; - const sourceCode = yield* SourceCode; - const schemaService = yield* SchemaService; - - // Authenticate - yield* auth.getSignedInSession.pipe( - Effect.catchTag("NoSignedInUserError", () => - Effect.fail( - userError( - "You must be logged in to check schema. Run 'voidhash auth login' first." - ) - ) - ) - ); - - // Load voidhash.config - const config = yield* sourceCode.loadVoidhashConfig().pipe( - Effect.catchTag("VoidhashConfigNotFoundError", () => - Effect.fail( - userError("voidhash.config.ts not found. Run 'voidhash init' to create one.") - ) - ) - ); - - // Load local schema - yield* Console.log("Loading local schema..."); - const localSchema = yield* schemaService.loadLocalSchema(config.schema).pipe( - Effect.catchTag("LocalSchemaNotFoundError", (e) => - Effect.fail(userError(`Schema file not found: ${e.path}`)) - ), - Effect.catchTag("LocalSchemaParseError", (e) => - Effect.fail(userError(`Failed to parse schema: ${e.message}`)) - ) - ); - - yield* Console.log(` Found ${localSchema.locations.size} paywall locations`); - yield* Console.log(` Found ${localSchema.perks.size} perks`); - yield* Console.log(` Found ${localSchema.products.size} products`); - yield* Console.log( - ` Providers: ${[...localSchema.enabledProviders].join(", ") || "none"}` - ); - - // Fetch remote schema - yield* Console.log("\nFetching remote schema..."); - const remoteSchema = yield* schemaService.fetchRemoteSchema().pipe( - Effect.catchTag("RemoteSchemaFetchError", (e) => - Effect.fail(userError(`Failed to fetch remote schema: ${String(e.cause)}`)) - ) - ); - - yield* Console.log(` Found ${remoteSchema.locations.size} paywall locations`); - yield* Console.log(` Found ${remoteSchema.perks.size} perks`); - yield* Console.log(` Found ${remoteSchema.products.size} products`); - - // Check provider configurations - const providerConfigs = yield* schemaService.fetchProviderConfigurations().pipe( - Effect.catchTag("RemoteSchemaFetchError", (e) => - Effect.fail( - userError(`Failed to fetch provider configurations: ${String(e.cause)}`) - ) - ) - ); - - const missingProviders = schemaService.checkProviderConfigurations( - localSchema.enabledProviders, - providerConfigs - ); - - if (missingProviders.length > 0) { - yield* Console.log("\nMissing payment provider configurations:"); - for (const provider of missingProviders) { - yield* Console.log(` - ${provider}`); - } - yield* Console.log( - "\nPlease configure these providers in the dashboard before pushing." - ); - return yield* Effect.fail( - new MissingProviderConfigurationError({ providers: missingProviders }) - ); - } - - // Compute diff - const diff = schemaService.computeDiff(localSchema, remoteSchema); - const summary = schemaService.summarizeDiff(diff); - - // Check results - const issues: string[] = []; - - if (diff.locations.toCreate.length > 0) { - issues.push(`${diff.locations.toCreate.length} paywall locations missing from server`); - yield* Console.log("\nMissing paywall locations:"); - for (const location of diff.locations.toCreate) { - yield* Console.log(` + ${location.slug} ("${location.name}")`); - } - } - - if (diff.locations.toUpdate.length > 0) { - issues.push(`${diff.locations.toUpdate.length} paywall locations need updating`); - yield* Console.log("\nOutdated paywall locations:"); - for (const { local, remote } of diff.locations.toUpdate) { - yield* Console.log(` ~ ${local.slug}: "${remote.name}" -> "${local.name}"`); - } - } - - if (diff.locations.toArchive.length > 0) { - issues.push(`${diff.locations.toArchive.length} paywall locations should be archived`); - yield* Console.log("\nPaywall locations to archive (remote-only):"); - for (const location of diff.locations.toArchive) { - yield* Console.log(` - ${location.slug} ("${location.name}")`); - } - } - - if (diff.perks.toCreate.length > 0) { - issues.push(`${diff.perks.toCreate.length} perks missing from server`); - yield* Console.log("\nMissing perks:"); - for (const perk of diff.perks.toCreate) { - yield* Console.log(` + ${perk.slug} ("${perk.name}")`); - } - } - - if (diff.products.toCreate.length > 0) { - issues.push(`${diff.products.toCreate.length} products missing from server`); - yield* Console.log("\nMissing products:"); - for (const product of diff.products.toCreate) { - yield* Console.log(` + ${product.slug} ("${product.name}")`); - } - } - - if (diff.perks.toUpdate.length > 0) { - issues.push(`${diff.perks.toUpdate.length} perks need updating`); - yield* Console.log("\nOutdated perks:"); - for (const { local, remote } of diff.perks.toUpdate) { - yield* Console.log(` ~ ${local.slug}: "${remote.name}" -> "${local.name}"`); - } - } - - if (diff.products.toUpdate.length > 0) { - issues.push(`${diff.products.toUpdate.length} products need updating`); - yield* Console.log("\nOutdated products:"); - for (const { local, remote } of diff.products.toUpdate) { - yield* Console.log(` ~ ${local.slug}: "${remote.name}" -> "${local.name}"`); - } - } - - if (issues.length === 0) { - yield* Console.log( - "\n\u2713 All local schema entities exist on the server and are up to date." - ); - return; - } - - yield* Console.log(`\n\u2717 Schema check failed: ${issues.join(", ")}`); - yield* Console.log("\nRun 'voidhash schema push' to sync these changes."); - - return yield* Effect.fail( - new SchemaCheckFailedError({ - message: `Schema check failed: ${issues.join(", ")}`, - }) - ); - }).pipe( - Effect.catchTags({ - MissingProviderConfigurationError: () => Effect.void, - SchemaCheckFailedError: () => Effect.void, - }) - ) -).pipe(Command.withDescription("Check that the server contains all local schema entities.")); diff --git a/apps/cli/src/cli/commands/schema-pull.ts b/apps/cli/src/cli/commands/schema-pull.ts deleted file mode 100644 index 2374afefd..000000000 --- a/apps/cli/src/cli/commands/schema-pull.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Command, Flag, Prompt } from "effect/unstable/cli"; -import { Console, Effect, Path } from "effect"; - -import { Auth } from "../../domain/services/auth"; -import { Codegen } from "../../domain/services/codegen"; -import { SchemaService } from "../../domain/services/schema"; -import { SourceCode } from "../../domain/services/source-code"; -import { userError } from "../../utils/error-formatter"; -import { debugOption } from "../shared-options"; - -export const schemaPullCommand = Command.make( - "pull", - { - debug: debugOption, - force: Flag.boolean("force").pipe( - Flag.withDescription("Skip confirmation prompt"), - Flag.withDefault(false) - ), - }, - ({ force }) => - Effect.gen(function* schemaPullCommand() { - const auth = yield* Auth; - const sourceCode = yield* SourceCode; - const schemaService = yield* SchemaService; - const codegen = yield* Codegen; - const pathService = yield* Path.Path; - - // Authenticate - yield* auth.getSignedInSession.pipe( - Effect.catchTag("NoSignedInUserError", () => - Effect.fail( - userError( - "You must be logged in to pull schema. Run 'voidhash auth login' first." - ) - ) - ) - ); - - // Load voidhash.config - const config = yield* sourceCode.loadVoidhashConfig().pipe( - Effect.catchTag("VoidhashConfigNotFoundError", () => - Effect.fail( - userError( - "voidhash.config.ts not found. Run 'voidhash init' to create one." - ) - ) - ) - ); - - // Fetch remote schema - yield* Console.log("Fetching remote schema..."); - const remoteSchema = yield* schemaService.fetchRemoteSchema().pipe( - Effect.catchTag("RemoteSchemaFetchError", (e) => - Effect.fail( - userError(`Failed to fetch remote schema: ${String(e.cause)}`) - ) - ) - ); - - // Display summary - yield* Console.log(`\nRemote schema contains:`); - yield* Console.log(` ${remoteSchema.locations.size} paywall locations`); - yield* Console.log(` ${remoteSchema.perks.size} perks`); - yield* Console.log(` ${remoteSchema.products.size} products`); - yield* Console.log( - ` Providers: ${[...remoteSchema.enabledProviders].join(", ") || "none"}` - ); - - if ( - remoteSchema.locations.size === 0 && - remoteSchema.perks.size === 0 && - remoteSchema.products.size === 0 - ) { - yield* Console.log( - "\nRemote schema is empty. Nothing to pull." - ); - return; - } - - // Confirm unless --force - if (!force) { - const confirmed = yield* Prompt.run( - Prompt.confirm({ - message: `This will overwrite ${config.schema}. Continue?`, - }) - ); - if (!confirmed) { - yield* Console.log("Pull cancelled."); - return; - } - } - - // Generate schema file - const schemaPath = pathService.resolve(config.schema); - yield* codegen.generateSchemaFile(schemaPath, remoteSchema); - - yield* Console.log(`\n\u2713 Schema pulled to ${config.schema}`); - }) -).pipe(Command.withDescription("Pull the Voidhash schema from the server.")); diff --git a/apps/cli/src/cli/commands/schema-push.ts b/apps/cli/src/cli/commands/schema-push.ts deleted file mode 100644 index a77414181..000000000 --- a/apps/cli/src/cli/commands/schema-push.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { Command, Flag, Prompt } from "effect/unstable/cli"; -import { Console, Effect } from "effect"; - -import { MissingProviderConfigurationError } from "../../domain/errors/schema"; -import { Auth } from "../../domain/services/auth"; -import { SchemaService, formatChange } from "../../domain/services/schema"; -import { SourceCode } from "../../domain/services/source-code"; -import { userError } from "../../utils/error-formatter"; -import { debugOption } from "../shared-options"; - -export const schemaPushCommand = Command.make( - "push", - { - debug: debugOption, - dryRun: Flag.boolean("dry-run").pipe( - Flag.withDescription("Preview changes without applying"), - Flag.withDefault(false) - ), - yes: Flag.boolean("yes").pipe( - Flag.withAlias("y"), - Flag.withDescription("Auto-approve all changes"), - Flag.withDefault(false) - ), - }, - ({ dryRun, yes }) => - Effect.gen(function* schemaPushCommand() { - const auth = yield* Auth; - const sourceCode = yield* SourceCode; - const schemaService = yield* SchemaService; - - // Authenticate - yield* auth.getSignedInSession.pipe( - Effect.catchTag("NoSignedInUserError", () => - Effect.fail( - userError( - "You must be logged in to push schema. Run 'voidhash auth login' first." - ) - ) - ) - ); - - // Load voidhash.config - const config = yield* sourceCode - .loadVoidhashConfig() - .pipe( - Effect.catchTag("VoidhashConfigNotFoundError", () => - Effect.fail( - userError( - "voidhash.config.ts not found. Run 'voidhash init' to create one." - ) - ) - ) - ); - - // Load local schema - yield* Console.log("Loading local schema..."); - const localSchema = yield* schemaService - .loadLocalSchema(config.schema) - .pipe( - Effect.catchTag("LocalSchemaNotFoundError", (e) => - Effect.fail(userError(`Schema file not found: ${e.path}`)) - ), - Effect.catchTag("LocalSchemaParseError", (e) => - Effect.fail(userError(`Failed to parse schema: ${e.message}`)) - ) - ); - - yield* Console.log(` Found ${localSchema.locations.size} paywall locations`); - yield* Console.log(` Found ${localSchema.perks.size} perks`); - yield* Console.log(` Found ${localSchema.products.size} products`); - - // Fetch remote schema - yield* Console.log("\nFetching remote schema..."); - const remoteSchema = yield* schemaService - .fetchRemoteSchema() - .pipe( - Effect.catchTag("RemoteSchemaFetchError", (e) => - Effect.fail( - userError(`Failed to fetch remote schema: ${String(e.cause)}`) - ) - ) - ); - - yield* Console.log(` Found ${remoteSchema.locations.size} paywall locations`); - yield* Console.log(` Found ${remoteSchema.perks.size} perks`); - yield* Console.log(` Found ${remoteSchema.products.size} products`); - - // Check provider configurations FIRST - const providerConfigs = yield* schemaService - .fetchProviderConfigurations() - .pipe( - Effect.catchTag("RemoteSchemaFetchError", (e) => - Effect.fail( - userError( - `Failed to fetch provider configurations: ${String(e.cause)}` - ) - ) - ) - ); - - const missingProviders = schemaService.checkProviderConfigurations( - localSchema.enabledProviders, - providerConfigs - ); - - if (missingProviders.length > 0) { - yield* Console.log( - "\nCannot push: Missing payment provider configurations:" - ); - for (const provider of missingProviders) { - yield* Console.log(` - ${provider}`); - } - yield* Console.log( - "\nPlease configure these providers in the dashboard first." - ); - return yield* Effect.fail( - new MissingProviderConfigurationError({ providers: missingProviders }) - ); - } - - // Compute diff - const diff = schemaService.computeDiff(localSchema, remoteSchema); - - // Build changeset (creates + updates only) - const changeset = schemaService.buildChangeset(diff); - - if (changeset.changes.length === 0) { - yield* Console.log( - "\n\u2713 Schema is already in sync. Nothing to push." - ); - return; - } - - // Display changes - yield* Console.log(`\n${changeset.changes.length} changes to push:\n`); - for (const change of changeset.changes) { - yield* Console.log(` ${formatChange(change)}`); - } - - if (dryRun) { - yield* Console.log("\n(Dry run - no changes applied)"); - return; - } - - // Collect approved changes - const approvedChanges: (typeof changeset.changes)[number][] = []; - - for (const change of changeset.changes) { - yield* Console.log(`\n${formatChange(change)}`); - - const approved = - yes || - (yield* Prompt.run( - Prompt.confirm({ initial: true, message: "Apply this change?" }) - )); - - if (approved) { - approvedChanges.push(change); - yield* Console.log(" \u2713 Approved"); - } else { - yield* Console.log(" \u2298 Skipped"); - } - } - - const skipped = changeset.changes.length - approvedChanges.length; - - if (approvedChanges.length === 0) { - yield* Console.log("\nNo changes to apply."); - return; - } - - // Deploy all approved changes at once - yield* Console.log(`\nDeploying ${approvedChanges.length} changes...`); - const result = yield* schemaService - .deployChangeset({ changes: approvedChanges }) - .pipe( - Effect.map(() => true), - Effect.catchTag("ChangeDeploymentError", (e) => - Effect.succeed(false).pipe( - Effect.tap(() => - Console.log(`\u2717 Deployment failed: ${String(e.cause)}`) - ) - ) - ) - ); - - if (result) { - yield* Console.log( - `\n\u2713 Push complete: ${approvedChanges.length} applied, ${skipped} skipped` - ); - } else { - yield* Console.log( - `\n\u2717 Push failed: 0 applied, ${skipped} skipped` - ); - } - }).pipe( - Effect.catchTags({ - MissingProviderConfigurationError: () => Effect.void, - }) - ) -).pipe( - Command.withDescription("Push the local Voidhash schema to the server.") -); diff --git a/apps/cli/src/cli/commands/schema.ts b/apps/cli/src/cli/commands/schema.ts deleted file mode 100644 index b2094019c..000000000 --- a/apps/cli/src/cli/commands/schema.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Command } from "effect/unstable/cli"; -import { Effect } from "effect"; - -import { debugOption } from "../shared-options"; -import { schemaCheckCommand } from "./schema-check"; -import { schemaPullCommand } from "./schema-pull"; -import { schemaPushCommand } from "./schema-push"; - -export const schemaCommand = Command.make("schema", { debug: debugOption }, () => - Effect.gen(function* schemaCommand() {}) -).pipe( - Command.withDescription("Manage the Voidhash schema."), - Command.withSubcommands([ - schemaPullCommand, - schemaPushCommand, - schemaCheckCommand, - ]) -); diff --git a/apps/cli/src/cli/commands/types-check.ts b/apps/cli/src/cli/commands/types-check.ts new file mode 100644 index 000000000..cf19e2261 --- /dev/null +++ b/apps/cli/src/cli/commands/types-check.ts @@ -0,0 +1,107 @@ +import { Command } from "effect/unstable/cli"; +import { Console, Effect, Path } from "effect"; + +import { SchemaCheckFailedError } from "../../domain/errors/schema"; +import { resolveTypesOutput } from "../../domain/schema/voidhash-config"; +import { Auth } from "../../domain/services/auth"; +import { Codegen } from "../../domain/services/codegen"; +import { SchemaService } from "../../domain/services/schema"; +import { SourceCode } from "../../domain/services/source-code"; +import { userError } from "../../utils/error-formatter"; +import { debugOption } from "../shared-options"; + +/** + * `voidhash types check` + * + * CI gate. Compares the `@voidhash:version` header inside the local + * `voidhash.gen.d.ts` against the server's current schema version. Exits + * non-zero with a clear message when they diverge, so a stale commit can't + * pass PR CI. + */ +export const typesCheckCommand = Command.make( + "check", + { debug: debugOption }, + () => + Effect.gen(function* typesCheckCommand() { + const auth = yield* Auth; + const sourceCode = yield* SourceCode; + const schemaService = yield* SchemaService; + const codegen = yield* Codegen; + const pathService = yield* Path.Path; + + yield* auth.getSignedInSession.pipe( + Effect.catchTag("NoSignedInUserError", () => + Effect.fail( + userError( + "You must be logged in to check types. Run 'voidhash auth login' first." + ) + ) + ) + ); + + const config = yield* sourceCode.loadVoidhashConfig().pipe( + Effect.catchTag("VoidhashConfigNotFoundError", () => + Effect.fail( + userError( + "voidhash.config.ts not found. Run 'voidhash init' to create one." + ) + ) + ) + ); + + const typesOutput = resolveTypesOutput(config); + const outPath = pathService.resolve(typesOutput); + + const localVersion = yield* codegen.readDeclarationVersion(outPath).pipe( + Effect.catch(() => + Effect.fail( + userError( + `Could not read generated types at ${typesOutput}. Run 'voidhash types generate' first.` + ) + ) + ) + ); + + if (localVersion === null) { + return yield* Effect.fail( + userError( + `${typesOutput} is missing the @voidhash:version header. Re-run 'voidhash types generate' to regenerate.` + ) + ); + } + + const remoteVersion = yield* schemaService.fetchSchemaVersion().pipe( + Effect.catchTag("RemoteSchemaFetchError", (e) => + Effect.fail( + userError( + `Failed to fetch remote schema version: ${String(e.cause)}` + ) + ) + ) + ); + + if (localVersion === remoteVersion) { + yield* Console.log( + `✓ Types are up to date (version ${localVersion.slice(0, 19)}...)` + ); + return; + } + + yield* Console.log("✗ Types are stale."); + yield* Console.log(` Local: ${localVersion}`); + yield* Console.log(` Server: ${remoteVersion}`); + yield* Console.log( + "\nRun 'voidhash types generate' to refresh, then commit the updated declaration file." + ); + + return yield* Effect.fail( + new SchemaCheckFailedError({ + message: `Local types version ${localVersion} does not match server version ${remoteVersion}`, + }) + ); + }) +).pipe( + Command.withDescription( + "Check whether the locally generated types are in sync with the server schema." + ) +); diff --git a/apps/cli/src/cli/commands/types-generate.ts b/apps/cli/src/cli/commands/types-generate.ts new file mode 100644 index 000000000..26d81c31b --- /dev/null +++ b/apps/cli/src/cli/commands/types-generate.ts @@ -0,0 +1,141 @@ +import { Command, Flag } from "effect/unstable/cli"; +import { Console, Effect, Path, Schedule } from "effect"; + +import { resolveTypesOutput } from "../../domain/schema/voidhash-config"; +import { Auth } from "../../domain/services/auth"; +import { Codegen } from "../../domain/services/codegen"; +import { SchemaService } from "../../domain/services/schema"; +import { SourceCode } from "../../domain/services/source-code"; +import { userError } from "../../utils/error-formatter"; +import { debugOption } from "../shared-options"; + +/** + * `voidhash types generate [--watch]` + * + * Fetches the schema from the server and emits `voidhash.gen.d.ts` (path + * configurable via `voidhash.config.ts#typesOutput`). With `--watch`, polls + * `GET /schema/version` every `pollIntervalMs` (default 5s) and regenerates + * on hash change. + */ +export const typesGenerateCommand = Command.make( + "generate", + { + debug: debugOption, + pollIntervalMs: Flag.integer("poll-interval-ms").pipe( + Flag.withDescription( + "Polling interval for --watch mode, in milliseconds" + ), + Flag.withDefault(5000) + ), + watch: Flag.boolean("watch").pipe( + Flag.withDescription( + "Watch the server schema and regenerate types when it changes" + ), + Flag.withDefault(false) + ), + }, + ({ pollIntervalMs, watch }) => + Effect.gen(function* typesGenerateCommand() { + const auth = yield* Auth; + const sourceCode = yield* SourceCode; + const schemaService = yield* SchemaService; + const codegen = yield* Codegen; + const pathService = yield* Path.Path; + + yield* auth.getSignedInSession.pipe( + Effect.catchTag("NoSignedInUserError", () => + Effect.fail( + userError( + "You must be logged in to generate types. Run 'voidhash auth login' first." + ) + ) + ) + ); + + const config = yield* sourceCode.loadVoidhashConfig().pipe( + Effect.catchTag("VoidhashConfigNotFoundError", () => + Effect.fail( + userError( + "voidhash.config.ts not found. Run 'voidhash init' to create one." + ) + ) + ) + ); + + const typesOutput = resolveTypesOutput(config); + const outPath = pathService.resolve(typesOutput); + + const regenerate = Effect.gen(function* regenerate() { + yield* Console.log("Fetching remote schema..."); + const remoteSchema = yield* schemaService.fetchRemoteSchema().pipe( + Effect.catchTag("RemoteSchemaFetchError", (e) => + Effect.fail( + userError(`Failed to fetch remote schema: ${String(e.cause)}`) + ) + ) + ); + + const version = yield* codegen.generateTypesDeclarationFile( + outPath, + remoteSchema + ); + + yield* Console.log( + `✓ Types written to ${typesOutput} (version ${version.slice( + 0, + 19 + )}...)` + ); + return version; + }); + + const initialVersion = yield* regenerate; + + if (!watch) { + return; + } + + yield* Console.log( + `\nWatching for schema changes (poll every ${pollIntervalMs}ms). Press Ctrl+C to stop.` + ); + + // Keep the last known version in a closure-shared ref so the poll loop + // can compare and skip work when the schema hasn't changed. + let lastVersion = initialVersion; + + const pollOnce = Effect.gen(function* pollOnce() { + const latest = yield* schemaService.fetchSchemaVersion().pipe( + Effect.catch((e) => + Effect.logWarning( + `[voidhash types --watch] Skipping poll due to error: ${String(e)}` + ).pipe(Effect.as(null)) + ) + ); + + if (latest === null || latest === lastVersion) { + return; + } + + yield* Console.log("Schema changed on server — regenerating types..."); + const next = yield* regenerate.pipe( + Effect.catch((e) => + Effect.logWarning( + `[voidhash types --watch] Regeneration failed: ${String(e)}` + ).pipe(Effect.as(null)) + ) + ); + if (next !== null) { + lastVersion = next; + } + }); + + yield* Effect.repeat( + pollOnce, + Schedule.spaced(`${pollIntervalMs} millis`) + ); + }) +).pipe( + Command.withDescription( + "Generate the voidhash.gen.d.ts declaration file from the server schema." + ) +); diff --git a/apps/cli/src/cli/commands/types.ts b/apps/cli/src/cli/commands/types.ts new file mode 100644 index 000000000..7aefd2b6e --- /dev/null +++ b/apps/cli/src/cli/commands/types.ts @@ -0,0 +1,17 @@ +import { Command } from "effect/unstable/cli"; +import { Effect } from "effect"; + +import { debugOption } from "../shared-options"; +import { typesCheckCommand } from "./types-check"; +import { typesGenerateCommand } from "./types-generate"; + +export const typesCommand = Command.make( + "types", + { debug: debugOption }, + () => Effect.gen(function* typesCommand() {}) +).pipe( + Command.withDescription( + "Generate and validate the Voidhash TypeScript declaration file." + ), + Command.withSubcommands([typesGenerateCommand, typesCheckCommand]) +); diff --git a/apps/cli/src/cli/index.ts b/apps/cli/src/cli/index.ts index 129ca0390..e475791d2 100644 --- a/apps/cli/src/cli/index.ts +++ b/apps/cli/src/cli/index.ts @@ -16,14 +16,14 @@ import { import { authCommand } from "./commands/auth"; import { configCommand } from "./commands/config"; import { initCommand } from "./commands/init"; -import { schemaCommand } from "./commands/schema"; +import { typesCommand } from "./commands/types"; const command = Command.make("voidhash").pipe( Command.withDescription("Voidhash CLI application."), Command.withSubcommands([ initCommand, authCommand, - schemaCommand, + typesCommand, configCommand, ]) ); diff --git a/apps/cli/src/domain/errors/schema.ts b/apps/cli/src/domain/errors/schema.ts index eac5697e6..f5e4dc870 100644 --- a/apps/cli/src/domain/errors/schema.ts +++ b/apps/cli/src/domain/errors/schema.ts @@ -1,42 +1,11 @@ import { Data } from "effect"; -export class LocalSchemaNotFoundError extends Data.TaggedError( - "LocalSchemaNotFoundError" -)<{ - path: string; -}> {} - -export class LocalSchemaParseError extends Data.TaggedError( - "LocalSchemaParseError" -)<{ - message: string; -}> {} - export class RemoteSchemaFetchError extends Data.TaggedError( "RemoteSchemaFetchError" )<{ cause: unknown; }> {} -export class MissingProviderConfigurationError extends Data.TaggedError( - "MissingProviderConfigurationError" -)<{ - providers: string[]; -}> {} - -export class SchemaValidationError extends Data.TaggedError( - "SchemaValidationError" -)<{ - issues: string[]; -}> {} - -export class ChangeDeploymentError extends Data.TaggedError( - "ChangeDeploymentError" -)<{ - cause: unknown; - change: string; -}> {} - export class SchemaCheckFailedError extends Data.TaggedError( "SchemaCheckFailedError" )<{ diff --git a/apps/cli/src/domain/schema/voidhash-config.ts b/apps/cli/src/domain/schema/voidhash-config.ts index b11bab36f..901507cd2 100644 --- a/apps/cli/src/domain/schema/voidhash-config.ts +++ b/apps/cli/src/domain/schema/voidhash-config.ts @@ -1,7 +1,26 @@ import { Schema } from "effect"; +/** + * Default output path for the generated `.d.ts` when `typesOutput` is omitted + * from `voidhash.config.ts`. + */ +export const DEFAULT_TYPES_OUTPUT = "voidhash.gen.d.ts"; + export const VoidhashConfigSchema = Schema.Struct({ project: Schema.String, - schema: Schema.String, team: Schema.String, + /** + * Output path for the generated `.d.ts` declaration file. Optional — + * defaults to `voidhash.gen.d.ts` at the project root. + */ + typesOutput: Schema.optional(Schema.String), }); + +/** + * Resolve `typesOutput` from a loaded config, applying the default. + */ +export function resolveTypesOutput( + config: typeof VoidhashConfigSchema.Type +): string { + return config.typesOutput ?? DEFAULT_TYPES_OUTPUT; +} diff --git a/apps/cli/src/domain/services/codegen.ts b/apps/cli/src/domain/services/codegen.ts index 5c052f6f8..0db126d99 100644 --- a/apps/cli/src/domain/services/codegen.ts +++ b/apps/cli/src/domain/services/codegen.ts @@ -1,135 +1,62 @@ import { Effect, FileSystem, Layer, ServiceMap } from "effect"; +import { + VOIDHASH_FETCHED_AT_COMMENT_PREFIX, + VOIDHASH_VERSION_COMMENT_PREFIX, + computeSchemaVersionFromNormalized, + parseVersionFromDeclaration, +} from "../../utils/schema/version"; import type { Writable } from "../../utils/types"; -import type { NormalizedSchema, ProviderId } from "../schema/normalized-schema"; +import type { NormalizedSchema } from "../schema/normalized-schema"; import type { VoidhashConfigSchema } from "../schema/voidhash-config"; -/** - * Convert slug to camelCase variable name - * e.g., "all-access" -> "allAccess", "monthly_sub" -> "monthlySub" - */ -function slugToCamelCase(slug: string): string { - return slug - .split(/[-_]/) - .map((part, i) => - i === 0 - ? part.toLowerCase() - : part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() - ) - .join(""); -} - -function toTsStringLiteral(value: string): string { - return JSON.stringify(value); +function toUnionType(slugs: string[]): string { + if (slugs.length === 0) { + return "never"; + } + return slugs.map((slug) => JSON.stringify(slug)).join(" | "); } /** - * Generate TypeScript code for a schema file + * Generate the contents of the `voidhash.gen.d.ts` declaration file. + * + * The file: + * - Starts with a `@voidhash:version` header so `voidhash types check` and the + * dev-mode runtime warning can detect staleness without re-downloading the + * full schema. + * - Augments `@voidhash/react-native`'s `VoidhashRegister` interface so all + * typed hook arguments (slug literals) resolve via the registry. */ -function generateSchemaCode(schema: NormalizedSchema): string { - const lines: string[] = []; +export function generateTypesDeclaration( + schema: NormalizedSchema, + options: { fetchedAt?: Date } = {} +): { content: string; version: string } { + const version = computeSchemaVersionFromNormalized(schema); + const fetchedAt = (options.fetchedAt ?? new Date()).toISOString(); - // Collect all provider IDs used - const providerIds = new Set(); - for (const product of schema.products.values()) { - for (const provider of product.providers) { - providerIds.add(provider.providerId); - } - } - // Also include providers from enabledProviders - for (const providerId of schema.enabledProviders) { - providerIds.add(providerId); - } + const productSlugs = [...schema.products.keys()].sort(); + const locationSlugs = [...schema.locations.keys()].sort(); + const perkSlugs = [...schema.perks.keys()].sort(); - // Imports - lines.push( - 'import { schemaConfiguration, unlockablePerk } from "@voidhash/react-native";' - ); + const lines: string[] = []; + lines.push("// voidhash.gen.d.ts — generated by voidhash-cli, do not edit"); + lines.push(`${VOIDHASH_VERSION_COMMENT_PREFIX}${version}`); + lines.push(`${VOIDHASH_FETCHED_AT_COMMENT_PREFIX}${fetchedAt}`); lines.push(""); - - // Schema configuration - lines.push("export const sc = schemaConfiguration({"); - - // Perks - lines.push(" perks: {"); - const sortedPerks = [...schema.perks.values()].sort((a, b) => - a.slug.localeCompare(b.slug) - ); - for (const perk of sortedPerks) { - const varName = slugToCamelCase(perk.slug); - lines.push( - ` ${varName}: unlockablePerk(${toTsStringLiteral(perk.slug)}, { name: ${toTsStringLiteral(perk.name)} }),` - ); - } - lines.push(" },"); - - // Providers - lines.push(" providers: {"); - const sortedProviders = [...providerIds].sort(); - for (const providerId of sortedProviders) { - lines.push(` ${providerId}: true,`); - } - lines.push(" },"); - - lines.push("});"); + lines.push('declare module "@voidhash/react-native" {'); + lines.push(" interface VoidhashRegister {"); + lines.push(" schema: {"); + lines.push(` products: ${toUnionType(productSlugs)};`); + lines.push(` locations: ${toUnionType(locationSlugs)};`); + lines.push(` perks: ${toUnionType(perkSlugs)};`); + lines.push(" };"); + lines.push(" }"); + lines.push("}"); + lines.push(""); + lines.push("export {};"); lines.push(""); - // Paywall locations - const sortedLocations = [...schema.locations.values()].sort((a, b) => - a.slug.localeCompare(b.slug) - ); - for (const location of sortedLocations) { - const varName = slugToCamelCase(location.slug); - lines.push( - `export const ${varName} = sc.location(${toTsStringLiteral(location.slug)}, {` - ); - lines.push(` name: ${toTsStringLiteral(location.name)},`); - if (location.description !== null) { - lines.push(` description: ${toTsStringLiteral(location.description)},`); - } - lines.push("});"); - lines.push(""); - } - - // Products - const sortedProducts = [...schema.products.values()].sort((a, b) => - a.slug.localeCompare(b.slug) - ); - for (const product of sortedProducts) { - const varName = slugToCamelCase(product.slug); - lines.push( - `export const ${varName} = sc.subscription(${toTsStringLiteral(product.slug)}, {` - ); - lines.push(` name: ${toTsStringLiteral(product.name)},`); - - // Perks - lines.push(" perks: {"); - const sortedProductPerks = [...product.perks].sort(); - for (const perkSlug of sortedProductPerks) { - const perkVarName = slugToCamelCase(perkSlug); - lines.push(` ${perkVarName}: true,`); - } - lines.push(" },"); - - // Providers - lines.push(" providers: {"); - const sortedProductProviders = [...product.providers].sort((a, b) => - a.providerId.localeCompare(b.providerId) - ); - for (const provider of sortedProductProviders) { - const configStr = JSON.stringify(provider.configuration, null, 2) - .split("\n") - .map((line, i) => (i === 0 ? line : ` ${line}`)) - .join("\n"); - lines.push(` ${provider.providerId}: ${configStr},`); - } - lines.push(" },"); - - lines.push("});"); - lines.push(""); - } - - return lines.join("\n"); + return { content: lines.join("\n"), version }; } const make = Effect.gen(function* effect() { @@ -140,40 +67,44 @@ const make = Effect.gen(function* effect() { config: Writable ) => Effect.gen(function* generateVoidhashConfigFile() { - const content = `import { defineConfig } from 'voidhash-cli'; - -export default defineConfig({ - team: '${config.team}', - project: '${config.project}', - schema: '${config.schema}' -}); -`; - yield* fileSystem.writeFileString(filePath, content); + const lines = [ + "import { defineConfig } from 'voidhash-cli';", + "", + "export default defineConfig({", + ` team: '${config.team}',`, + ` project: '${config.project}',`, + ]; + if (config.typesOutput !== undefined) { + lines.push(` typesOutput: '${config.typesOutput}',`); + } + lines.push("});", ""); + yield* fileSystem.writeFileString(filePath, lines.join("\n")); }); - const generateClientFile = (filePath: string, publishableKey: string) => - Effect.gen(function* generateClientFile() { - const content = `import { createVoidhashClient } from "@voidhash/react-native"; -import * as schema from "./schema"; - -export const voidhash = createVoidhashClient( - "${publishableKey}", - schema -); -`; + /** + * Generate the `.d.ts` declaration file from the remote schema and write it + * to disk. Returns the version hash that was baked into the header. + */ + const generateTypesDeclarationFile = ( + filePath: string, + schema: NormalizedSchema + ) => + Effect.gen(function* generateTypesDeclarationFile() { + const { content, version } = generateTypesDeclaration(schema); yield* fileSystem.writeFileString(filePath, content); + return version; }); - const generateSchemaFile = (filePath: string, schema: NormalizedSchema) => - Effect.gen(function* generateSchemaFile() { - const content = generateSchemaCode(schema); - yield* fileSystem.writeFileString(filePath, content); + const readDeclarationVersion = (filePath: string) => + Effect.gen(function* readDeclarationVersion() { + const content = yield* fileSystem.readFileString(filePath); + return parseVersionFromDeclaration(content); }); return { - generateClientFile, - generateSchemaFile, + generateTypesDeclarationFile, generateVoidhashConfigFile, + readDeclarationVersion, } as const; }); @@ -182,5 +113,5 @@ type CodegenShape = Effect.Success; export class Codegen extends ServiceMap.Service()( "voidhash-cli/Codegen" ) { - static Default = Layer.effect(Codegen, make) + static Default = Layer.effect(Codegen, make); } diff --git a/apps/cli/src/domain/services/schema.ts b/apps/cli/src/domain/services/schema.ts index 77a7025d9..72a8432ff 100644 --- a/apps/cli/src/domain/services/schema.ts +++ b/apps/cli/src/domain/services/schema.ts @@ -1,244 +1,162 @@ -import type { ChangesetSchema } from "@voidhash/shared"; import { Effect, Layer, Schedule, ServiceMap } from "effect"; import { ApiClient } from "../../utils/api-client"; +import { computeSchemaVersionFromNormalized } from "../../utils/schema/version"; +import { RemoteSchemaFetchError } from "../errors/schema"; import { - buildChangeset, - formatChange, -} from "../../utils/schema/changeset-builder"; -import { computeDiff, summarizeDiff } from "../../utils/schema/diff"; -import { loadLocalSchema } from "../../utils/schema/local-schema-loader"; -import { - ChangeDeploymentError, - RemoteSchemaFetchError, -} from "../errors/schema"; -import { - type ProviderId, - createEmptyNormalizedSchema, + type ProviderId, + createEmptyNormalizedSchema, } from "../schema/normalized-schema"; -// Re-export types for convenience -export type { SchemaDiff } from "../../utils/schema/diff"; -export { formatChange } from "../../utils/schema/changeset-builder"; - -type Change = (typeof ChangesetSchema.Type)["changes"][number]; - const make = Effect.gen(function* effect() { - const apiClient = yield* ApiClient; - - /** - * Fetch the remote schema from the API - */ - const fetchRemoteSchema = () => - Effect.gen(function* fetchRemoteSchema() { - yield* Effect.logDebug("Fetching remote schema from API"); - const schema = createEmptyNormalizedSchema(); - - // 1. Fetch all perks - const remotePerks = yield* apiClient.perksListPerks(); - for (const perk of remotePerks) { - schema.perks.set(perk.slug, { - name: perk.name, - slug: perk.slug, - }); - } - - // 1b. Fetch all active paywall locations - const remoteLocations = - yield* apiClient.paywallLocationsListPaywallLocations(); - for (const location of remoteLocations) { - schema.locations.set(location.slug, { - description: location.description, - name: location.name, - slug: location.slug, - }); - } - - // 2. Fetch all products - const remoteProducts = yield* apiClient.productsListProducts(); - - // 3. Fetch payment provider configurations - const providerConfigs = - yield* apiClient.paymentProviderConfigurationsListPaymentProviderConfigurations(); - for (const config of providerConfigs) { - if ( - config.providerId === "appleAppStore" || - config.providerId === "googlePlay" - ) { - schema.enabledProviders.add(config.providerId); - } - } - - // 4. Fetch all payment provider products - const providerProducts = - yield* apiClient.paymentProviderProductsListPaymentProviderProducts(); - - // Build a map of productId -> provider products - const productProviderMap = new Map< - string, - { providerId: ProviderId; configuration: Record }[] - >(); - for (const pp of providerProducts) { - if ( - pp.providerId !== "appleAppStore" && - pp.providerId !== "googlePlay" - ) { - continue; - } - const existing = productProviderMap.get(pp.productId) || []; - existing.push({ - configuration: pp.configuration as Record, - providerId: pp.providerId, - }); - productProviderMap.set(pp.productId, existing); - } - - // 5. For each product, fetch its perks - - yield* Effect.all( - remoteProducts.map((product) => - Effect.gen(function* () { - const productPerks = yield* apiClient - .productPerksListProductPerksByProductId(product.id) - .pipe( - Effect.retry({ - schedule: Schedule.exponential(1000), - times: 3, - }), - ); - - // Map perkIds to slugs - const perkSlugs: string[] = []; - for (const pp of productPerks) { - const perk = remotePerks.find((p) => p.id === pp.perkId); - if (perk) { - perkSlugs.push(perk.slug); - } - } - - schema.products.set(product.slug, { - name: product.name, - perks: perkSlugs, - providers: productProviderMap.get(product.id) || [], - slug: product.slug, - type: "subscription", // TODO: map from product.type - }); - }), - ), - { - concurrency: 8, - }, - ); - - yield* Effect.logDebug( - `Fetched ${schema.locations.size} locations, ${schema.perks.size} perks, ${schema.products.size} products` - ); - return schema; - }).pipe( - Effect.withSpan("SchemaService.fetchRemoteSchema"), - Effect.catch( - (e) => - Effect.fail(new RemoteSchemaFetchError({ - cause: e, - })), - ), - ); - - /** - * Fetch payment provider configurations - */ - const fetchProviderConfigurations = () => - apiClient.paymentProviderConfigurationsListPaymentProviderConfigurations() - .pipe( - Effect.tap((configs) => - Effect.logDebug( - `Fetched ${configs.length} provider configurations` - ) - ), - Effect.withSpan("SchemaService.fetchProviderConfigurations"), - Effect.catch( - (e) => - Effect.fail(new RemoteSchemaFetchError({ - cause: e, - })), - ), - ); - /** - * Check which providers are missing configurations - */ - const checkProviderConfigurations = ( - localProviders: Set, - remoteConfigs: readonly { providerId: string }[], - ): ProviderId[] => { - const remoteProviderIds = new Set( - remoteConfigs.map((c) => c.providerId), - ); - return [...localProviders].filter( - (p) => !remoteProviderIds.has(p), - ) as ProviderId[]; - }; - - /** - * Deploy a single change to the server - */ - const deployChange = (change: Change) => - Effect.logDebug(`Deploying change: ${formatChange(change)}`).pipe( - Effect.andThen( - apiClient.changesetsDeployChangeset({ - changeset: { changes: [change] }, - }) - ), - Effect.withSpan("SchemaService.deployChange"), - Effect.catch( - (e) => - Effect.fail(new ChangeDeploymentError({ - cause: e, - change: formatChange(change), - })) - ) - ); - - /** - * Deploy an entire changeset to the server - */ - const deployChangeset = (changeset: typeof ChangesetSchema.Type) => - Effect.logDebug( - `Deploying changeset with ${changeset.changes.length} changes` - ).pipe( - Effect.andThen( - apiClient.changesetsDeployChangeset({ - changeset, - }) - ), - Effect.withSpan("SchemaService.deployChangeset"), - Effect.catch( - (e) => - Effect.fail(new ChangeDeploymentError({ - cause: e, - change: "Full changeset deployment", - })) - ) - ); - - return { - buildChangeset, - checkProviderConfigurations, - computeDiff, - deployChange, - deployChangeset, - fetchProviderConfigurations, - fetchRemoteSchema, - loadLocalSchema, - summarizeDiff, - } as const; + const apiClient = yield* ApiClient; + + /** + * Fetch the full schema (perks, locations, products, provider configs) from + * the server. Used by `voidhash types generate` to assemble the data the + * `.d.ts` is generated from. + * + * Once the server ships a consolidated `GET /schema` endpoint this collapses + * to a single call; for now it composes the per-entity endpoints. + */ + const fetchRemoteSchema = () => + Effect.gen(function* fetchRemoteSchema() { + yield* Effect.logDebug("Fetching remote schema from API"); + const schema = createEmptyNormalizedSchema(); + + // Perks + const remotePerks = yield* apiClient.perksListPerks(); + for (const perk of remotePerks) { + schema.perks.set(perk.slug, { + name: perk.name, + slug: perk.slug, + }); + } + + // Paywall locations + const remoteLocations = + yield* apiClient.paywallLocationsListPaywallLocations(); + for (const location of remoteLocations) { + schema.locations.set(location.slug, { + description: location.description, + name: location.name, + slug: location.slug, + }); + } + + // Products + const remoteProducts = yield* apiClient.productsListProducts(); + + // Payment provider configurations + const providerConfigs = + yield* apiClient.paymentProviderConfigurationsListPaymentProviderConfigurations(); + for (const config of providerConfigs) { + if ( + config.providerId === "appleAppStore" || + config.providerId === "googlePlay" + ) { + schema.enabledProviders.add(config.providerId); + } + } + + // Payment provider products + const providerProducts = + yield* apiClient.paymentProviderProductsListPaymentProviderProducts(); + const productProviderMap = new Map< + string, + { providerId: ProviderId; configuration: Record }[] + >(); + for (const pp of providerProducts) { + if ( + pp.providerId !== "appleAppStore" && + pp.providerId !== "googlePlay" + ) { + continue; + } + const existing = productProviderMap.get(pp.productId) || []; + existing.push({ + configuration: pp.configuration as Record, + providerId: pp.providerId, + }); + productProviderMap.set(pp.productId, existing); + } + + // For each product, fetch its perks + yield* Effect.all( + remoteProducts.map((product) => + Effect.gen(function* () { + const productPerks = yield* apiClient + .productPerksListProductPerksByProductId(product.id) + .pipe( + Effect.retry({ + schedule: Schedule.exponential(1000), + times: 3, + }), + ); + + const perkSlugs: string[] = []; + for (const pp of productPerks) { + const perk = remotePerks.find((p) => p.id === pp.perkId); + if (perk) { + perkSlugs.push(perk.slug); + } + } + + schema.products.set(product.slug, { + name: product.name, + perks: perkSlugs, + providers: productProviderMap.get(product.id) || [], + slug: product.slug, + type: "subscription", + }); + }), + ), + { + concurrency: 8, + }, + ); + + yield* Effect.logDebug( + `Fetched ${schema.locations.size} locations, ${schema.perks.size} perks, ${schema.products.size} products` + ); + return schema; + }).pipe( + Effect.withSpan("SchemaService.fetchRemoteSchema"), + Effect.catch( + (e) => + Effect.fail(new RemoteSchemaFetchError({ + cause: e, + })), + ), + ); + + /** + * Fetch just the schema version hash from the server. Used by `types check`, + * the `--watch` poll loop, and the dev-mode runtime warning to detect + * staleness cheaply without re-downloading the full schema. + * + * Today this re-derives the version from the full schema fetch (since the + * consolidated `GET /schema/version` endpoint isn't shipped yet). Once + * that endpoint exists this collapses to a single sub-kilobyte request. + */ + const fetchSchemaVersion = () => + Effect.gen(function* fetchSchemaVersion() { + const schema = yield* fetchRemoteSchema(); + return computeSchemaVersionFromNormalized(schema); + }); + + return { + fetchRemoteSchema, + fetchSchemaVersion, + } as const; }); type SchemaServiceShape = Effect.Success; export class SchemaService extends ServiceMap.Service()( - "voidhash-cli/Schema" + "voidhash-cli/Schema" ) { - static Default = Layer.effect(SchemaService, make).pipe( - Layer.provide(ApiClient.Default) - ) + static Default = Layer.effect(SchemaService, make).pipe( + Layer.provide(ApiClient.Default) + ); } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index d971dd272..2d388959b 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,7 +1,11 @@ interface Config { - schema: string; team: string; project: string; + /** + * Output path for the generated `.d.ts` declaration file. Defaults to + * `voidhash.gen.d.ts` at the project root. + */ + typesOutput?: string; } export const defineConfig = (config: Config) => config; diff --git a/apps/cli/src/utils/schema/changeset-builder.ts b/apps/cli/src/utils/schema/changeset-builder.ts deleted file mode 100644 index 5ae94ab24..000000000 --- a/apps/cli/src/utils/schema/changeset-builder.ts +++ /dev/null @@ -1,257 +0,0 @@ -import type { ChangesetSchema } from "@voidhash/shared"; - -import type { NormalizedProduct } from "../../domain/schema/normalized-schema"; -import type { SchemaDiff } from "./diff"; - -// ======================================================== -// Types -// ======================================================== - -// Re-export the Change type from the changeset -type Change = (typeof ChangesetSchema.Type)["changes"][number]; -type Changeset = typeof ChangesetSchema.Type; - -// ======================================================== -// Changeset Builder -// ======================================================== - -/** - * Build a changeset from a schema diff. - * Only includes creates and updates, NOT deletes. - * - * Order: - * 1. Create locations - * 2. Update locations - * 3. Create perks - * 4. Update perks - * 5. Create products - * 6. Update products - * 7. Create product-perks (for new products) - * 8. Create payment-provider-products (for new products) - * 9. Update payment-provider-products (for updated products) - * 10. Archive locations - */ -export function buildChangeset(diff: SchemaDiff): Changeset { - const changes: Change[] = []; - - // 1. Create locations - for (const location of diff.locations.toCreate) { - changes.push({ - changeType: "create-paywall-location", - key: location.slug, - payload: { - description: location.description, - name: location.name, - slug: location.slug, - }, - }); - } - - // 2. Update locations - for (const { local } of diff.locations.toUpdate) { - changes.push({ - changeType: "update-paywall-location", - key: local.slug, - payload: { - description: local.description, - name: local.name, - slug: local.slug, - }, - }); - } - - // 3. Create perks (first, as products depend on them) - for (const perk of diff.perks.toCreate) { - changes.push({ - changeType: "create-perk", - key: perk.slug, - payload: { name: perk.name, slug: perk.slug }, - }); - } - - // 4. Update perks - for (const { local } of diff.perks.toUpdate) { - changes.push({ - changeType: "update-perk", - key: local.slug, - payload: { name: local.name, slug: local.slug }, - }); - } - - // 5. Create products - for (const product of diff.products.toCreate) { - changes.push({ - changeType: "create-product", - key: product.slug, - payload: { name: product.name, slug: product.slug }, - }); - - // 3a. Create product-perks for this product - for (const perkSlug of product.perks) { - changes.push({ - changeType: "create-product-perk", - key: `${product.slug}:${perkSlug}`, - payload: { perkSlug, productSlug: product.slug }, - }); - } - - // 3b. Create payment-provider-products for this product - for (const provider of product.providers) { - changes.push({ - changeType: "create-payment-provider-product", - key: `${product.slug}:${provider.providerId}`, - payload: { - configuration: provider.configuration as Record, - productSlug: product.slug, - providerId: provider.providerId, - }, - }); - } - } - - // 6. Update products - for (const { local, remote } of diff.products.toUpdate) { - // Check if product name changed - if (local.name !== remote.name) { - changes.push({ - changeType: "update-product", - key: local.slug, - payload: { name: local.name, slug: local.slug }, - }); - } - - // Handle product-perk changes - const localPerkSet = new Set(local.perks); - const remotePerkSet = new Set(remote.perks); - - // Add new perks - for (const perkSlug of local.perks) { - if (!remotePerkSet.has(perkSlug)) { - changes.push({ - changeType: "create-product-perk", - key: `${local.slug}:${perkSlug}`, - payload: { perkSlug, productSlug: local.slug }, - }); - } - } - - // Note: We don't delete product-perks here (no deletes in push) - - // Handle payment-provider-product changes - const localProviderMap = new Map( - local.providers.map((p) => [p.providerId, p]) - ); - const remoteProviderMap = new Map( - remote.providers.map((p) => [p.providerId, p]) - ); - - for (const [providerId, localProvider] of localProviderMap) { - const remoteProvider = remoteProviderMap.get(providerId); - - if (!remoteProvider) { - // New provider configuration - changes.push({ - changeType: "create-payment-provider-product", - key: `${local.slug}:${providerId}`, - payload: { - configuration: localProvider.configuration as Record, - productSlug: local.slug, - providerId, - }, - }); - } else if ( - JSON.stringify(localProvider.configuration) !== - JSON.stringify(remoteProvider.configuration) - ) { - // Updated provider configuration - changes.push({ - changeType: "update-payment-provider-product", - key: `${local.slug}:${providerId}`, - payload: { - configuration: localProvider.configuration as Record, - productSlug: local.slug, - providerId, - }, - }); - } - } - - // Note: We don't delete payment-provider-products here (no deletes in push) - } - - // 10. Archive locations - for (const location of diff.locations.toArchive) { - changes.push({ - changeType: "archive-paywall-location", - key: location.slug, - payload: { slug: location.slug }, - }); - } - - return { changes }; -} - -// ======================================================== -// Change Formatting -// ======================================================== - -export function formatChange(change: Change): string { - switch (change.changeType) { - case "create-paywall-location": - return `+ Create paywall location: ${change.payload.slug} ("${change.payload.name}")`; - case "update-paywall-location": - return `~ Update paywall location: ${change.payload.slug} ("${change.payload.name}")`; - case "archive-paywall-location": - return `- Archive paywall location: ${change.payload.slug}`; - case "create-perk": - return `+ Create perk: ${change.payload.slug} ("${change.payload.name}")`; - case "update-perk": - return `~ Update perk: ${change.payload.slug} ("${change.payload.name}")`; - case "create-product": - return `+ Create product: ${change.payload.slug} ("${change.payload.name}")`; - case "update-product": - return `~ Update product: ${change.payload.slug} ("${change.payload.name}")`; - case "create-product-perk": - return `+ Link perk "${change.payload.perkSlug}" to product "${change.payload.productSlug}"`; - case "delete-product-perk": - return `- Unlink perk "${change.payload.perkSlug}" from product "${change.payload.productSlug}"`; - case "create-payment-provider-product": - return `+ Configure ${change.payload.providerId} for product "${change.payload.productSlug}"`; - case "update-payment-provider-product": - return `~ Update ${change.payload.providerId} config for product "${change.payload.productSlug}"`; - case "delete-payment-provider-product": - return `- Remove ${change.payload.providerId} config from product "${change.payload.productSlug}"`; - case "delete-perk": - return `- Delete perk: ${change.payload.slug}`; - case "delete-product": - return `- Delete product: ${change.payload.slug}`; - default: - return `? Unknown change type`; - } -} - -export function formatChangeShort(change: Change): string { - switch (change.changeType) { - case "create-paywall-location": - case "update-paywall-location": - case "archive-paywall-location": - return `PaywallLocation: ${change.payload.slug}`; - case "create-perk": - case "update-perk": - case "delete-perk": - return `Perk: ${change.payload.slug}`; - case "create-product": - case "update-product": - case "delete-product": - return `Product: ${change.payload.slug}`; - case "create-product-perk": - case "delete-product-perk": - return `ProductPerk: ${change.payload.productSlug}:${change.payload.perkSlug}`; - case "create-payment-provider-product": - case "update-payment-provider-product": - case "delete-payment-provider-product": - return `ProviderProduct: ${change.payload.productSlug}:${change.payload.providerId}`; - default: - return "Unknown"; - } -} diff --git a/apps/cli/src/utils/schema/diff.ts b/apps/cli/src/utils/schema/diff.ts deleted file mode 100644 index 4be3e4a55..000000000 --- a/apps/cli/src/utils/schema/diff.ts +++ /dev/null @@ -1,222 +0,0 @@ -import type { - NormalizedPaywallLocation, - NormalizedPerk, - NormalizedProduct, - NormalizedSchema, -} from "../../domain/schema/normalized-schema"; - -// ======================================================== -// Types -// ======================================================== - -export interface PerkDiff { - remoteOnly: NormalizedPerk[]; - toCreate: NormalizedPerk[]; - toUpdate: { local: NormalizedPerk; remote: NormalizedPerk }[]; -} - -export interface ProductDiff { - remoteOnly: NormalizedProduct[]; - toCreate: NormalizedProduct[]; - toUpdate: { local: NormalizedProduct; remote: NormalizedProduct }[]; -} - -export interface PaywallLocationDiff { - remoteOnly: NormalizedPaywallLocation[]; - toArchive: NormalizedPaywallLocation[]; - toCreate: NormalizedPaywallLocation[]; - toUpdate: { local: NormalizedPaywallLocation; remote: NormalizedPaywallLocation }[]; -} - -export interface SchemaDiff { - locations: PaywallLocationDiff; - perks: PerkDiff; - products: ProductDiff; -} - -// ======================================================== -// Comparison Helpers -// ======================================================== - -function perksEqual(a: NormalizedPerk, b: NormalizedPerk): boolean { - return a.slug === b.slug && a.name === b.name; -} - -function arraysEqual(a: readonly T[], b: readonly T[]): boolean { - if (a.length !== b.length) return false; - const sortedA = [...a].sort(); - const sortedB = [...b].sort(); - return sortedA.every((val, i) => val === sortedB[i]); -} - -function providerConfigsEqual( - a: readonly { providerId: string; configuration: Readonly> }[], - b: readonly { providerId: string; configuration: Readonly> }[] -): boolean { - if (a.length !== b.length) return false; - - const sortedA = [...a].sort((x, y) => x.providerId.localeCompare(y.providerId)); - const sortedB = [...b].sort((x, y) => x.providerId.localeCompare(y.providerId)); - - for (let i = 0; i < sortedA.length; i++) { - const provA = sortedA[i]; - const provB = sortedB[i]; - if (!provA || !provB) return false; - if (provA.providerId !== provB.providerId) return false; - // Deep compare configurations - if (JSON.stringify(provA.configuration) !== JSON.stringify(provB.configuration)) { - return false; - } - } - - return true; -} - -function productsEqual(a: NormalizedProduct, b: NormalizedProduct): boolean { - return ( - a.slug === b.slug && - a.name === b.name && - a.type === b.type && - arraysEqual(a.perks, b.perks) && - providerConfigsEqual(a.providers, b.providers) - ); -} - -function locationsEqual( - a: NormalizedPaywallLocation, - b: NormalizedPaywallLocation -): boolean { - return ( - a.slug === b.slug && - a.name === b.name && - (a.description ?? null) === (b.description ?? null) - ); -} - -// ======================================================== -// Diff Algorithm -// ======================================================== - -/** - * Compute the difference between local and remote schemas. - * - * - toCreate: exists in local but not in remote - * - toUpdate: exists in both but values differ - * - remoteOnly: exists in remote but not in local (for info, we don't delete) - */ -export function computeDiff( - local: NormalizedSchema, - remote: NormalizedSchema -): SchemaDiff { - const diff: SchemaDiff = { - locations: { remoteOnly: [], toArchive: [], toCreate: [], toUpdate: [] }, - perks: { remoteOnly: [], toCreate: [], toUpdate: [] }, - products: { remoteOnly: [], toCreate: [], toUpdate: [] }, - }; - - // Compare paywall locations - for (const [slug, localLocation] of local.locations) { - const remoteLocation = remote.locations.get(slug); - if (!remoteLocation) { - diff.locations.toCreate.push(localLocation); - } else if (!locationsEqual(localLocation, remoteLocation)) { - diff.locations.toUpdate.push({ local: localLocation, remote: remoteLocation }); - } - } - - // Find remote-only locations (active on server, absent locally) - for (const [slug, remoteLocation] of remote.locations) { - if (!local.locations.has(slug)) { - diff.locations.remoteOnly.push(remoteLocation); - diff.locations.toArchive.push(remoteLocation); - } - } - - // Compare perks - for (const [slug, localPerk] of local.perks) { - const remotePerk = remote.perks.get(slug); - if (!remotePerk) { - diff.perks.toCreate.push(localPerk); - } else if (!perksEqual(localPerk, remotePerk)) { - diff.perks.toUpdate.push({ local: localPerk, remote: remotePerk }); - } - } - - // Find remote-only perks - for (const [slug, remotePerk] of remote.perks) { - if (!local.perks.has(slug)) { - diff.perks.remoteOnly.push(remotePerk); - } - } - - // Compare products - for (const [slug, localProduct] of local.products) { - const remoteProduct = remote.products.get(slug); - if (!remoteProduct) { - diff.products.toCreate.push(localProduct); - } else if (!productsEqual(localProduct, remoteProduct)) { - diff.products.toUpdate.push({ local: localProduct, remote: remoteProduct }); - } - } - - // Find remote-only products - for (const [slug, remoteProduct] of remote.products) { - if (!local.products.has(slug)) { - diff.products.remoteOnly.push(remoteProduct); - } - } - - return diff; -} - -// ======================================================== -// Summary Helpers -// ======================================================== - -export interface DiffSummary { - locationsToArchive: number; - locationsToCreate: number; - locationsToUpdate: number; - locationsRemoteOnly: number; - perksToCreate: number; - perksToUpdate: number; - perksRemoteOnly: number; - productsToCreate: number; - productsToUpdate: number; - productsRemoteOnly: number; - totalChanges: number; -} - -export function summarizeDiff(diff: SchemaDiff): DiffSummary { - const locationsToCreate = diff.locations.toCreate.length; - const locationsToUpdate = diff.locations.toUpdate.length; - const locationsToArchive = diff.locations.toArchive.length; - const locationsRemoteOnly = diff.locations.remoteOnly.length; - const perksToCreate = diff.perks.toCreate.length; - const perksToUpdate = diff.perks.toUpdate.length; - const perksRemoteOnly = diff.perks.remoteOnly.length; - const productsToCreate = diff.products.toCreate.length; - const productsToUpdate = diff.products.toUpdate.length; - const productsRemoteOnly = diff.products.remoteOnly.length; - - return { - locationsRemoteOnly, - locationsToArchive, - locationsToCreate, - locationsToUpdate, - perksRemoteOnly, - perksToCreate, - perksToUpdate, - productsRemoteOnly, - productsToCreate, - productsToUpdate, - totalChanges: - locationsToCreate + - locationsToUpdate + - locationsToArchive + - perksToCreate + - perksToUpdate + - productsToCreate + - productsToUpdate, - }; -} diff --git a/apps/cli/src/utils/schema/local-schema-loader.ts b/apps/cli/src/utils/schema/local-schema-loader.ts deleted file mode 100644 index da5c6a803..000000000 --- a/apps/cli/src/utils/schema/local-schema-loader.ts +++ /dev/null @@ -1,339 +0,0 @@ -import Module from "node:module"; -import { Effect, FileSystem, Path } from "effect"; - -import { - LocalSchemaNotFoundError, - LocalSchemaParseError, -} from "../../domain/errors/schema"; -import { - type NormalizedSchema, - type ProviderId, - createEmptyNormalizedSchema, -} from "../../domain/schema/normalized-schema"; -import { safeRegister } from "../js-loading/js-file-loading"; - -// Extended Module type to include internal _resolveFilename method -interface ModuleInternal { - _resolveFilename: ( - request: string, - parent: unknown, - isMain: boolean, - options: unknown - ) => string; -} - -/** - * Sets up module resolution alias to redirect @voidhash/react-native imports - * to the schema-only exports. This prevents the CLI from loading the full - * React Native package which requires native bindings. - * - * @returns A cleanup function to restore original module resolution - */ -function setupSchemaModuleAlias(): () => void { - const moduleInternal = Module as unknown as ModuleInternal; - const originalResolve = moduleInternal._resolveFilename; - - moduleInternal._resolveFilename = function ( - request: string, - parent: unknown, - isMain: boolean, - options: unknown - ) { - // Redirect @voidhash/react-native to its schema-only exports - if (request === "@voidhash/react-native") { - // Resolve the schema subpath using the original resolver - return originalResolve.call( - this, - "@voidhash/react-native/schema", - parent, - isMain, - options - ); - } - return originalResolve.call(this, request, parent, isMain, options); - }; - - return () => { - moduleInternal._resolveFilename = originalResolve; - }; -} - -/** - * Symbol used to identify schema entity types at runtime. - * Must match the symbol used in @voidhash/react-native schema definitions. - */ -export const SCHEMA_KIND = Symbol.for("voidhash.schema.kind"); - -export const SchemaKind = { - Perk: "perk", - PaywallLocation: "paywall-location", - Product: "product", - SchemaConfiguration: "schema-configuration", -} as const; - -/** - * Check if a value is a SchemaConfiguration object using the schema kind symbol - */ -export function isSchemaConfiguration( - value: unknown -): value is { - perks: Record; - providers: Record; - location: unknown; - subscription: unknown; -} { - return ( - value !== null && - typeof value === "object" && - (value as Record)[SCHEMA_KIND] === - SchemaKind.SchemaConfiguration - ); -} - -/** - * Check if a value is a PaywallLocationDefinition instance using the schema kind symbol - */ -export function isPaywallLocationDefinition( - value: unknown -): value is { - description: string | null; - name: string; - slug: string; -} { - return ( - value !== null && - typeof value === "object" && - (value as Record)[SCHEMA_KIND] === - SchemaKind.PaywallLocation - ); -} - -/** - * Check if a value is a PerkDefinition instance using the schema kind symbol - */ -export function isPerkDefinition( - value: unknown -): value is { slug: string; name: string } { - return ( - value !== null && - typeof value === "object" && - (value as Record)[SCHEMA_KIND] === SchemaKind.Perk - ); -} - -/** - * Check if a value is a ProductDefinition instance using the schema kind symbol - */ -export function isProductDefinition( - value: unknown -): value is { - type: string; - slug: string; - properties: { name: string }; - configuration: { - perks?: Record; - providers?: Record; - }; -} { - return ( - value !== null && - typeof value === "object" && - (value as Record)[SCHEMA_KIND] === SchemaKind.Product - ); -} - -/** - * Extract perk slugs from a product's configuration - */ -export function extractPerkSlugs( - perksConfig: Record | undefined, - allPerks: Map -): string[] { - if (!perksConfig) return []; - - const perkSlugs: string[] = []; - - for (const [key, value] of Object.entries(perksConfig)) { - // Skip the metadata key - if (key === "_") continue; - - // If value is true, this perk is enabled - if (value === true) { - // Find the perk by variable name (key) in our perks map - // We need to match by the variable name, not slug - for (const [slug, perk] of allPerks) { - // The key in the config should match the camelCase version of the slug - const expectedKey = slugToCamelCase(slug); - if (expectedKey === key) { - perkSlugs.push(slug); - break; - } - } - } - } - - return perkSlugs; -} - -/** - * Extract provider configurations from a product - */ -export function extractProviderConfigs( - providersConfig: Record | undefined -): { providerId: ProviderId; configuration: Record }[] { - if (!providersConfig) return []; - - const providers: { - providerId: ProviderId; - configuration: Record; - }[] = []; - - for (const [providerId, config] of Object.entries(providersConfig)) { - // Skip the metadata key - if (providerId === "_") continue; - - // Only include appleAppStore and googlePlay - if (providerId === "appleAppStore" || providerId === "googlePlay") { - if (config && typeof config === "object") { - providers.push({ - configuration: config as Record, - providerId, - }); - } - } - } - - return providers; -} - -/** - * Convert slug to camelCase variable name - * e.g., "all-access" -> "allAccess", "monthly_sub" -> "monthlySub" - */ -export function slugToCamelCase(slug: string): string { - return slug - .split(/[-_]/) - .map((part, i) => - i === 0 ? part.toLowerCase() : part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() - ) - .join(""); -} - -/** - * Load and parse a local schema file into a NormalizedSchema - */ -export const loadLocalSchema = (schemaPath: string) => - Effect.gen(function* loadLocalSchema() { - const fs = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - - // Resolve the absolute path - const absolutePath = pathService.resolve(schemaPath); - - // Check if file exists - const exists = yield* fs.exists(absolutePath); - if (!exists) { - return yield* Effect.fail( - new LocalSchemaNotFoundError({ path: schemaPath }) - ); - } - - // Register esbuild for TypeScript - const { unregister } = yield* safeRegister().pipe( - Effect.catchTag("FailedToLoadJsFileError", (e) => - Effect.fail( - new LocalSchemaParseError({ - message: `Failed to register TypeScript loader: ${e.message}`, - }) - ) - ) - ); - - // Set up module alias to redirect @voidhash/react-native to schema-only exports - // This prevents loading React Native native bindings in the CLI - const removeAlias = setupSchemaModuleAlias(); - - // Load the schema module - let schemaModule: Record; - try { - // Clear require cache to ensure fresh load - delete require.cache[require.resolve(absolutePath)]; - schemaModule = require(absolutePath); - } catch (e) { - removeAlias(); - unregister(); - return yield* Effect.fail( - new LocalSchemaParseError({ - message: `Failed to load schema file: ${e instanceof Error ? e.message : String(e)}`, - }) - ); - } - - removeAlias(); - unregister(); - - // Create the normalized schema - const schema = createEmptyNormalizedSchema(); - - // First pass: extract perks and providers from SchemaConfiguration - for (const [, value] of Object.entries(schemaModule)) { - if (isSchemaConfiguration(value)) { - // Extract perks - for (const [, perkDef] of Object.entries(value.perks)) { - if (isPerkDefinition(perkDef)) { - schema.perks.set(perkDef.slug, { - name: perkDef.name, - slug: perkDef.slug, - }); - } - } - - // Extract enabled providers - for (const [providerId, enabled] of Object.entries(value.providers)) { - if ( - enabled === true && - (providerId === "appleAppStore" || providerId === "googlePlay") - ) { - schema.enabledProviders.add(providerId); - } - } - } - } - - // Second pass: extract products - for (const [, value] of Object.entries(schemaModule)) { - if (isProductDefinition(value)) { - const perkSlugs = extractPerkSlugs( - value.configuration.perks, - schema.perks - ); - const providers = extractProviderConfigs(value.configuration.providers); - - // Add providers from this product to enabled providers - for (const provider of providers) { - schema.enabledProviders.add(provider.providerId); - } - - schema.products.set(value.slug, { - name: value.properties.name, - perks: perkSlugs, - providers, - slug: value.slug, - type: "subscription", // For now, only subscription is supported - }); - } - } - - // Third pass: extract paywall locations - for (const [, value] of Object.entries(schemaModule)) { - if (isPaywallLocationDefinition(value)) { - schema.locations.set(value.slug, { - description: value.description, - name: value.name, - slug: value.slug, - }); - } - } - - return schema; - }); diff --git a/apps/cli/src/utils/schema/version.ts b/apps/cli/src/utils/schema/version.ts new file mode 100644 index 000000000..bfecd1993 --- /dev/null +++ b/apps/cli/src/utils/schema/version.ts @@ -0,0 +1,66 @@ +import { createHash } from "node:crypto"; + +import type { NormalizedSchema } from "../../domain/schema/normalized-schema"; + +/** + * Compute a deterministic sha256 hash of a normalized schema. The result is + * what `voidhash types generate` bakes into the `.d.ts` header and what + * `voidhash types check` compares against the server. + * + * The hash is order-independent (slugs sorted) so unrelated reorderings + * don't churn the version. + */ +export function computeSchemaVersionFromNormalized( + schema: NormalizedSchema +): string { + const sortedProducts = [...schema.products.values()] + .map((product) => ({ + name: product.name, + perks: [...product.perks].sort(), + providers: [...product.providers] + .map((provider) => ({ + providerId: provider.providerId, + configuration: provider.configuration, + })) + .sort((a, b) => a.providerId.localeCompare(b.providerId)), + slug: product.slug, + type: product.type, + })) + .sort((a, b) => a.slug.localeCompare(b.slug)); + + const sortedLocations = [...schema.locations.values()] + .map((location) => ({ + description: location.description, + name: location.name, + slug: location.slug, + })) + .sort((a, b) => a.slug.localeCompare(b.slug)); + + const sortedPerks = [...schema.perks.values()] + .map((perk) => ({ name: perk.name, slug: perk.slug })) + .sort((a, b) => a.slug.localeCompare(b.slug)); + + const payload = JSON.stringify({ + locations: sortedLocations, + perks: sortedPerks, + products: sortedProducts, + }); + + return `sha256:${createHash("sha256").update(payload).digest("hex")}`; +} + +export const VOIDHASH_VERSION_COMMENT_PREFIX = "// @voidhash:version "; +export const VOIDHASH_FETCHED_AT_COMMENT_PREFIX = "// @voidhash:fetched-at "; + +/** + * Extract the version header from a generated `.d.ts`, if present. + * Returns null when the header is missing (e.g. the file was hand-edited). + */ +export function parseVersionFromDeclaration(content: string): string | null { + for (const line of content.split(/\r?\n/)) { + if (line.startsWith(VOIDHASH_VERSION_COMMENT_PREFIX)) { + return line.slice(VOIDHASH_VERSION_COMMENT_PREFIX.length).trim(); + } + } + return null; +} diff --git a/apps/cli/tests/fixtures/empty-schema.ts b/apps/cli/tests/fixtures/empty-schema.ts deleted file mode 100644 index e684fc181..000000000 --- a/apps/cli/tests/fixtures/empty-schema.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { schemaConfiguration } from "@voidhash/react-native/schema"; - -export const schema = schemaConfiguration({ - perks: {}, - providers: {}, -}); diff --git a/apps/cli/tests/fixtures/valid-schema.ts b/apps/cli/tests/fixtures/valid-schema.ts deleted file mode 100644 index 33b80a833..000000000 --- a/apps/cli/tests/fixtures/valid-schema.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - schemaConfiguration, - unlockablePerk, -} from "@voidhash/react-native/schema"; - -export const schema = schemaConfiguration({ - perks: { - allAccess: unlockablePerk("all-access", { name: "All Access" }), - premiumFeatures: unlockablePerk("premium-features", { - name: "Premium Features", - }), - }, - providers: { - appleAppStore: true, - googlePlay: true, - }, -}); - -export const monthlyPlan = schema.subscription("monthly-plan", { - name: "Monthly Plan", - perks: { allAccess: true }, - providers: { - appleAppStore: { productId: "com.example.monthly" }, - googlePlay: { productId: "monthly_subscription" }, - }, -}); - -export const yearlyPlan = schema.subscription("yearly-plan", { - name: "Yearly Plan", - perks: { allAccess: true, premiumFeatures: true }, - providers: { - appleAppStore: { productId: "com.example.yearly" }, - googlePlay: { productId: "yearly_subscription", basePlanId: "base-yearly" }, - }, -}); - -export const onboardingUpsell = schema.location("onboarding-upsell", { - description: "Shown after onboarding", - name: "Onboarding Upsell", -}); - -export const settingsPaywall = schema.location("settings-paywall", { - name: "Settings Paywall", -}); diff --git a/apps/cli/tests/utils/schema/changeset-builder.test.ts b/apps/cli/tests/utils/schema/changeset-builder.test.ts deleted file mode 100644 index a2165ebad..000000000 --- a/apps/cli/tests/utils/schema/changeset-builder.test.ts +++ /dev/null @@ -1,991 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - buildChangeset, - formatChange, - formatChangeShort, -} from "../../../src/utils/schema/changeset-builder"; -import { computeDiff } from "../../../src/utils/schema/diff"; -import { - createProviderConfig, - createTestPaywallLocation, - createTestPerk, - createTestProduct, - createTestSchema, -} from "../../helpers/schema-factories"; - -describe("buildChangeset", () => { - describe("empty diff", () => { - it("returns empty changeset for empty diff", () => { - const local = createTestSchema({}); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - expect(changeset.changes).toHaveLength(0); - }); - }); - - describe("paywall location changes", () => { - it("generates create-paywall-location for new locations", () => { - const local = createTestSchema({ - locations: [ - createTestPaywallLocation({ - description: "Shown on launch", - name: "Onboarding", - slug: "onboarding", - }), - ], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - expect(changeset.changes).toHaveLength(1); - expect(changeset.changes[0]).toEqual({ - changeType: "create-paywall-location", - key: "onboarding", - payload: { - description: "Shown on launch", - name: "Onboarding", - slug: "onboarding", - }, - }); - }); - - it("generates update-paywall-location for updated locations", () => { - const local = createTestSchema({ - locations: [ - createTestPaywallLocation({ - description: null, - name: "Onboarding New", - slug: "onboarding", - }), - ], - }); - const remote = createTestSchema({ - locations: [ - createTestPaywallLocation({ - description: "Shown on launch", - name: "Onboarding Old", - slug: "onboarding", - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - expect(changeset.changes).toHaveLength(1); - expect(changeset.changes[0]).toEqual({ - changeType: "update-paywall-location", - key: "onboarding", - payload: { - description: null, - name: "Onboarding New", - slug: "onboarding", - }, - }); - }); - - it("generates archive-paywall-location for remote-only locations", () => { - const local = createTestSchema({}); - const remote = createTestSchema({ - locations: [ - createTestPaywallLocation({ - name: "Onboarding", - slug: "onboarding", - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - expect(changeset.changes).toHaveLength(1); - expect(changeset.changes[0]).toEqual({ - changeType: "archive-paywall-location", - key: "onboarding", - payload: { slug: "onboarding" }, - }); - }); - }); - - describe("perk changes", () => { - it("generates create-perk for new perks", () => { - const local = createTestSchema({ - perks: [createTestPerk({ slug: "new-perk", name: "New Perk" })], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - expect(changeset.changes).toHaveLength(1); - expect(changeset.changes[0]).toEqual({ - changeType: "create-perk", - key: "new-perk", - payload: { name: "New Perk", slug: "new-perk" }, - }); - }); - - it("generates update-perk for updated perks", () => { - const local = createTestSchema({ - perks: [createTestPerk({ slug: "perk-1", name: "Updated Name" })], - }); - const remote = createTestSchema({ - perks: [createTestPerk({ slug: "perk-1", name: "Original Name" })], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - expect(changeset.changes).toHaveLength(1); - expect(changeset.changes[0]).toEqual({ - changeType: "update-perk", - key: "perk-1", - payload: { name: "Updated Name", slug: "perk-1" }, - }); - }); - - it("generates multiple perk changes in correct order", () => { - const local = createTestSchema({ - perks: [ - createTestPerk({ slug: "new-perk", name: "New" }), - createTestPerk({ slug: "updated-perk", name: "Updated Local" }), - ], - }); - const remote = createTestSchema({ - perks: [createTestPerk({ slug: "updated-perk", name: "Updated Remote" })], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - expect(changeset.changes).toHaveLength(2); - // Creates should come before updates - expect(changeset.changes[0]?.changeType).toBe("create-perk"); - expect(changeset.changes[1]?.changeType).toBe("update-perk"); - }); - }); - - describe("product creation", () => { - it("generates create-product for new products", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "new-product", - name: "New Product", - perks: [], - providers: [], - }), - ], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const createProductChange = changeset.changes.find( - (c) => c.changeType === "create-product", - ); - expect(createProductChange).toEqual({ - changeType: "create-product", - key: "new-product", - payload: { name: "New Product", slug: "new-product" }, - }); - }); - - it("generates create-product-perk for each perk on new product", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "new-product", - name: "New Product", - perks: ["perk-1", "perk-2"], - providers: [], - }), - ], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const productPerkChanges = changeset.changes.filter( - (c) => c.changeType === "create-product-perk", - ); - expect(productPerkChanges).toHaveLength(2); - expect(productPerkChanges).toContainEqual({ - changeType: "create-product-perk", - key: "new-product:perk-1", - payload: { perkSlug: "perk-1", productSlug: "new-product" }, - }); - expect(productPerkChanges).toContainEqual({ - changeType: "create-product-perk", - key: "new-product:perk-2", - payload: { perkSlug: "perk-2", productSlug: "new-product" }, - }); - }); - - it("generates create-payment-provider-product for each provider on new product", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "new-product", - name: "New Product", - perks: [], - providers: [ - createProviderConfig("appleAppStore", { - productId: "com.app.monthly", - }), - createProviderConfig("googlePlay", { - productId: "monthly_subscription", - }), - ], - }), - ], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const providerChanges = changeset.changes.filter( - (c) => c.changeType === "create-payment-provider-product", - ); - expect(providerChanges).toHaveLength(2); - expect(providerChanges).toContainEqual({ - changeType: "create-payment-provider-product", - key: "new-product:appleAppStore", - payload: { - configuration: { productId: "com.app.monthly" }, - productSlug: "new-product", - providerId: "appleAppStore", - }, - }); - }); - - it("generates changes in correct order: product, then perks, then providers", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "new-product", - name: "New Product", - perks: ["perk-1"], - providers: [ - createProviderConfig("appleAppStore", { productId: "com.app.1" }), - ], - }), - ], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const changeTypes = changeset.changes.map((c) => c.changeType); - const productIndex = changeTypes.indexOf("create-product"); - const perkIndex = changeTypes.indexOf("create-product-perk"); - const providerIndex = changeTypes.indexOf("create-payment-provider-product"); - - expect(productIndex).toBeLessThan(perkIndex); - expect(perkIndex).toBeLessThan(providerIndex); - }); - }); - - describe("product updates", () => { - it("generates update-product when name changes", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Updated Name", - }), - ], - }); - const remote = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Original Name", - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const updateProductChange = changeset.changes.find( - (c) => c.changeType === "update-product", - ); - expect(updateProductChange).toEqual({ - changeType: "update-product", - key: "product-1", - payload: { name: "Updated Name", slug: "product-1" }, - }); - }); - - it("does not generate update-product when only perks/providers change", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1", "perk-2"], - }), - ], - }); - const remote = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1"], - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const updateProductChange = changeset.changes.find( - (c) => c.changeType === "update-product", - ); - expect(updateProductChange).toBeUndefined(); - }); - - it("generates create-product-perk for newly added perks", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1", "perk-2"], - }), - ], - }); - const remote = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1"], - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const createPerkChanges = changeset.changes.filter( - (c) => c.changeType === "create-product-perk", - ); - expect(createPerkChanges).toHaveLength(1); - expect(createPerkChanges[0]).toEqual({ - changeType: "create-product-perk", - key: "product-1:perk-2", - payload: { perkSlug: "perk-2", productSlug: "product-1" }, - }); - }); - - it("does not generate delete-product-perk for removed perks", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1"], - }), - ], - }); - const remote = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1", "perk-2"], - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const deleteChanges = changeset.changes.filter( - (c) => c.changeType === "delete-product-perk", - ); - expect(deleteChanges).toHaveLength(0); - }); - - it("generates create-payment-provider-product for new provider", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("appleAppStore", { productId: "com.app.1" }), - ], - }), - ], - }); - const remote = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - providers: [], - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const createProviderChanges = changeset.changes.filter( - (c) => c.changeType === "create-payment-provider-product", - ); - expect(createProviderChanges).toHaveLength(1); - expect(createProviderChanges[0]).toEqual({ - changeType: "create-payment-provider-product", - key: "product-1:appleAppStore", - payload: { - configuration: { productId: "com.app.1" }, - productSlug: "product-1", - providerId: "appleAppStore", - }, - }); - }); - - it("generates update-payment-provider-product for changed config", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("appleAppStore", { - productId: "com.app.new", - }), - ], - }), - ], - }); - const remote = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("appleAppStore", { - productId: "com.app.old", - }), - ], - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const updateProviderChanges = changeset.changes.filter( - (c) => c.changeType === "update-payment-provider-product", - ); - expect(updateProviderChanges).toHaveLength(1); - expect(updateProviderChanges[0]).toEqual({ - changeType: "update-payment-provider-product", - key: "product-1:appleAppStore", - payload: { - configuration: { productId: "com.app.new" }, - productSlug: "product-1", - providerId: "appleAppStore", - }, - }); - }); - - it("does not generate delete for removed providers", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - providers: [], - }), - ], - }); - const remote = createTestSchema({ - products: [ - createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("appleAppStore", { productId: "com.app.1" }), - ], - }), - ], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const deleteChanges = changeset.changes.filter( - (c) => c.changeType === "delete-payment-provider-product", - ); - expect(deleteChanges).toHaveLength(0); - }); - }); - - describe("ordering", () => { - it("orders: create-paywall-location before create-perk", () => { - const local = createTestSchema({ - locations: [ - createTestPaywallLocation({ - name: "Onboarding", - slug: "onboarding", - }), - ], - perks: [createTestPerk({ slug: "new-perk", name: "New" })], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const locationIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-paywall-location", - ); - const perkIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-perk", - ); - - expect(locationIndex).toBeLessThan(perkIndex); - }); - - it("orders: create-perk before update-perk", () => { - const local = createTestSchema({ - perks: [ - createTestPerk({ slug: "new-perk", name: "New" }), - createTestPerk({ slug: "updated-perk", name: "Updated Local" }), - ], - }); - const remote = createTestSchema({ - perks: [createTestPerk({ slug: "updated-perk", name: "Updated Remote" })], - }); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const createIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-perk", - ); - const updateIndex = changeset.changes.findIndex( - (c) => c.changeType === "update-perk", - ); - - expect(createIndex).toBeLessThan(updateIndex); - }); - - it("orders: perk changes before product changes", () => { - const local = createTestSchema({ - perks: [createTestPerk({ slug: "new-perk", name: "New" })], - products: [createTestProduct({ slug: "new-product", name: "New" })], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const perkIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-perk", - ); - const productIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-product", - ); - - expect(perkIndex).toBeLessThan(productIndex); - }); - - it("orders: create-product before product-perk and provider changes", () => { - const local = createTestSchema({ - products: [ - createTestProduct({ - slug: "new-product", - name: "New", - perks: ["perk-1"], - providers: [ - createProviderConfig("appleAppStore", { productId: "com.app.1" }), - ], - }), - ], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const changeset = buildChangeset(diff); - - const productIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-product", - ); - const perkIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-product-perk", - ); - const providerIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-payment-provider-product", - ); - - expect(productIndex).toBeLessThan(perkIndex); - expect(productIndex).toBeLessThan(providerIndex); - }); - - it("orders: archive-paywall-location after creates/updates", () => { - const local = createTestSchema({ - locations: [ - createTestPaywallLocation({ - name: "Onboarding New", - slug: "onboarding", - }), - ], - perks: [createTestPerk({ slug: "new-perk", name: "New" })], - }); - const remote = createTestSchema({ - locations: [ - createTestPaywallLocation({ - name: "Legacy", - slug: "legacy", - }), - createTestPaywallLocation({ - description: "Old", - name: "Onboarding Old", - slug: "onboarding", - }), - ], - }); - const diff = computeDiff(local, remote); - const changeset = buildChangeset(diff); - - const createLocationIndex = changeset.changes.findIndex( - (c) => c.changeType === "create-paywall-location", - ); - const updateLocationIndex = changeset.changes.findIndex( - (c) => c.changeType === "update-paywall-location", - ); - const archiveLocationIndex = changeset.changes.findIndex( - (c) => c.changeType === "archive-paywall-location", - ); - - expect(createLocationIndex).toBe(-1); - expect(updateLocationIndex).toBeGreaterThan(-1); - expect(archiveLocationIndex).toBeGreaterThan(updateLocationIndex); - }); - }); -}); - -describe("formatChange", () => { - it("formats paywall location changes correctly", () => { - expect( - formatChange({ - changeType: "create-paywall-location", - key: "onboarding", - payload: { - description: "Shown after onboarding", - name: "Onboarding", - slug: "onboarding", - }, - }), - ).toBe('+ Create paywall location: onboarding ("Onboarding")'); - - expect( - formatChange({ - changeType: "update-paywall-location", - key: "onboarding", - payload: { - description: null, - name: "Onboarding Updated", - slug: "onboarding", - }, - }), - ).toBe('~ Update paywall location: onboarding ("Onboarding Updated")'); - - expect( - formatChange({ - changeType: "archive-paywall-location", - key: "onboarding", - payload: { slug: "onboarding" }, - }), - ).toBe("- Archive paywall location: onboarding"); - }); - - it("formats create-perk correctly", () => { - const result = formatChange({ - changeType: "create-perk", - key: "all-access", - payload: { name: "All Access", slug: "all-access" }, - }); - expect(result).toBe('+ Create perk: all-access ("All Access")'); - }); - - it("formats update-perk correctly", () => { - const result = formatChange({ - changeType: "update-perk", - key: "all-access", - payload: { name: "All Access Updated", slug: "all-access" }, - }); - expect(result).toBe('~ Update perk: all-access ("All Access Updated")'); - }); - - it("formats create-product correctly", () => { - const result = formatChange({ - changeType: "create-product", - key: "monthly", - payload: { name: "Monthly Plan", slug: "monthly" }, - }); - expect(result).toBe('+ Create product: monthly ("Monthly Plan")'); - }); - - it("formats update-product correctly", () => { - const result = formatChange({ - changeType: "update-product", - key: "monthly", - payload: { name: "Monthly Plan Updated", slug: "monthly" }, - }); - expect(result).toBe('~ Update product: monthly ("Monthly Plan Updated")'); - }); - - it("formats create-product-perk correctly", () => { - const result = formatChange({ - changeType: "create-product-perk", - key: "monthly:all-access", - payload: { perkSlug: "all-access", productSlug: "monthly" }, - }); - expect(result).toBe('+ Link perk "all-access" to product "monthly"'); - }); - - it("formats delete-product-perk correctly", () => { - const result = formatChange({ - changeType: "delete-product-perk", - key: "monthly:all-access", - payload: { perkSlug: "all-access", productSlug: "monthly" }, - }); - expect(result).toBe('- Unlink perk "all-access" from product "monthly"'); - }); - - it("formats create-payment-provider-product correctly", () => { - const result = formatChange({ - changeType: "create-payment-provider-product", - key: "monthly:appleAppStore", - payload: { - configuration: { productId: "com.app.monthly" }, - productSlug: "monthly", - providerId: "appleAppStore", - }, - }); - expect(result).toBe('+ Configure appleAppStore for product "monthly"'); - }); - - it("formats update-payment-provider-product correctly", () => { - const result = formatChange({ - changeType: "update-payment-provider-product", - key: "monthly:appleAppStore", - payload: { - configuration: { productId: "com.app.monthly.v2" }, - productSlug: "monthly", - providerId: "appleAppStore", - }, - }); - expect(result).toBe('~ Update appleAppStore config for product "monthly"'); - }); - - it("formats delete-payment-provider-product correctly", () => { - const result = formatChange({ - changeType: "delete-payment-provider-product", - key: "monthly:appleAppStore", - payload: { - productSlug: "monthly", - providerId: "appleAppStore", - }, - }); - expect(result).toBe('- Remove appleAppStore config from product "monthly"'); - }); - - it("formats delete-perk correctly", () => { - const result = formatChange({ - changeType: "delete-perk", - key: "all-access", - payload: { slug: "all-access" }, - }); - expect(result).toBe("- Delete perk: all-access"); - }); - - it("formats delete-product correctly", () => { - const result = formatChange({ - changeType: "delete-product", - key: "monthly", - payload: { slug: "monthly" }, - }); - expect(result).toBe("- Delete product: monthly"); - }); - - it("returns fallback for unknown change type", () => { - const result = formatChange({ - changeType: "unknown-type" as never, - key: "test", - payload: {}, - } as never); - expect(result).toBe("? Unknown change type"); - }); -}); - -describe("formatChangeShort", () => { - it("formats paywall location changes correctly", () => { - expect( - formatChangeShort({ - changeType: "create-paywall-location", - key: "onboarding", - payload: { - description: "Shown after onboarding", - name: "Onboarding", - slug: "onboarding", - }, - }), - ).toBe("PaywallLocation: onboarding"); - - expect( - formatChangeShort({ - changeType: "update-paywall-location", - key: "onboarding", - payload: { - description: null, - name: "Onboarding Updated", - slug: "onboarding", - }, - }), - ).toBe("PaywallLocation: onboarding"); - - expect( - formatChangeShort({ - changeType: "archive-paywall-location", - key: "onboarding", - payload: { slug: "onboarding" }, - }), - ).toBe("PaywallLocation: onboarding"); - }); - - it("formats perk changes correctly", () => { - expect( - formatChangeShort({ - changeType: "create-perk", - key: "all-access", - payload: { name: "All Access", slug: "all-access" }, - }), - ).toBe("Perk: all-access"); - - expect( - formatChangeShort({ - changeType: "update-perk", - key: "all-access", - payload: { name: "All Access", slug: "all-access" }, - }), - ).toBe("Perk: all-access"); - - expect( - formatChangeShort({ - changeType: "delete-perk", - key: "all-access", - payload: { slug: "all-access" }, - }), - ).toBe("Perk: all-access"); - }); - - it("formats product changes correctly", () => { - expect( - formatChangeShort({ - changeType: "create-product", - key: "monthly", - payload: { name: "Monthly", slug: "monthly" }, - }), - ).toBe("Product: monthly"); - - expect( - formatChangeShort({ - changeType: "update-product", - key: "monthly", - payload: { name: "Monthly", slug: "monthly" }, - }), - ).toBe("Product: monthly"); - - expect( - formatChangeShort({ - changeType: "delete-product", - key: "monthly", - payload: { slug: "monthly" }, - }), - ).toBe("Product: monthly"); - }); - - it("formats product-perk changes correctly", () => { - expect( - formatChangeShort({ - changeType: "create-product-perk", - key: "monthly:all-access", - payload: { perkSlug: "all-access", productSlug: "monthly" }, - }), - ).toBe("ProductPerk: monthly:all-access"); - - expect( - formatChangeShort({ - changeType: "delete-product-perk", - key: "monthly:all-access", - payload: { perkSlug: "all-access", productSlug: "monthly" }, - }), - ).toBe("ProductPerk: monthly:all-access"); - }); - - it("formats provider-product changes correctly", () => { - expect( - formatChangeShort({ - changeType: "create-payment-provider-product", - key: "monthly:appleAppStore", - payload: { - configuration: {}, - productSlug: "monthly", - providerId: "appleAppStore", - }, - }), - ).toBe("ProviderProduct: monthly:appleAppStore"); - - expect( - formatChangeShort({ - changeType: "update-payment-provider-product", - key: "monthly:appleAppStore", - payload: { - configuration: {}, - productSlug: "monthly", - providerId: "appleAppStore", - }, - }), - ).toBe("ProviderProduct: monthly:appleAppStore"); - - expect( - formatChangeShort({ - changeType: "delete-payment-provider-product", - key: "monthly:appleAppStore", - payload: { - productSlug: "monthly", - providerId: "appleAppStore", - }, - }), - ).toBe("ProviderProduct: monthly:appleAppStore"); - }); - - it("returns Unknown for unknown change type", () => { - const result = formatChangeShort({ - changeType: "unknown-type" as never, - key: "test", - payload: {}, - } as never); - expect(result).toBe("Unknown"); - }); -}); diff --git a/apps/cli/tests/utils/schema/diff.test.ts b/apps/cli/tests/utils/schema/diff.test.ts deleted file mode 100644 index fbeb170d2..000000000 --- a/apps/cli/tests/utils/schema/diff.test.ts +++ /dev/null @@ -1,648 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { computeDiff, summarizeDiff } from "../../../src/utils/schema/diff"; -import { - createProviderConfig, - createTestPaywallLocation, - createTestPerk, - createTestProduct, - createTestSchema, -} from "../../helpers/schema-factories"; - -describe("computeDiff", () => { - describe("locations", () => { - it("returns empty diff for identical locations", () => { - const location = createTestPaywallLocation({ - slug: "onboarding", - name: "Onboarding", - }); - const local = createTestSchema({ locations: [location] }); - const remote = createTestSchema({ locations: [location] }); - - const diff = computeDiff(local, remote); - - expect(diff.locations.toCreate).toHaveLength(0); - expect(diff.locations.toUpdate).toHaveLength(0); - expect(diff.locations.remoteOnly).toHaveLength(0); - expect(diff.locations.toArchive).toHaveLength(0); - }); - - it("identifies locations to create (local only)", () => { - const localLocation = createTestPaywallLocation({ - slug: "onboarding", - name: "Onboarding", - }); - const local = createTestSchema({ locations: [localLocation] }); - const remote = createTestSchema({ locations: [] }); - - const diff = computeDiff(local, remote); - - expect(diff.locations.toCreate).toHaveLength(1); - expect(diff.locations.toCreate[0]).toEqual(localLocation); - expect(diff.locations.toUpdate).toHaveLength(0); - expect(diff.locations.remoteOnly).toHaveLength(0); - expect(diff.locations.toArchive).toHaveLength(0); - }); - - it("identifies locations to update (same slug, different metadata)", () => { - const localLocation = createTestPaywallLocation({ - description: null, - slug: "onboarding", - name: "Onboarding New", - }); - const remoteLocation = createTestPaywallLocation({ - description: "Shown after launch", - slug: "onboarding", - name: "Onboarding Old", - }); - const local = createTestSchema({ locations: [localLocation] }); - const remote = createTestSchema({ locations: [remoteLocation] }); - - const diff = computeDiff(local, remote); - - expect(diff.locations.toCreate).toHaveLength(0); - expect(diff.locations.toUpdate).toHaveLength(1); - expect(diff.locations.toUpdate[0]).toEqual({ - local: localLocation, - remote: remoteLocation, - }); - }); - - it("identifies remote-only locations to archive", () => { - const remoteLocation = createTestPaywallLocation({ - slug: "onboarding", - name: "Onboarding", - }); - const local = createTestSchema({ locations: [] }); - const remote = createTestSchema({ locations: [remoteLocation] }); - - const diff = computeDiff(local, remote); - - expect(diff.locations.remoteOnly).toHaveLength(1); - expect(diff.locations.toArchive).toHaveLength(1); - expect(diff.locations.toArchive[0]).toEqual(remoteLocation); - }); - }); - - describe("perks", () => { - it("returns empty diff for identical schemas", () => { - const perk = createTestPerk({ slug: "perk-1", name: "Perk 1" }); - const local = createTestSchema({ perks: [perk] }); - const remote = createTestSchema({ perks: [perk] }); - - const diff = computeDiff(local, remote); - - expect(diff.perks.toCreate).toHaveLength(0); - expect(diff.perks.toUpdate).toHaveLength(0); - expect(diff.perks.remoteOnly).toHaveLength(0); - }); - - it("identifies perks to create (local only)", () => { - const localPerk = createTestPerk({ slug: "local-perk", name: "Local" }); - const local = createTestSchema({ perks: [localPerk] }); - const remote = createTestSchema({ perks: [] }); - - const diff = computeDiff(local, remote); - - expect(diff.perks.toCreate).toHaveLength(1); - expect(diff.perks.toCreate[0]).toEqual(localPerk); - expect(diff.perks.toUpdate).toHaveLength(0); - expect(diff.perks.remoteOnly).toHaveLength(0); - }); - - it("identifies remote-only perks", () => { - const remotePerk = createTestPerk({ - slug: "remote-perk", - name: "Remote", - }); - const local = createTestSchema({ perks: [] }); - const remote = createTestSchema({ perks: [remotePerk] }); - - const diff = computeDiff(local, remote); - - expect(diff.perks.toCreate).toHaveLength(0); - expect(diff.perks.toUpdate).toHaveLength(0); - expect(diff.perks.remoteOnly).toHaveLength(1); - expect(diff.perks.remoteOnly[0]).toEqual(remotePerk); - }); - - it("identifies perks to update (same slug, different name)", () => { - const localPerk = createTestPerk({ slug: "perk-1", name: "Updated Name" }); - const remotePerk = createTestPerk({ - slug: "perk-1", - name: "Original Name", - }); - const local = createTestSchema({ perks: [localPerk] }); - const remote = createTestSchema({ perks: [remotePerk] }); - - const diff = computeDiff(local, remote); - - expect(diff.perks.toCreate).toHaveLength(0); - expect(diff.perks.toUpdate).toHaveLength(1); - expect(diff.perks.toUpdate[0]).toEqual({ - local: localPerk, - remote: remotePerk, - }); - expect(diff.perks.remoteOnly).toHaveLength(0); - }); - - it("handles multiple perks correctly", () => { - const sharedPerk = createTestPerk({ slug: "shared", name: "Shared" }); - const localOnlyPerk = createTestPerk({ - slug: "local-only", - name: "Local Only", - }); - const remoteOnlyPerk = createTestPerk({ - slug: "remote-only", - name: "Remote Only", - }); - const localUpdatedPerk = createTestPerk({ - slug: "updated", - name: "Updated Local", - }); - const remoteUpdatedPerk = createTestPerk({ - slug: "updated", - name: "Updated Remote", - }); - - const local = createTestSchema({ - perks: [sharedPerk, localOnlyPerk, localUpdatedPerk], - }); - const remote = createTestSchema({ - perks: [sharedPerk, remoteOnlyPerk, remoteUpdatedPerk], - }); - - const diff = computeDiff(local, remote); - - expect(diff.perks.toCreate).toHaveLength(1); - expect(diff.perks.toCreate[0]?.slug).toBe("local-only"); - expect(diff.perks.toUpdate).toHaveLength(1); - expect(diff.perks.toUpdate[0]?.local.slug).toBe("updated"); - expect(diff.perks.remoteOnly).toHaveLength(1); - expect(diff.perks.remoteOnly[0]?.slug).toBe("remote-only"); - }); - }); - - describe("products", () => { - it("identifies products to create (local only)", () => { - const localProduct = createTestProduct({ - slug: "local-product", - name: "Local", - }); - const local = createTestSchema({ products: [localProduct] }); - const remote = createTestSchema({ products: [] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toCreate).toHaveLength(1); - expect(diff.products.toCreate[0]).toEqual(localProduct); - expect(diff.products.toUpdate).toHaveLength(0); - expect(diff.products.remoteOnly).toHaveLength(0); - }); - - it("identifies remote-only products", () => { - const remoteProduct = createTestProduct({ - slug: "remote-product", - name: "Remote", - }); - const local = createTestSchema({ products: [] }); - const remote = createTestSchema({ products: [remoteProduct] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toCreate).toHaveLength(0); - expect(diff.products.toUpdate).toHaveLength(0); - expect(diff.products.remoteOnly).toHaveLength(1); - expect(diff.products.remoteOnly[0]).toEqual(remoteProduct); - }); - - it("identifies products to update when name differs", () => { - const localProduct = createTestProduct({ - slug: "product-1", - name: "Updated Name", - }); - const remoteProduct = createTestProduct({ - slug: "product-1", - name: "Original Name", - }); - const local = createTestSchema({ products: [localProduct] }); - const remote = createTestSchema({ products: [remoteProduct] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toCreate).toHaveLength(0); - expect(diff.products.toUpdate).toHaveLength(1); - expect(diff.products.toUpdate[0]).toEqual({ - local: localProduct, - remote: remoteProduct, - }); - }); - - it("identifies products to update when perks differ", () => { - const localProduct = createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1", "perk-2"], - }); - const remoteProduct = createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1"], - }); - const local = createTestSchema({ products: [localProduct] }); - const remote = createTestSchema({ products: [remoteProduct] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toUpdate).toHaveLength(1); - expect(diff.products.toUpdate[0]?.local.perks).toEqual([ - "perk-1", - "perk-2", - ]); - expect(diff.products.toUpdate[0]?.remote.perks).toEqual(["perk-1"]); - }); - - it("identifies products to update when providers differ", () => { - const localProduct = createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("appleAppStore", { productId: "com.app.1" }), - ], - }); - const remoteProduct = createTestProduct({ - slug: "product-1", - name: "Product", - providers: [], - }); - const local = createTestSchema({ products: [localProduct] }); - const remote = createTestSchema({ products: [remoteProduct] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toUpdate).toHaveLength(1); - }); - - it("identifies products to update when provider config differs", () => { - const localProduct = createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("appleAppStore", { productId: "com.app.new" }), - ], - }); - const remoteProduct = createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("appleAppStore", { productId: "com.app.old" }), - ], - }); - const local = createTestSchema({ products: [localProduct] }); - const remote = createTestSchema({ products: [remoteProduct] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toUpdate).toHaveLength(1); - }); - - it("handles products with no perks", () => { - const product = createTestProduct({ - slug: "no-perks", - name: "No Perks", - perks: [], - }); - const local = createTestSchema({ products: [product] }); - const remote = createTestSchema({ products: [product] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toCreate).toHaveLength(0); - expect(diff.products.toUpdate).toHaveLength(0); - }); - - it("handles products with no providers", () => { - const product = createTestProduct({ - slug: "no-providers", - name: "No Providers", - providers: [], - }); - const local = createTestSchema({ products: [product] }); - const remote = createTestSchema({ products: [product] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toCreate).toHaveLength(0); - expect(diff.products.toUpdate).toHaveLength(0); - }); - - it("treats products with same perks in different order as equal", () => { - const localProduct = createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-1", "perk-2"], - }); - const remoteProduct = createTestProduct({ - slug: "product-1", - name: "Product", - perks: ["perk-2", "perk-1"], - }); - const local = createTestSchema({ products: [localProduct] }); - const remote = createTestSchema({ products: [remoteProduct] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toUpdate).toHaveLength(0); - }); - - it("treats products with same providers in different order as equal", () => { - const localProduct = createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("googlePlay", { productId: "google.1" }), - createProviderConfig("appleAppStore", { productId: "apple.1" }), - ], - }); - const remoteProduct = createTestProduct({ - slug: "product-1", - name: "Product", - providers: [ - createProviderConfig("appleAppStore", { productId: "apple.1" }), - createProviderConfig("googlePlay", { productId: "google.1" }), - ], - }); - const local = createTestSchema({ products: [localProduct] }); - const remote = createTestSchema({ products: [remoteProduct] }); - - const diff = computeDiff(local, remote); - - expect(diff.products.toUpdate).toHaveLength(0); - }); - }); - - describe("complex scenarios", () => { - it("handles mixed creates, updates, and remote-only items", () => { - const sharedPerk = createTestPerk({ slug: "shared", name: "Shared" }); - const localPerk = createTestPerk({ - slug: "local-perk", - name: "Local Perk", - }); - const remotePerk = createTestPerk({ - slug: "remote-perk", - name: "Remote Perk", - }); - - const sharedProduct = createTestProduct({ - slug: "shared-product", - name: "Shared", - }); - const localProduct = createTestProduct({ - slug: "local-product", - name: "Local", - }); - const localUpdatedProduct = createTestProduct({ - slug: "updated-product", - name: "Updated Local", - }); - const remoteProduct = createTestProduct({ - slug: "remote-product", - name: "Remote", - }); - const remoteUpdatedProduct = createTestProduct({ - slug: "updated-product", - name: "Updated Remote", - }); - - const local = createTestSchema({ - perks: [sharedPerk, localPerk], - products: [sharedProduct, localProduct, localUpdatedProduct], - }); - const remote = createTestSchema({ - perks: [sharedPerk, remotePerk], - products: [sharedProduct, remoteProduct, remoteUpdatedProduct], - }); - - const diff = computeDiff(local, remote); - - expect(diff.perks.toCreate).toHaveLength(1); - expect(diff.perks.remoteOnly).toHaveLength(1); - expect(diff.products.toCreate).toHaveLength(1); - expect(diff.products.toUpdate).toHaveLength(1); - expect(diff.products.remoteOnly).toHaveLength(1); - }); - - it("handles empty local schema against populated remote", () => { - const remotePerk = createTestPerk({ - slug: "remote-perk", - name: "Remote", - }); - const remoteProduct = createTestProduct({ - slug: "remote-product", - name: "Remote", - }); - - const local = createTestSchema({}); - const remote = createTestSchema({ - perks: [remotePerk], - products: [remoteProduct], - }); - - const diff = computeDiff(local, remote); - - expect(diff.perks.toCreate).toHaveLength(0); - expect(diff.perks.remoteOnly).toHaveLength(1); - expect(diff.products.toCreate).toHaveLength(0); - expect(diff.products.remoteOnly).toHaveLength(1); - }); - - it("handles populated local schema against empty remote", () => { - const localPerk = createTestPerk({ slug: "local-perk", name: "Local" }); - const localProduct = createTestProduct({ - slug: "local-product", - name: "Local", - }); - - const local = createTestSchema({ - perks: [localPerk], - products: [localProduct], - }); - const remote = createTestSchema({}); - - const diff = computeDiff(local, remote); - - expect(diff.perks.toCreate).toHaveLength(1); - expect(diff.perks.remoteOnly).toHaveLength(0); - expect(diff.products.toCreate).toHaveLength(1); - expect(diff.products.remoteOnly).toHaveLength(0); - }); - }); -}); - -describe("summarizeDiff", () => { - it("returns zeros for empty diff", () => { - const local = createTestSchema({}); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const summary = summarizeDiff(diff); - - expect(summary.perksToCreate).toBe(0); - expect(summary.locationsToCreate).toBe(0); - expect(summary.locationsToUpdate).toBe(0); - expect(summary.locationsToArchive).toBe(0); - expect(summary.locationsRemoteOnly).toBe(0); - expect(summary.perksToUpdate).toBe(0); - expect(summary.perksRemoteOnly).toBe(0); - expect(summary.productsToCreate).toBe(0); - expect(summary.productsToUpdate).toBe(0); - expect(summary.productsRemoteOnly).toBe(0); - expect(summary.totalChanges).toBe(0); - }); - - it("correctly counts perks to create", () => { - const local = createTestSchema({ - perks: [ - createTestPerk({ slug: "perk-1", name: "Perk 1" }), - createTestPerk({ slug: "perk-2", name: "Perk 2" }), - ], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const summary = summarizeDiff(diff); - - expect(summary.perksToCreate).toBe(2); - }); - - it("correctly counts perks to update", () => { - const local = createTestSchema({ - perks: [createTestPerk({ slug: "perk-1", name: "Updated" })], - }); - const remote = createTestSchema({ - perks: [createTestPerk({ slug: "perk-1", name: "Original" })], - }); - const diff = computeDiff(local, remote); - - const summary = summarizeDiff(diff); - - expect(summary.perksToUpdate).toBe(1); - }); - - it("correctly counts remote-only perks", () => { - const local = createTestSchema({}); - const remote = createTestSchema({ - perks: [ - createTestPerk({ slug: "perk-1", name: "Perk 1" }), - createTestPerk({ slug: "perk-2", name: "Perk 2" }), - createTestPerk({ slug: "perk-3", name: "Perk 3" }), - ], - }); - const diff = computeDiff(local, remote); - - const summary = summarizeDiff(diff); - - expect(summary.perksRemoteOnly).toBe(3); - }); - - it("correctly counts products to create", () => { - const local = createTestSchema({ - products: [createTestProduct({ slug: "product-1", name: "Product 1" })], - }); - const remote = createTestSchema({}); - const diff = computeDiff(local, remote); - - const summary = summarizeDiff(diff); - - expect(summary.productsToCreate).toBe(1); - }); - - it("correctly counts products to update", () => { - const local = createTestSchema({ - products: [createTestProduct({ slug: "product-1", name: "Updated" })], - }); - const remote = createTestSchema({ - products: [createTestProduct({ slug: "product-1", name: "Original" })], - }); - const diff = computeDiff(local, remote); - - const summary = summarizeDiff(diff); - - expect(summary.productsToUpdate).toBe(1); - }); - - it("correctly counts remote-only products", () => { - const local = createTestSchema({}); - const remote = createTestSchema({ - products: [ - createTestProduct({ slug: "product-1", name: "Product 1" }), - createTestProduct({ slug: "product-2", name: "Product 2" }), - ], - }); - const diff = computeDiff(local, remote); - - const summary = summarizeDiff(diff); - - expect(summary.productsRemoteOnly).toBe(2); - }); - - it("totalChanges includes location archive actions derived from remote-only locations", () => { - const local = createTestSchema({ - locations: [ - createTestPaywallLocation({ - description: null, - slug: "new-location", - name: "New Location", - }), - createTestPaywallLocation({ - description: null, - slug: "updated-location", - name: "Updated Local", - }), - ], - perks: [ - createTestPerk({ slug: "new-perk", name: "New" }), - createTestPerk({ slug: "updated-perk", name: "Updated Local" }), - ], - products: [ - createTestProduct({ slug: "new-product", name: "New" }), - createTestProduct({ slug: "updated-product", name: "Updated Local" }), - ], - }); - const remote = createTestSchema({ - locations: [ - createTestPaywallLocation({ - description: "Old description", - slug: "updated-location", - name: "Updated Remote", - }), - createTestPaywallLocation({ - slug: "remote-only-location", - name: "Remote Only", - }), - ], - perks: [ - createTestPerk({ slug: "updated-perk", name: "Updated Remote" }), - createTestPerk({ slug: "remote-only-perk", name: "Remote Only" }), - ], - products: [ - createTestProduct({ slug: "updated-product", name: "Updated Remote" }), - createTestProduct({ - slug: "remote-only-product", - name: "Remote Only", - }), - ], - }); - const diff = computeDiff(local, remote); - - const summary = summarizeDiff(diff); - - // 1 location to create + 1 location to update + 1 location to archive + - // 1 perk to create + 1 perk to update + 1 product to create + 1 product to update = 7 - expect(summary.totalChanges).toBe(7); - expect(summary.locationsRemoteOnly).toBe(1); - expect(summary.locationsToArchive).toBe(1); - expect(summary.perksRemoteOnly).toBe(1); - expect(summary.productsRemoteOnly).toBe(1); - }); -}); diff --git a/apps/cli/tests/utils/schema/local-schema-loader.test.ts b/apps/cli/tests/utils/schema/local-schema-loader.test.ts deleted file mode 100644 index 3246263a7..000000000 --- a/apps/cli/tests/utils/schema/local-schema-loader.test.ts +++ /dev/null @@ -1,433 +0,0 @@ -import { NodeFileSystem, NodePath } from "@effect/platform-node"; -import { Effect, Exit, Layer } from "effect"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; - -import { - extractPerkSlugs, - extractProviderConfigs, - isPaywallLocationDefinition, - isPerkDefinition, - isProductDefinition, - isSchemaConfiguration, - loadLocalSchema, - SCHEMA_KIND, - SchemaKind, - slugToCamelCase, -} from "../../../src/utils/schema/local-schema-loader"; - -// Create a minimal layer for testing that only includes what we need -const TestLayer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer); - -describe("slugToCamelCase", () => { - it("converts hyphenated slug", () => { - expect(slugToCamelCase("all-access")).toBe("allAccess"); - }); - - it("converts underscored slug", () => { - expect(slugToCamelCase("monthly_sub")).toBe("monthlySub"); - }); - - it("handles simple slug", () => { - expect(slugToCamelCase("simple")).toBe("simple"); - }); - - it("handles multi-part slug", () => { - expect(slugToCamelCase("multi-part-slug")).toBe("multiPartSlug"); - }); - - it("handles uppercase", () => { - expect(slugToCamelCase("UPPER_CASE")).toBe("upperCase"); - }); - - it("handles mixed separators", () => { - expect(slugToCamelCase("foo-bar_baz")).toBe("fooBarBaz"); - }); - - it("handles empty string", () => { - expect(slugToCamelCase("")).toBe(""); - }); -}); - -describe("extractPerkSlugs", () => { - it("returns empty array for undefined config", () => { - const allPerks = new Map(); - expect(extractPerkSlugs(undefined, allPerks)).toEqual([]); - }); - - it("returns empty array for empty config", () => { - const allPerks = new Map(); - expect(extractPerkSlugs({}, allPerks)).toEqual([]); - }); - - it("extracts enabled perks (value=true)", () => { - const allPerks = new Map([ - ["all-access", { slug: "all-access", name: "All Access" }], - ["premium", { slug: "premium", name: "Premium" }], - ]); - const config = { allAccess: true, premium: false }; - - const result = extractPerkSlugs(config, allPerks); - - expect(result).toEqual(["all-access"]); - }); - - it("skips metadata key '_'", () => { - const allPerks = new Map([ - ["all-access", { slug: "all-access", name: "All Access" }], - ]); - const config = { - _: { perks: {} } as unknown as boolean, - allAccess: true, - }; - - const result = extractPerkSlugs(config, allPerks); - - expect(result).toEqual(["all-access"]); - }); - - it("correctly maps camelCase keys to slugs via allPerks map", () => { - const allPerks = new Map([ - ["premium-features", { slug: "premium-features", name: "Premium" }], - ["all-access", { slug: "all-access", name: "All Access" }], - ]); - const config = { premiumFeatures: true, allAccess: true }; - - const result = extractPerkSlugs(config, allPerks); - - expect(result).toContain("premium-features"); - expect(result).toContain("all-access"); - }); - - it("ignores perks not in allPerks map", () => { - const allPerks = new Map([ - ["existing-perk", { slug: "existing-perk", name: "Existing" }], - ]); - const config = { nonExistentPerk: true }; - - const result = extractPerkSlugs(config, allPerks); - - expect(result).toEqual([]); - }); -}); - -describe("extractProviderConfigs", () => { - it("returns empty array for undefined config", () => { - expect(extractProviderConfigs(undefined)).toEqual([]); - }); - - it("returns empty array for empty config", () => { - expect(extractProviderConfigs({})).toEqual([]); - }); - - it("extracts appleAppStore provider", () => { - const config = { - appleAppStore: { productId: "com.app.monthly" }, - }; - - const result = extractProviderConfigs(config); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - providerId: "appleAppStore", - configuration: { productId: "com.app.monthly" }, - }); - }); - - it("extracts googlePlay provider", () => { - const config = { - googlePlay: { productId: "monthly_subscription" }, - }; - - const result = extractProviderConfigs(config); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - providerId: "googlePlay", - configuration: { productId: "monthly_subscription" }, - }); - }); - - it("ignores unknown provider IDs", () => { - const config = { - unknownProvider: { productId: "test" }, - appleAppStore: { productId: "com.app.1" }, - }; - - const result = extractProviderConfigs(config); - - expect(result).toHaveLength(1); - expect(result[0]?.providerId).toBe("appleAppStore"); - }); - - it("skips metadata key '_'", () => { - const config = { - _: { paymentProviders: {} }, - appleAppStore: { productId: "com.app.1" }, - }; - - const result = extractProviderConfigs(config); - - expect(result).toHaveLength(1); - expect(result[0]?.providerId).toBe("appleAppStore"); - }); - - it("ignores null/undefined config values", () => { - const config = { - appleAppStore: null, - googlePlay: undefined, - }; - - const result = extractProviderConfigs(config as Record); - - expect(result).toEqual([]); - }); - - it("extracts configuration object correctly", () => { - const config = { - appleAppStore: { productId: "com.app.1", groupId: "group123" }, - }; - - const result = extractProviderConfigs(config); - - expect(result[0]?.configuration).toEqual({ - productId: "com.app.1", - groupId: "group123", - }); - }); -}); - -describe("type guards", () => { - describe("isSchemaConfiguration", () => { - it("returns true for object with correct symbol", () => { - const obj = { - [SCHEMA_KIND]: SchemaKind.SchemaConfiguration, - perks: {}, - providers: {}, - location: () => {}, - subscription: () => {}, - }; - expect(isSchemaConfiguration(obj)).toBe(true); - }); - - it("returns false for null", () => { - expect(isSchemaConfiguration(null)).toBe(false); - }); - - it("returns false for non-object", () => { - expect(isSchemaConfiguration("string")).toBe(false); - expect(isSchemaConfiguration(123)).toBe(false); - expect(isSchemaConfiguration(undefined)).toBe(false); - }); - - it("returns false for object without symbol", () => { - const obj = { - perks: {}, - providers: {}, - location: () => {}, - subscription: () => {}, - }; - expect(isSchemaConfiguration(obj)).toBe(false); - }); - - it("returns false for object with wrong symbol value", () => { - const obj = { - [SCHEMA_KIND]: SchemaKind.Perk, - perks: {}, - providers: {}, - location: () => {}, - subscription: () => {}, - }; - expect(isSchemaConfiguration(obj)).toBe(false); - }); - }); - - describe("isPaywallLocationDefinition", () => { - it("returns true for object with correct symbol", () => { - const obj = { - [SCHEMA_KIND]: SchemaKind.PaywallLocation, - description: "Shown after onboarding", - name: "Onboarding", - slug: "onboarding", - }; - expect(isPaywallLocationDefinition(obj)).toBe(true); - }); - - it("returns false for null", () => { - expect(isPaywallLocationDefinition(null)).toBe(false); - }); - - it("returns false for non-object", () => { - expect(isPaywallLocationDefinition("string")).toBe(false); - }); - - it("returns false for object without symbol", () => { - const obj = { name: "Onboarding", slug: "onboarding" }; - expect(isPaywallLocationDefinition(obj)).toBe(false); - }); - }); - - describe("isPerkDefinition", () => { - it("returns true for object with correct symbol", () => { - const obj = { - [SCHEMA_KIND]: SchemaKind.Perk, - slug: "test-perk", - name: "Test Perk", - }; - expect(isPerkDefinition(obj)).toBe(true); - }); - - it("returns false for null", () => { - expect(isPerkDefinition(null)).toBe(false); - }); - - it("returns false for non-object", () => { - expect(isPerkDefinition("string")).toBe(false); - }); - - it("returns false for object without symbol", () => { - const obj = { slug: "test", name: "Test" }; - expect(isPerkDefinition(obj)).toBe(false); - }); - }); - - describe("isProductDefinition", () => { - it("returns true for object with correct symbol", () => { - const obj = { - [SCHEMA_KIND]: SchemaKind.Product, - type: "subscription", - slug: "test-product", - properties: { name: "Test" }, - configuration: {}, - }; - expect(isProductDefinition(obj)).toBe(true); - }); - - it("returns false for null", () => { - expect(isProductDefinition(null)).toBe(false); - }); - - it("returns false for non-object", () => { - expect(isProductDefinition(123)).toBe(false); - }); - - it("returns false for object without symbol", () => { - const obj = { - type: "subscription", - slug: "test", - properties: { name: "Test" }, - configuration: {}, - }; - expect(isProductDefinition(obj)).toBe(false); - }); - }); -}); - -describe("loadLocalSchema", () => { - const fixturesPath = path.resolve(__dirname, "../../fixtures"); - - it( - "fails with LocalSchemaNotFoundError when file doesn't exist", - async () => { - await Effect.runPromise( - Effect.gen(function* () { - const result = yield* Effect.exit( - loadLocalSchema("/non/existent/path.ts"), - ); - - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const error = result.cause; - expect(error._tag).toBe("Fail"); - } - }).pipe(Effect.provide(TestLayer)), - ); - }, - ); - - it("extracts perks from SchemaConfiguration", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const schemaPath = path.join(fixturesPath, "valid-schema.ts"); - const result = yield* loadLocalSchema(schemaPath); - - expect(result.perks.size).toBe(2); - expect(result.perks.has("all-access")).toBe(true); - expect(result.perks.has("premium-features")).toBe(true); - - const allAccessPerk = result.perks.get("all-access"); - expect(allAccessPerk?.name).toBe("All Access"); - }).pipe(Effect.provide(TestLayer)), - ); - }); - - it("extracts enabled providers from SchemaConfiguration", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const schemaPath = path.join(fixturesPath, "valid-schema.ts"); - const result = yield* loadLocalSchema(schemaPath); - - expect(result.enabledProviders.has("appleAppStore")).toBe(true); - expect(result.enabledProviders.has("googlePlay")).toBe(true); - }).pipe(Effect.provide(TestLayer)), - ); - }); - - it("extracts products with perks and providers", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const schemaPath = path.join(fixturesPath, "valid-schema.ts"); - const result = yield* loadLocalSchema(schemaPath); - - expect(result.products.size).toBe(2); - expect(result.products.has("monthly-plan")).toBe(true); - expect(result.products.has("yearly-plan")).toBe(true); - - const monthlyPlan = result.products.get("monthly-plan"); - expect(monthlyPlan?.name).toBe("Monthly Plan"); - expect(monthlyPlan?.perks).toContain("all-access"); - expect(monthlyPlan?.providers).toHaveLength(2); - - const yearlyPlan = result.products.get("yearly-plan"); - expect(yearlyPlan?.name).toBe("Yearly Plan"); - expect(yearlyPlan?.perks).toContain("all-access"); - expect(yearlyPlan?.perks).toContain("premium-features"); - }).pipe(Effect.provide(TestLayer)), - ); - }); - - it("extracts paywall locations", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const schemaPath = path.join(fixturesPath, "valid-schema.ts"); - const result = yield* loadLocalSchema(schemaPath); - - expect(result.locations.size).toBe(2); - expect(result.locations.has("onboarding-upsell")).toBe(true); - expect(result.locations.has("settings-paywall")).toBe(true); - - const onboardingUpsell = result.locations.get("onboarding-upsell"); - expect(onboardingUpsell?.name).toBe("Onboarding Upsell"); - expect(onboardingUpsell?.description).toBe("Shown after onboarding"); - - const settingsPaywall = result.locations.get("settings-paywall"); - expect(settingsPaywall?.name).toBe("Settings Paywall"); - expect(settingsPaywall?.description).toBeNull(); - }).pipe(Effect.provide(TestLayer)), - ); - }); - - it("handles empty schema", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const schemaPath = path.join(fixturesPath, "empty-schema.ts"); - const result = yield* loadLocalSchema(schemaPath); - - expect(result.perks.size).toBe(0); - expect(result.products.size).toBe(0); - expect(result.locations.size).toBe(0); - expect(result.enabledProviders.size).toBe(0); - }).pipe(Effect.provide(TestLayer)), - ); - }); -}); diff --git a/docs/server-first-schema-server-spec.md b/docs/server-first-schema-server-spec.md new file mode 100644 index 000000000..41c615f2a --- /dev/null +++ b/docs/server-first-schema-server-spec.md @@ -0,0 +1,363 @@ +# Server-First Schema — Backend Implementation Spec + +Status: Draft +Owner: Backend (consumed by CLI + React Native SDK) +Tracks: server-side work required by the +`feat/move-to-server-first` redesign of the CLI and `@voidhash/react-native`. + +## Context + +The CLI and React Native SDK no longer treat the user's code as the source of +truth for the schema (perks, products, paywall locations, payment provider +mappings). The dashboard is. After the client-side refactor: + +- `voidhash schema push / pull / check` are gone. +- The CLI's only job vs the schema is to generate a `.d.ts` declaration file + (`voidhash.gen.d.ts`) consumed by the SDK via module augmentation. +- The SDK fetches the runtime schema on `Provider` mount instead of receiving + it via code. +- The CLI watches for schema changes and the dev-mode SDK warns when the + generated file is stale. + +This document specifies the **server-side** endpoints and contracts that make +that operational. None of this exists today — the client side has stubs / falls +back to existing per-entity endpoints in the meantime. + +The shapes referenced below come from: + +- `apps/cli/src/domain/schema/normalized-schema.ts` — `NormalizedSchema` + (what the CLI generates the `.d.ts` from). +- `libraries/react-native/src/core/schema/runtime.ts` — `RuntimeSchema` + (what the SDK consumes at runtime). +- `apps/cli/src/utils/schema/version.ts` — + `computeSchemaVersionFromNormalized` (the deterministic hash that both + client and server must agree on). + +## Endpoints + +Three new things to ship. Existing per-entity endpoints +(`/api/v1/perks`, `/api/v1/products`, `/api/v1/paywall-locations`, +`/api/v1/payment-provider-configurations`, +`/api/v1/payment-provider-products`, `/api/v1/product-perks/by-product-id/{productId}`) +can stay; the new endpoints are additive. + +### 1. `GET /api/v1/schema` — consolidated read (CLI-facing) + +Replaces the five round-trips the CLI currently makes to assemble a +`NormalizedSchema`. Returns everything in one response. + +**Authentication:** Bearer token (session). Same auth as today's per-entity +admin endpoints. The CLI calls this from `voidhash types generate` (and +indirectly from `voidhash init`). + +**Response (200):** + +```jsonc +{ + "version": "sha256:", // identical to GET /schema/version below + "perks": [ + { "slug": "all-access", "name": "All Access" } + ], + "locations": [ + { "slug": "home", "name": "Home", "description": null } + ], + "products": [ + { + "slug": "monthly_sub", + "name": "Monthly", + "type": "subscription", + "perks": ["all-access"], // perk slugs, not IDs + "providers": [ + { + "providerId": "appleAppStore", + "configuration": { "productId": "com.app.monthly" } + }, + { + "providerId": "googlePlay", + "configuration": { + "productId": "com.app.monthly", + "basePlanId": "monthly-base" + } + } + ] + } + ], + "enabledProviders": ["appleAppStore", "googlePlay"] +} +``` + +**Field-by-field mapping** to the CLI's `NormalizedSchema` (which is the +intermediate the codegen runs over): + +| Server field | CLI shape (`NormalizedSchema`) | Notes | +|------------------------|---------------------------------------|-------| +| `perks[]` | `Map` | | +| `locations[]` | `Map` | `description` is `null` when unset. | +| `products[]` | `Map` | `perks` is an array of perk **slugs**. `providers[].configuration` is provider-shaped. `type` is currently always `"subscription"` (extensible later). | +| `enabledProviders[]` | `Set` | `"appleAppStore"` / `"googlePlay"`. Other provider IDs are filtered out client-side; sending them is harmless. | +| `version` | derived (see hash spec below) | Must equal `GET /schema/version`. | + +**Caching:** Should set `ETag: ""` and respect +`If-None-Match` → return 304 when the client's known version matches. +The CLI watch loop relies on this being cheap. + +**Errors:** Standard auth / not-found responses. An empty project (zero +perks/products/locations) returns 200 with empty arrays and a deterministic +version hash (see below). + +### 2. `GET /api/v1/schema/version` — cheap version probe + +Returns just the version hash. Used by: + +- `voidhash types generate --watch` (polls every 5s by default). +- `voidhash types check` (CI gate — compares against the `@voidhash:version` + header in the local `.d.ts`). +- The SDK's dev-mode drift warning. + +**Authentication:** Bearer token (session) **OR** publishable key. +The CLI uses session auth. The SDK uses the publishable key it already +sends on every other request (`x-publishable-key` header). The endpoint +needs to accept both so we don't need two near-identical routes. + +**Response (200):** + +```json +{ "version": "sha256:" } +``` + +Must equal the `version` field of `GET /api/v1/schema`. + +**Caching:** Same `ETag` / `If-None-Match` story; 304 on match. Strongly +prefer keeping this under ~200 bytes uncompressed. + +### 3. `GET /sdk/schema` — runtime schema for the SDK + +The SDK calls this on `Provider` mount to populate `RuntimeSchema`, which +drives `useProducts`, native store lookups (slug → provider productId), etc. + +**Authentication:** Publishable key (`x-publishable-key` header, matching +existing `/api/v1/sdk/*` endpoints). The CLI does **not** call this — it +uses `GET /api/v1/schema`. + +**Response (200):** + +```jsonc +{ + "version": "sha256:", + "perks": { + "all-access": { "slug": "all-access", "name": "All Access" } + }, + "locations": { + "home": { "slug": "home", "name": "Home", "description": null } + }, + "products": { + "monthly_sub": { + "slug": "monthly_sub", + "type": "subscription", + "properties": { "name": "Monthly" }, + "configuration": { + "perks": { "all-access": true }, + "providers": { + "appleAppStore": { "productId": "com.app.monthly" }, + "googlePlay": { + "productId": "com.app.monthly", + "basePlanId": "monthly-base" + } + } + } + } + } +} +``` + +This is exactly `RuntimeSchema` from +`libraries/react-native/src/core/schema/runtime.ts`. Note the differences +from `GET /api/v1/schema`: + +- Object-keyed by slug (not arrays of objects). The SDK looks products up + by slug at runtime. +- Per product, `properties: { name }` and + `configuration: { perks, providers }` are split (matches the SDK's + existing internal shape — minimizes the diff vs the old DSL-built + product definitions). +- `enabledProviders` is **not** required (the SDK doesn't need it; only the + CLI codegen does). + +The server should be able to derive this from the same underlying records +that back `GET /api/v1/schema` — it's a different projection of the same +data. + +### 4. Slug-accepting endpoint variants + +Today the SDK passes server-issued IDs to: + +- `POST /api/v1/sdk/sync-transaction` — `payload.productId` +- `POST /api/v1/sdk/resolve-paywall` — `payload.locationSlug` (already a + slug; verify no other ID fields are needed) +- Any internal lookups during purchase/restore that travel through + `/api/v1/sdk/*` + +After the redesign the SDK no longer ships a slug → ID map. **Every SDK +endpoint that today accepts an entity ID must accept the corresponding +slug as a substitute.** Two acceptable patterns: + +- Replace the field type from "ID" to "slug or ID" with the server + resolving either form. Simplest; keeps a single field name. +- Add a parallel `*Slug` field and deprecate the `*Id` variant. + +Pick whichever your typed HTTP framework prefers. The SDK will only emit +slugs going forward; the ID variants only need to remain alive long enough +to drain in-flight clients (probably one or two release cycles). + +Affected endpoints to audit (non-exhaustive — please confirm against the +current code): + +- `POST /api/v1/sdk/sync-transaction` — `productId` +- `POST /api/v1/sdk/resolve-paywall` — confirm whether the body or any + resolved sub-objects carry IDs the client would need to round-trip +- Any future purchase / checkout endpoints + +The CLI does not need slug variants — it operates against IDs as today. + +## Version hash algorithm + +The hash is the single point of agreement between client and server. The +CLI computes it from a `NormalizedSchema` in +`apps/cli/src/utils/schema/version.ts`. The server **must** produce the +same bytes from the same logical schema, otherwise `types check` will +ping-pong between "stale" and "fresh" depending on who computed last. + +Algorithm: + +1. Build a JSON object with three top-level keys, **in this exact order**: + `locations`, `perks`, `products`. +2. Each is an array, sorted ascending by `slug`. +3. Per-element field order matches the CLI emit (see below). +4. Stable JSON stringification: no whitespace, no trailing keys, exact + field order as written. (Node's `JSON.stringify` with explicit object + construction in declared order is what the CLI does.) +5. `sha256` the resulting UTF-8 bytes, hex-encode, prefix with `sha256:`. + +Reference: `apps/cli/src/utils/schema/version.ts` — +`computeSchemaVersionFromNormalized`. The exact source order: + +```js +// products[i] +{ name, perks: [sorted slugs], providers: [{providerId, configuration} sorted by providerId], slug, type } + +// locations[i] +{ description, name, slug } + +// perks[i] +{ name, slug } +``` + +The `configuration` field per provider is whatever object was stored — its +internal property order matters. Two safe options: + +- Store provider configurations canonically (sort keys at write time). +- Re-canonicalize at read time before hashing. + +Whichever you pick, the CLI just round-trips whatever the server sent in +its `providers[].configuration`. As long as **the server hashes the same +bytes it sends in `GET /api/v1/schema`**, the client side will match by +construction (the CLI just JSON-serializes what it received). + +An empty schema produces a deterministic hash: + +``` +sha256: +``` + +## Authentication summary + +| Endpoint | Session bearer | Publishable key | +|--------------------------------|:--------------:|:---------------:| +| `GET /api/v1/schema` | ✅ | ❌ | +| `GET /api/v1/schema/version` | ✅ | ✅ | +| `GET /sdk/schema` | ❌ | ✅ | +| Slug-accepting `/sdk/*` | ❌ | ✅ | + +`GET /api/v1/schema/version` accepts both because the CLI uses it during +`types generate --watch` and the SDK uses it for the dev-mode drift +warning. + +## Caching & freshness + +- All three GETs should set `Cache-Control: no-cache, must-revalidate` and + `ETag: ""`. +- 304 on `If-None-Match` match. The CLI watch loop and the dev-mode SDK + warning will both benefit (cheap polls, no payload on no-change). +- Don't set a positive max-age — the whole point is that we want clients + to revalidate eagerly. The cache savings come from 304s, not from + skipping the round trip. + +## Empty / unconfigured projects + +A freshly-`init`ed project has zero perks/products/locations. The +endpoints should still return 200 with empty collections and a deterministic +hash (see "Empty schema" above). The CLI / SDK already handle this +gracefully — `voidhash.gen.d.ts` lists `products: never`, and the +SDK-side conditional types in +`libraries/react-native/src/core/schema/registry.ts` degrade `never` back +to `string` so user code still compiles. + +## Out of scope for this spec + +- Schema mutation endpoints. The dashboard already exposes them today; no + changes needed. +- Versioning of the schema itself (migrations, snapshots, history). The + `sha256:` is a content hash, not a logical version number. If we want + per-revision history later, that's an additive feature. +- Webhook on schema change. Out of scope for v1 of the server-first + redesign — the CLI's polling against `/schema/version` is intentionally + the v1 freshness mechanism (Combo A + B in the design doc). + +## Sequencing recommendation + +1. **`GET /api/v1/schema/version`** first. It's the smallest piece of + surface area and unblocks both the CLI watch loop and the dev-mode SDK + drift warning. Until it ships, `types check` falls back to a full + schema fetch (already implemented client-side in `fetchSchemaVersion`). +2. **`GET /api/v1/schema`** second. The CLI already composes today's + per-entity endpoints to produce the same `NormalizedSchema`, so this + is purely an optimization (1 round-trip vs 5+). No client changes + needed when it lands — the CLI's `fetchRemoteSchema` can be swapped + internally to use the new endpoint. +3. **`GET /sdk/schema`** third. Unblocks runtime product hooks + (`useProducts`, `usePurchase`) in the SDK. Until it ships, + `apiClient.sdk.getSchema()` returns an empty schema with a warning + (see the stub in `libraries/react-native/src/core/networking/api-client.ts`). +4. **Slug-accepting `/sdk/*` variants** last. The SDK doesn't need them + to function in the "products fetched via `GET /sdk/schema`" path — it + still has the provider productIds — but they let us drop the + provider-productId fields from the bundle if/when we decide + schema-on-bundle was wrong. Out of the critical path for v1. + +## Verification (server-side) + +1. Round-trip `GET /api/v1/schema` and run the result through the CLI's + `computeSchemaVersionFromNormalized` helper. The result must equal the + `version` field in the response and the response of + `GET /api/v1/schema/version`. +2. Mutate the schema (add a perk), assert `GET /schema/version` changes, + `If-None-Match: ` returns 304 before the mutation and 200 + after. +3. From a fresh CLI clone with a generated `voidhash.gen.d.ts` for the + pre-mutation schema, run `voidhash types check` — assert non-zero + exit + the printed diff cites the new version. +4. From the SDK example app, mount `Provider`, assert `useProducts()` + returns the slugs/configurations now living on the server. + +## Open questions for the backend team + +- Should `GET /sdk/schema` be rate-limited per publishable key? The SDK + calls it once per session; a misbehaving app could call it more + aggressively. +- Do we want the dashboard to invalidate ETags eagerly on edit, or rely + on the natural propagation? (Either works; ETag invalidation is purely + a latency optimization.) +- Provider-configuration shape stability: are we comfortable hashing the + raw `configuration` object, or do we want to canonicalize per-provider + to insulate against accidental key reorderings? Current proposal is + "hash what you send" which is the simplest contract. diff --git a/examples/react-native-example/app/_layout.tsx b/examples/react-native-example/app/_layout.tsx index dba02aa5b..4baef834e 100644 --- a/examples/react-native-example/app/_layout.tsx +++ b/examples/react-native-example/app/_layout.tsx @@ -2,7 +2,7 @@ import { Stack } from "expo-router"; import "react-native-reanimated"; import "fast-text-encoding"; import { StatusBar } from "expo-status-bar"; -import { voidhash } from "utils/voidhash/local.client"; +import { voidhash } from "utils/voidhash/client"; // Prevent the splash screen from auto-hiding before asset loading is complete. // SplashScreen.preventAutoHideAsync(); diff --git a/examples/react-native-example/app/index.tsx b/examples/react-native-example/app/index.tsx index e3f4eea71..d23a05833 100644 --- a/examples/react-native-example/app/index.tsx +++ b/examples/react-native-example/app/index.tsx @@ -3,7 +3,7 @@ import { useRouter } from "expo-router"; import { ActivityIndicator, Image, StyleSheet, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { fakeAuthService, useCurrentUser } from "utils/fake-auth-service"; -import { voidhash } from "utils/voidhash/local.client"; +import { voidhash } from "utils/voidhash/client"; import { Logo } from "../components/logo"; diff --git a/examples/react-native-example/app/menu/customer.tsx b/examples/react-native-example/app/menu/customer.tsx index 74b4aa1b1..33f283ca6 100644 --- a/examples/react-native-example/app/menu/customer.tsx +++ b/examples/react-native-example/app/menu/customer.tsx @@ -1,7 +1,7 @@ import { Button } from "components/button"; import { Platform, StyleSheet, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { voidhash } from "utils/voidhash/local.client"; +import { voidhash } from "utils/voidhash/client"; export default function HomeScreen() { const insets = useSafeAreaInsets(); diff --git a/examples/react-native-example/app/menu/paywall.tsx b/examples/react-native-example/app/menu/paywall.tsx index fd38df17b..b736990b4 100644 --- a/examples/react-native-example/app/menu/paywall.tsx +++ b/examples/react-native-example/app/menu/paywall.tsx @@ -3,7 +3,7 @@ import { Button } from "components/button"; import { useMemo, useState } from "react"; import { StyleSheet, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { voidhash } from "utils/voidhash/local.client"; +import { voidhash } from "utils/voidhash/client"; const PAYWALL_LOCATION_SLUG = "example-paywall"; diff --git a/examples/react-native-example/app/menu/sign-in.tsx b/examples/react-native-example/app/menu/sign-in.tsx index 3438d5e6c..22458e68c 100644 --- a/examples/react-native-example/app/menu/sign-in.tsx +++ b/examples/react-native-example/app/menu/sign-in.tsx @@ -2,7 +2,7 @@ import { useRouter } from "expo-router"; import { Image, Pressable, StyleSheet, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { fakeAuthService, users } from "utils/fake-auth-service"; -import { voidhash } from "utils/voidhash/local.client"; +import { voidhash } from "utils/voidhash/client"; export default function HomeScreen() { const router = useRouter(); diff --git a/examples/react-native-example/package.json b/examples/react-native-example/package.json index 2efde49e6..661ae7739 100644 --- a/examples/react-native-example/package.json +++ b/examples/react-native-example/package.json @@ -9,6 +9,8 @@ "eas:build-dev:local": "eas build --local -e development", "start": "expo start --dev-client", "prebuild": "expo prebuild", + "voidhash:types": "voidhash types generate", + "voidhash:types-check": "voidhash types check", "lint": "biome check .", "typecheck": "tsc --noEmit", "format": "biome format .", diff --git a/examples/react-native-example/utils/voidhash/_schema.ts b/examples/react-native-example/utils/voidhash/_schema.ts deleted file mode 100644 index 8c167b40e..000000000 --- a/examples/react-native-example/utils/voidhash/_schema.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { schemaConfiguration, unlockablePerk } from "@voidhash/react-native"; - -export const sc = schemaConfiguration({ - perks: { - allAccess: unlockablePerk("all-access", { - name: "All Access", - }), - }, - providers: { - // appleAppStore: true, - // googlePlay: true, - }, -}); - -export const monthlySub = sc.subscription("monthly_sub", { - name: "Monthly", - perks: { - allAccess: true, - }, - providers: { - // appleAppStore: { - // productId: "test_group_monthly", - // }, - // googlePlay: { - // productId: "com.voidhash.example.monthly", - // }, - }, -}); - -export const yearlySub = sc.subscription("yearly_sub", { - name: "Yearly", - perks: { - allAccess: true, - }, - providers: { - // appleAppStore: { - // productId: "test_group_yearly", - // }, - // googlePlay: { - // basePlanId: "com.voidhash.example.yearly.base", - // productId: "com.voidhash.example.yearly", - // }, - }, -}); diff --git a/examples/react-native-example/utils/voidhash/client.ts b/examples/react-native-example/utils/voidhash/client.ts index fb855fa2b..db76c615c 100644 --- a/examples/react-native-example/utils/voidhash/client.ts +++ b/examples/react-native-example/utils/voidhash/client.ts @@ -1,8 +1,18 @@ import { createVoidhashClient } from "@voidhash/react-native"; -import * as schema from "./schema"; -// export const voidhash = createVoidhashClient( -// "vh_pk_PWIsDyqMEOOuHpmAsFQvwaFhGKBVlkHf", -// schema, -// {} -// ); +/** + * Voidhash client for the example app. + * + * Schema lives on the server now — run `voidhash types generate` to refresh + * the local `voidhash.gen.d.ts` whenever the dashboard schema changes. + */ +export const voidhash = createVoidhashClient( + process.env.EXPO_PUBLIC_VOIDHASH_PUBLISHABLE_KEY ?? + "vh_pk_hrvyOZJoxtonGGPtTnkMehrCoEPsAbwD", + { + debug: true, + ...(process.env.EXPO_PUBLIC_VOIDHASH_API_URL + ? { baseUrl: process.env.EXPO_PUBLIC_VOIDHASH_API_URL } + : {}), + }, +); diff --git a/examples/react-native-example/utils/voidhash/local.client.ts b/examples/react-native-example/utils/voidhash/local.client.ts deleted file mode 100644 index 5a5d3316e..000000000 --- a/examples/react-native-example/utils/voidhash/local.client.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { createVoidhashClient } from "@voidhash/react-native"; -import Constants from "expo-constants"; - -import * as schema from "./_schema"; - -function resolveHostIp() { - const debuggerHost = - ( - Constants.expoConfig as { - debuggerHost?: string; - hostUri?: string; - } | null - )?.debuggerHost ?? - ( - Constants.expoConfig as { - debuggerHost?: string; - hostUri?: string; - } | null - )?.hostUri ?? - ( - Constants.manifest2 as { - extra?: { - expoGo?: { - debuggerHost?: string; - }; - }; - } | null - )?.extra?.expoGo?.debuggerHost; - - return debuggerHost?.split(":")[0]; -} - -const baseUrl = process.env.EXPO_PUBLIC_VOIDHASH_API_URL?.replace( - "localhost", - resolveHostIp() ?? "localhost", -); - -const clientOptions = { - debug: true, - ...(baseUrl ? { baseUrl } : {}), -}; -const publishableKey = - process.env.EXPO_PUBLIC_VOIDHASH_PUBLISHABLE_KEY ?? - "vh_pk_hrvyOZJoxtonGGPtTnkMehrCoEPsAbwD"; - -console.log(baseUrl, publishableKey); - -export const voidhash = createVoidhashClient( - publishableKey, - schema, - clientOptions, -); diff --git a/examples/react-native-example/utils/voidhash/schema.ts b/examples/react-native-example/utils/voidhash/schema.ts deleted file mode 100644 index d39a3ff95..000000000 --- a/examples/react-native-example/utils/voidhash/schema.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { schemaConfiguration, unlockablePerk } from "@voidhash/react-native"; - -export const sc = schemaConfiguration({ - perks: { - allAccess: unlockablePerk("all-access", { name: "All Access" }), - }, - providers: { - }, -}); - -export const monthlySub = sc.subscription("monthly_sub", { - name: "Monthly", - perks: { - allAccess: true, - }, - providers: { - }, -}); - -export const yearlySub = sc.subscription("yearly_sub", { - name: "Yearly", - perks: { - allAccess: true, - }, - providers: { - }, -}); diff --git a/examples/react-native-example/voidhash.config.ts b/examples/react-native-example/voidhash.config.ts index 315e61edb..2c63f37e4 100644 --- a/examples/react-native-example/voidhash.config.ts +++ b/examples/react-native-example/voidhash.config.ts @@ -1,7 +1,6 @@ -import { defineConfig } from 'voidhash-cli'; +import { defineConfig } from "voidhash-cli"; export default defineConfig({ - team: 'voidhash-dev-sro', - project: 'dev-proj', - schema: 'utils/voidhash/schema.ts' + team: "voidhash-dev-sro", + project: "dev-proj", }); diff --git a/examples/react-native-example/voidhash.gen.d.ts b/examples/react-native-example/voidhash.gen.d.ts new file mode 100644 index 000000000..ea5c4866a --- /dev/null +++ b/examples/react-native-example/voidhash.gen.d.ts @@ -0,0 +1,15 @@ +// voidhash.gen.d.ts — generated by voidhash-cli, do not edit +// @voidhash:version sha256:placeholder +// @voidhash:fetched-at 1970-01-01T00:00:00.000Z + +declare module "@voidhash/react-native" { + interface VoidhashRegister { + schema: { + products: never; + locations: never; + perks: never; + }; + } +} + +export {}; diff --git a/libraries/react-native/package.json b/libraries/react-native/package.json index 7e55036e2..84b2b6692 100644 --- a/libraries/react-native/package.json +++ b/libraries/react-native/package.json @@ -52,7 +52,9 @@ "exports": { "./app.plugin.js": "./app.plugin.js", "./package.json": "./package.json", - "./schema": "./src/core/schema/index.ts", + "./metro": { + "node": "./src/metro/index.ts" + }, ".": "./src/index.ts" }, "scripts": { @@ -97,7 +99,13 @@ "expo-linking": "*", "react": "*", "react-native": "*", - "react-native-nitro-modules": "0.26.4" + "react-native-nitro-modules": "0.26.4", + "voidhash-cli": "workspace:*" + }, + "peerDependenciesMeta": { + "voidhash-cli": { + "optional": true + } }, "jest": { "moduleNameMapper": { diff --git a/libraries/react-native/src/__tests__/client.test.ts b/libraries/react-native/src/__tests__/client.test.ts index 1ec69179a..e7a88568a 100644 --- a/libraries/react-native/src/__tests__/client.test.ts +++ b/libraries/react-native/src/__tests__/client.test.ts @@ -49,14 +49,15 @@ function createClient(readOnly = false, unstableSwallowErrors = false) { return new VoidhashClient( null, "voidhash", - createTestSchema(), "https://api.voidhash.test", undefined, "pk_test", readOnly, unstableSwallowErrors, new EventBus(), - "ios" + "ios", + false, + createTestSchema() ); } diff --git a/libraries/react-native/src/__tests__/core/client-effect.test.ts b/libraries/react-native/src/__tests__/core/client-effect.test.ts index fbfaf6563..a255e4ce7 100644 --- a/libraries/react-native/src/__tests__/core/client-effect.test.ts +++ b/libraries/react-native/src/__tests__/core/client-effect.test.ts @@ -46,7 +46,7 @@ describe("VoidhashEffectClient", () => { const initializedClient = await harness.runtime.runPromise( VoidhashEffectClient.makeUnitializedClient().init({ distinctId: "user-after-init", - schema, + internalSchema: schema, }) ); @@ -81,7 +81,7 @@ describe("VoidhashEffectClient", () => { try { await harness.runtime.runPromise( VoidhashEffectClient.makeUnitializedClient().init({ - schema, + internalSchema: schema, }) ); @@ -128,8 +128,8 @@ describe("VoidhashEffectClient", () => { const second = await harness.runtime.runPromise(initializedClient.getProducts()); expect(paymentDouble.state.getProductsCalls).toBe(1); - expect(first.monthlySub?.slug).toBe("monthly_sub"); - expect(first.yearlySub).toBeNull(); + expect(first.monthly_sub?.slug).toBe("monthly_sub"); + expect(first.yearly_sub).toBeNull(); expect(second).toEqual(first); } finally { await harness.runtime.dispose(); @@ -237,7 +237,7 @@ describe("VoidhashEffectClient", () => { try { await harness.runtime.runPromise( - initializedClient.purchase(monthlyProduct, { + initializedClient.purchase(monthlyProduct, { method: "native", }) ); diff --git a/libraries/react-native/src/__tests__/helpers/test-schema.ts b/libraries/react-native/src/__tests__/helpers/test-schema.ts index 8611a48b0..0f2b7aa7f 100644 --- a/libraries/react-native/src/__tests__/helpers/test-schema.ts +++ b/libraries/react-native/src/__tests__/helpers/test-schema.ts @@ -1,49 +1,45 @@ -import { schemaConfiguration, unlockablePerk } from "../../core/schema"; -import type { VoidhashSchema } from "../../core/schema/types"; +import type { RuntimeSchema } from "../../core/schema/runtime"; -export function createTestSchema() { - const schemaConfig = schemaConfiguration({ +/** + * Build a deterministic in-memory schema for tests. Mirrors the shape the + * SDK fetches from the server at init time so tests don't need a live + * backend to exercise product / purchase flows. + */ +export function createTestSchema(): RuntimeSchema { + return { + version: "sha256:test", perks: { - allAccess: unlockablePerk("all-access", { name: "All Access" }), + "all-access": { slug: "all-access", name: "All Access" }, }, - providers: { - appleAppStore: true, - googlePlay: true, - }, - }); - - const schema = { - monthlySub: schemaConfig.subscription("monthly_sub", { - name: "Monthly", - perks: { - allAccess: true, - }, - providers: { - appleAppStore: { - productId: "com.voidhash.monthly.ios", - }, - googlePlay: { - productId: "com.voidhash.monthly.android", + locations: {}, + products: { + monthly_sub: { + slug: "monthly_sub", + type: "subscription", + properties: { name: "Monthly" }, + configuration: { + perks: { "all-access": true }, + providers: { + appleAppStore: { productId: "com.voidhash.monthly.ios" }, + googlePlay: { productId: "com.voidhash.monthly.android" }, + }, }, }, - }), - schemaConfig, - yearlySub: schemaConfig.subscription("yearly_sub", { - name: "Yearly", - perks: { - allAccess: true, - }, - providers: { - appleAppStore: { - productId: "com.voidhash.yearly.ios", - }, - googlePlay: { - basePlanId: "yearly-base", - productId: "com.voidhash.yearly.android", + yearly_sub: { + slug: "yearly_sub", + type: "subscription", + properties: { name: "Yearly" }, + configuration: { + perks: { "all-access": true }, + providers: { + appleAppStore: { productId: "com.voidhash.yearly.ios" }, + googlePlay: { + productId: "com.voidhash.yearly.android", + basePlanId: "yearly-base", + }, + }, }, }, - }), - } satisfies VoidhashSchema; - - return schema; + }, + }; } diff --git a/libraries/react-native/src/__tests__/react/use-paywall-by-location.test.tsx b/libraries/react-native/src/__tests__/react/use-paywall-by-location.test.tsx index ab730e323..bd0aca4b3 100644 --- a/libraries/react-native/src/__tests__/react/use-paywall-by-location.test.tsx +++ b/libraries/react-native/src/__tests__/react/use-paywall-by-location.test.tsx @@ -1,5 +1,4 @@ import type { VoidhashClient } from "../../client"; -import type { VoidhashSchema } from "../../core/schema"; import { __internal_handlePaywallBridgeEventForTests, __internal_resetPaywallByLocationCachesForTests, @@ -23,7 +22,7 @@ function createClientMock() { getProducts: jest.fn(), purchase: jest.fn(), restorePurchases: jest.fn(), - } as unknown as jest.Mocked>; + } as unknown as jest.Mocked; } function createPresenterMock() { diff --git a/libraries/react-native/src/client-effect.ts b/libraries/react-native/src/client-effect.ts index fe9cf6362..69a2de9c9 100644 --- a/libraries/react-native/src/client-effect.ts +++ b/libraries/react-native/src/client-effect.ts @@ -6,7 +6,7 @@ import { Cause, Effect } from "effect"; import { SDK_VERSION } from "./core/constants"; import { CacheManager } from "./core/caching/cache-manager"; -import type { Product } from "./core/entities/product"; +import type { Product, SubscriptionProduct } from "./core/entities/product"; import type { Transaction } from "./core/entities/transaction"; import { EventBusProvider } from "./core/event-bus"; import { CustomerAttributeManager } from "./core/identity/customer-attribute-manager"; @@ -14,19 +14,25 @@ import { CustomerInfoManager } from "./core/identity/customer-info-manager"; import { IdentityManager } from "./core/identity/identity-manager"; import { ApiClient } from "./core/networking/api-client"; import { PaymentAdapter } from "./core/payment-adapters/payment-adapter"; -import type { - ExtractSchemaProductDefinitions, - ExtractSchemaProductKeys, - InferGetPaywallLocationInput, - InferGetProductResponseFromSchema, - VoidhashSchema, -} from "./core/schema"; -import { extractProductDefinitions } from "./core/schema/utils"; +import type { LocationSlug, ProductSlug } from "./core/schema/registry"; +import { + type RuntimeProductDefinition, + type RuntimeSchema, + createEmptyRuntimeSchema, +} from "./core/schema/runtime"; import { SdkConfiguration } from "./core/sdk-configuration"; import { getCommonSdkHeaders } from "./core/utils/get-common-sdk-headers"; import { UnsupportedPlatformError } from "./errors"; -import { AnalyticsIngestEvent, AnalyticsSendFailure, QueuedAnalyticsEvent } from "./core/analytics/types"; -import { createQueuedAnalyticsEvent, getAnalyticsStandardizedProperties, mapQueuedAnalyticsEventToIngestEvent } from "./core/analytics/utils"; +import { + AnalyticsIngestEvent, + AnalyticsSendFailure, + QueuedAnalyticsEvent, +} from "./core/analytics/types"; +import { + createQueuedAnalyticsEvent, + getAnalyticsStandardizedProperties, + mapQueuedAnalyticsEventToIngestEvent, +} from "./core/analytics/utils"; import { getNonce } from "./core/utils/crypto"; const PROCESSED_TRANSACTION_TTL_MS = 1000 * 60 * 30; @@ -35,8 +41,12 @@ const ANALYTICS_FLUSH_INTERVAL_MS = 5000; const MAX_ANALYTICS_RETRY_DELAY_MS = 30_000; const RETRYABLE_ANALYTICS_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]); - - +/** + * The shape of `useProducts()` etc. — keyed by the registered product slugs. + * Values are `null` when the underlying store SDK doesn't know about that + * product (e.g. the product isn't configured on this platform). + */ +export type ProductsBySlug = Record; interface AppReleaseInfo { readonly appBuild: string | null; @@ -74,7 +84,9 @@ const getReactNativeAppState = (): ReactNativeAppState | null => { const toNullableString = (value: unknown): string | null => value !== null && value !== undefined ? String(value) : null; -const toAppReleaseInfo = (value: AppReleaseInfo | undefined | null): AppReleaseInfo | null => { +const toAppReleaseInfo = ( + value: AppReleaseInfo | undefined | null +): AppReleaseInfo | null => { if (!value) return null; return { appBuild: value.appBuild, @@ -82,8 +94,6 @@ const toAppReleaseInfo = (value: AppReleaseInfo | undefined | null): AppReleaseI }; }; - - const getAnalyticsRetryDelayMs = (attempts: number) => Math.min(1000 * 2 ** Math.max(attempts - 1, 0), MAX_ANALYTICS_RETRY_DELAY_MS); @@ -106,29 +116,36 @@ const parseRetryAfterMs = (value: string | null): number | undefined => { }; const getRetryAfterMsFromResponseBody = ( - data: CaptureAcceptedResponse | CaptureErrorResponse | undefined, + data: CaptureAcceptedResponse | CaptureErrorResponse | undefined ): number | undefined => data && "retry_after_ms" in data && typeof data.retry_after_ms === "number" ? data.retry_after_ms : undefined; +interface InitOptions { + readonly distinctId?: string; + /** + * Test/internal escape hatch — inject a known runtime schema instead of + * fetching from the server. Not part of the public API. Lets the test + * suite drive product fetching deterministically while the server-side + * schema endpoint is still being built out. + */ + readonly internalSchema?: RuntimeSchema; +} + const makeUnitializedClient = () => ({ - init: (initOptions: { - distinctId?: string; - schema: TSchema; - }) => + init: (initOptions: InitOptions = {}) => Effect.gen(function* init() { const identityManager = yield* IdentityManager; const customerAttributeManager = yield* CustomerAttributeManager; const customerInfoManager = yield* CustomerInfoManager; + const apiClient = yield* ApiClient; if (initOptions.distinctId) { - // Identify as the distinct id provided during SDK initialization. yield* Effect.logDebug("Initializing with provided distinct id", { distinctId: initOptions.distinctId, }); - // Sync customer attributes before identify to not lose historical customer data const distinctId = yield* identityManager.getDistinctIdFromCache(); if (distinctId) { yield* customerAttributeManager.syncCustomerAttributes(distinctId); @@ -136,29 +153,44 @@ const makeUnitializedClient = () => ({ yield* identityManager.identify(initOptions.distinctId, {}); } else { - // If no distinct id was passed during SDK initialization, fetch the current customer in the background. const distinctId = yield* identityManager.getDistinctId(); yield* Effect.logDebug("Initializing without provided distinct id", { distinctId, }); yield* customerAttributeManager.syncCustomerAttributes(distinctId); - - // We don't need the result immediately. We do this to pre-fetch fresh customer data in the background. yield* customerInfoManager.getCustomer(distinctId, "fetch"); } - // Return the initialized client - return makeInitializedClient({ - schema: initOptions.schema, - }); + // Fetch the runtime schema. The server endpoint is still being built; + // until it ships the API client returns an empty schema with a warning, + // and product-related calls return empty data. + let runtimeSchema = initOptions.internalSchema ?? createEmptyRuntimeSchema(); + if (!initOptions.internalSchema) { + const commonHeaders = yield* getCommonSdkHeaders(); + const distinctId = yield* identityManager.getDistinctId(); + const fetched = yield* Effect.exit( + apiClient.sdk.getSchema({ + headers: { + ...commonHeaders, + "x-distinct-id": distinctId, + }, + }) + ); + if (fetched._tag === "Success") { + runtimeSchema = fetched.value as RuntimeSchema; + } else { + yield* Effect.logWarning( + "[voidhash] Failed to fetch schema at init — product-related hooks will return empty data." + ); + } + } + + return makeInitializedClient({ schema: runtimeSchema }); }), }); - -const makeInitializedClient = (options: { - schema: TSchema; -}) => { +const makeInitializedClient = (options: { schema: RuntimeSchema }) => { const inFlightTransactionKeys = new Set(); // Analytics state @@ -181,12 +213,16 @@ const makeInitializedClient = (options: { } const now = Date.now(); - const hasDueEvents = analyticsQueue.some((event) => event.availableAt <= now); + const hasDueEvents = analyticsQueue.some( + (event) => event.availableAt <= now + ); if (hasDueEvents) { return ANALYTICS_FLUSH_INTERVAL_MS; } - const nextAvailableAt = Math.min(...analyticsQueue.map((event) => event.availableAt)); + const nextAvailableAt = Math.min( + ...analyticsQueue.map((event) => event.availableAt) + ); return Math.max(nextAvailableAt - now, 0); }; @@ -203,7 +239,9 @@ const makeInitializedClient = (options: { }, delayMs); }; - const sendAnalyticsEventsImpl = (events: ReadonlyArray) => + const sendAnalyticsEventsImpl = ( + events: ReadonlyArray + ) => Effect.gen(function* () { if (events.length === 0) return; @@ -249,11 +287,15 @@ const makeInitializedClient = (options: { }); const data = (yield* Effect.tryPromise({ - try: () => response.json() as Promise, + try: () => + response.json() as Promise< + CaptureAcceptedResponse | CaptureErrorResponse + >, catch: (cause) => cause, - }).pipe( - Effect.orElseSucceed(() => undefined), - )) as CaptureAcceptedResponse | CaptureErrorResponse | undefined; + }).pipe(Effect.orElseSucceed(() => undefined))) as + | CaptureAcceptedResponse + | CaptureErrorResponse + | undefined; if (response.status === 202) { return; @@ -293,10 +335,13 @@ const makeInitializedClient = (options: { } }); - const buildQueuedAnalyticsBatchIds = (events: ReadonlyArray) => - new Set(events.map((event) => event.id)); + const buildQueuedAnalyticsBatchIds = ( + events: ReadonlyArray + ) => new Set(events.map((event) => event.id)); - const dropQueuedAnalyticsBatch = (events: ReadonlyArray) => { + const dropQueuedAnalyticsBatch = ( + events: ReadonlyArray + ) => { const ids = buildQueuedAnalyticsBatchIds(events); for (let index = analyticsQueue.length - 1; index >= 0; index -= 1) { if (ids.has(analyticsQueue[index]!.id)) { @@ -307,7 +352,7 @@ const makeInitializedClient = (options: { const postponeQueuedAnalyticsBatch = ( events: ReadonlyArray, - nextAvailableAt: number, + nextAvailableAt: number ) => { const ids = buildQueuedAnalyticsBatchIds(events); for (let index = 0; index < analyticsQueue.length; index += 1) { @@ -344,11 +389,19 @@ const makeInitializedClient = (options: { const processQueuedAnalyticsBatch = ( queuedBatch: ReadonlyArray, - standardizedProperties: Record, - ): Effect.Effect => + standardizedProperties: Record + ): Effect.Effect< + void, + AnalyticsSendFailure, + IdentityManager | SdkConfiguration + > => Effect.gen(function* () { const ingestBatch = queuedBatch.map((event) => - mapQueuedAnalyticsEventToIngestEvent(event, standardizedProperties, analyticsSessionId), + mapQueuedAnalyticsEventToIngestEvent( + event, + standardizedProperties, + analyticsSessionId + ) ); const sendResult = yield* Effect.exit(sendAnalyticsEventsImpl(ingestBatch)); @@ -364,14 +417,20 @@ const makeInitializedClient = (options: { cause: failure, message: failure instanceof Error ? failure.message : String(failure), retryable: false, - }), + }) ); } if (failure.status === 413 && queuedBatch.length > 1) { const midpoint = Math.ceil(queuedBatch.length / 2); - yield* processQueuedAnalyticsBatch(queuedBatch.slice(0, midpoint), standardizedProperties); - yield* processQueuedAnalyticsBatch(queuedBatch.slice(midpoint), standardizedProperties); + yield* processQueuedAnalyticsBatch( + queuedBatch.slice(0, midpoint), + standardizedProperties + ); + yield* processQueuedAnalyticsBatch( + queuedBatch.slice(midpoint), + standardizedProperties + ); return; } @@ -385,10 +444,13 @@ const makeInitializedClient = (options: { if (!failure.retryable) { dropQueuedAnalyticsBatch(queuedBatch); - yield* Effect.logWarning("Dropping analytics batch after non-retryable response", { - eventIds: queuedBatch.map((event) => event.id), - status: failure.status, - }); + yield* Effect.logWarning( + "Dropping analytics batch after non-retryable response", + { + eventIds: queuedBatch.map((event) => event.id), + status: failure.status, + } + ); return; } @@ -397,8 +459,7 @@ const makeInitializedClient = (options: { const processObservedTransaction = (transaction: Transaction) => Effect.gen(function* processObservedTransaction() { - const transactionProcessingKey = - buildTransactionProcessingKey(transaction); + const transactionProcessingKey = buildTransactionProcessingKey(transaction); if (inFlightTransactionKeys.has(transactionProcessingKey)) { return; } @@ -534,9 +595,7 @@ const makeInitializedClient = (options: { return result; }), - getPaywallForLocation: ( - locationSlug: InferGetPaywallLocationInput - ) => + getPaywallForLocation: (locationSlug: LocationSlug) => Effect.gen(function* getPaywallForLocation() { const apiClient = yield* ApiClient; const identityManager = yield* IdentityManager; @@ -549,7 +608,7 @@ const makeInitializedClient = (options: { ...commonHeaders, "x-distinct-id": distinctId, }, - payload: { locationSlug }, + payload: { locationSlug: String(locationSlug) }, }); }), @@ -575,11 +634,14 @@ const makeInitializedClient = (options: { getProducts: () => Effect.gen(function* getProducts() { - const productDefinitions = extractProductDefinitions(options.schema); + const productDefinitions = options.schema.products; const nativeProducts = yield* loadProductsCached(productDefinitions); return mapNativeProductsToProductMap(productDefinitions, nativeProducts); }), + /** Read access to the schema fetched at init time. */ + getSchema: () => options.schema, + identify: ( distinctId: string, options: { @@ -622,10 +684,8 @@ const makeInitializedClient = (options: { processObservedTransaction, - purchase: ( - product: NonNullable< - InferGetProductResponseFromSchema[keyof InferGetProductResponseFromSchema] - >, + purchase: ( + product: SubscriptionProduct, _options: { method?: "native"; } @@ -679,7 +739,7 @@ const makeInitializedClient = (options: { } const sendResult = yield* Effect.exit( - processQueuedAnalyticsBatch(queuedBatch, standardizedProperties), + processQueuedAnalyticsBatch(queuedBatch, standardizedProperties) ); if (sendResult._tag === "Failure") { const failure = Cause.squash(sendResult.cause); @@ -688,7 +748,9 @@ const makeInitializedClient = (options: { queuedBatch, Date.now() + (failure.retryAfterMs ?? - getAnalyticsRetryDelayMs((queuedBatch[0]?.attempts ?? 0) + 1)), + getAnalyticsRetryDelayMs( + (queuedBatch[0]?.attempts ?? 0) + 1 + )) ); scheduleFlushTimer(); return; @@ -710,12 +772,19 @@ const makeInitializedClient = (options: { clearFlushTimer(); }), - transferAnalyticsEvents: (events: ReadonlyArray<{ eventName: string; properties: Record }>) => + transferAnalyticsEvents: ( + events: ReadonlyArray<{ + eventName: string; + properties: Record; + }> + ) => Effect.sync(() => { for (const event of events) { const normalized = event.eventName.trim(); if (!normalized) continue; - analyticsQueue.push(createQueuedAnalyticsEvent(normalized, event.properties)); + analyticsQueue.push( + createQueuedAnalyticsEvent(normalized, event.properties) + ); } }), @@ -761,7 +830,8 @@ const makeInitializedClient = (options: { return null; } - let lifecycleState: AppLifecycleState | null = appState.currentState ?? null; + let lifecycleState: AppLifecycleState | null = + appState.currentState ?? null; const subscription = appState.addEventListener("change", (nextAppState) => { const previousAppState = lifecycleState; @@ -809,8 +879,8 @@ const makeInitializedClient = (options: { }; }; -const loadProductsCached = ( - productDefinitions: ExtractSchemaProductDefinitions +const loadProductsCached = ( + productDefinitions: Readonly> ) => Effect.gen(function* loadProductsCached() { const cacheManager = yield* CacheManager; @@ -830,8 +900,7 @@ const loadProductsCached = ( return cachedProducts.value; } - const nativeProducts = - yield* paymentAdapter.getProducts(productDefinitions); + const nativeProducts = yield* paymentAdapter.getProducts(productDefinitions); yield* Effect.logDebug("Products fetched from native adapter", { products: nativeProducts, @@ -845,33 +914,26 @@ const loadProductsCached = ( return nativeProducts; }); -const mapNativeProductsToProductMap = ( - productDefinitions: ExtractSchemaProductDefinitions, +const mapNativeProductsToProductMap = ( + productDefinitions: Readonly>, nativeProducts: Product[] -) => { - const productMap: InferGetProductResponseFromSchema = - {} as InferGetProductResponseFromSchema; - - for (const productDefinitionKey of Object.keys(productDefinitions)) { - const productDefinition = - productDefinitions[ - productDefinitionKey as ExtractSchemaProductKeys - ]; +): ProductsBySlug => { + const productMap = {} as Record; + + for (const slug of Object.keys(productDefinitions)) { const nativeProduct = nativeProducts.find( - (nativeProduct) => nativeProduct.slug === productDefinition.slug + (nativeProduct) => nativeProduct.slug === slug ); if (nativeProduct) { - productMap[productDefinitionKey as ExtractSchemaProductKeys] = - nativeProduct as InferGetProductResponseFromSchema[ExtractSchemaProductKeys]; + productMap[slug] = nativeProduct as SubscriptionProduct; continue; } - productMap[productDefinitionKey as ExtractSchemaProductKeys] = - null as InferGetProductResponseFromSchema[ExtractSchemaProductKeys]; + productMap[slug] = null; } - return productMap; + return productMap as ProductsBySlug; }; const buildTransactionProcessingKey = (transaction: Transaction) => @@ -903,8 +965,8 @@ const mapTransactionToSyncPayload = (transaction: Transaction) => { }; }; -const generateCacheKeyFromProductDefinitions = ( - productDefinitions: ExtractSchemaProductDefinitions +const generateCacheKeyFromProductDefinitions = ( + productDefinitions: Readonly> ) => `native-products:${JSON.stringify(productDefinitions)}`; const resolveIngestEventsUrl = (options: { diff --git a/libraries/react-native/src/client-react-native.ts b/libraries/react-native/src/client-react-native.ts index 736219377..0dd0b0d0a 100644 --- a/libraries/react-native/src/client-react-native.ts +++ b/libraries/react-native/src/client-react-native.ts @@ -3,7 +3,6 @@ import { Platform as RNPlatform } from "react-native"; import { VoidhashClient, type VoidhashClientOptions } from "./client"; import { EventBus } from "./core/event-bus"; -import type { VoidhashSchema } from "./core/schema"; import { SchemeNotSetError } from "./errors"; import { voidhashProviderFactory } from "./react/components/provider"; import { useRetrieveAppStoreProduct } from "./react/hooks/app-store/use-retrieve-app-store-product"; @@ -16,10 +15,18 @@ import { paywallByLocationHookFactory } from "./react/hooks/use-paywall-by-locat import { productsHookFactory } from "./react/hooks/use-products"; import { purchaseHookFactory } from "./react/hooks/use-purchase"; -export function createVoidhashClient( +/** + * Bootstrap the Voidhash React Native SDK for a project. + * + * After the server-first redesign: + * - There is no schema argument. The schema lives on the server and is + * fetched on `Provider` mount. + * - Type safety for product / location / perk slugs comes from the generated + * `voidhash.gen.d.ts` (run `voidhash types generate`). + */ +export function createVoidhashClient( publishableKey: string, - schema: VoidhashClientOptions["schema"], - options: Omit, "schema"> + options: VoidhashClientOptions = {} ) { const baseUrl = options.baseUrl || "https://api.voidhash.com"; const debug = options.debug ?? false; @@ -40,10 +47,9 @@ export function createVoidhashClient( const eventBus = new EventBus(); const platform = RNPlatform.OS === "ios" ? "ios" : "android"; - const client = new VoidhashClient( + const client = new VoidhashClient( distinctId, scheme, - schema, baseUrl, ingestUrl, publishableKey, @@ -51,7 +57,8 @@ export function createVoidhashClient( unstableSwallowErrors, eventBus, platform, - debug + debug, + options.unstable_internalSchema ); const { provider, context, useVoidhash } = voidhashProviderFactory(client); diff --git a/libraries/react-native/src/client.tsx b/libraries/react-native/src/client.tsx index 63b44e830..c6c5259d0 100644 --- a/libraries/react-native/src/client.tsx +++ b/libraries/react-native/src/client.tsx @@ -13,24 +13,27 @@ import { AppStoreAdapter } from "./core/payment-adapters/app-store-adapter"; import { GooglePlayAdapter } from "./core/payment-adapters/google-play-adapter"; import { type PlatformInfo } from "./core/platform/platform-provider"; import { ReactNativePlatformProvider } from "./core/platform/react-native-platform-provider"; -import type { - InferGetPaywallLocationInput, - InferGetProductResponseFromSchema, - VoidhashSchema, -} from "./core/schema"; +import type { LocationSlug, ProductSlug } from "./core/schema/registry"; +import type { RuntimeSchema } from "./core/schema/runtime"; import { SdkConfiguration } from "./core/sdk-configuration"; import { ReadOnlyModePurchaseNotAllowedError, VoidhashError } from "./errors"; import { AnalyticsService } from "./core/analytics/service"; +import type { SubscriptionProduct } from "./core/entities/product"; -export interface VoidhashClientOptions { +export interface VoidhashClientOptions { baseUrl?: string; debug?: boolean; distinctId?: string; ingestUrl?: string; readOnly?: boolean; - schema: TSchema; scheme?: string; unstable_swallowErrors?: boolean; + /** + * Test/internal escape hatch — inject a known runtime schema instead of + * letting the SDK fetch from the server on init. Not part of the public + * API; production code should rely on the server-side schema endpoint. + */ + unstable_internalSchema?: RuntimeSchema; } const CreateEffectRuntime = ( @@ -82,31 +85,33 @@ type UninitializedEffectClient = ReturnType< typeof VoidhashEffectClient.makeUnitializedClient >; -type InitializedEffectClient = ReturnType< - typeof VoidhashEffectClient.makeInitializedClient +type InitializedEffectClient = ReturnType< + typeof VoidhashEffectClient.makeInitializedClient >; -export class VoidhashClient { +export class VoidhashClient { private _isInitialized = false; private analyticsFlushInFlight: Promise | null = null; private appLifecycleSubscription: { remove: () => void } | null = null; - private preInitAnalyticsBuffer: Array<{ eventName: string; properties: Record }> = []; + private preInitAnalyticsBuffer: Array<{ + eventName: string; + properties: Record; + }> = []; private initialDistinctId: string | null; private readOnly: boolean; private scheme: string; - private schema: TSchema; + private internalSchema: RuntimeSchema | undefined; private unstableSwallowErrors: boolean; private eventBus: EventBus; private effectRuntime: ReturnType; private unitializedClient: UninitializedEffectClient; - private initializedClient?: InitializedEffectClient; + private initializedClient?: InitializedEffectClient; constructor( initialDistinctId: string | null, scheme: string, - schema: TSchema, baseUrl: string, ingestUrl: string | undefined, publishableKey: string, @@ -114,12 +119,13 @@ export class VoidhashClient { unstableSwallowErrors: boolean, eventBus: EventBus, platform: Exclude, - debug = false + debug = false, + internalSchema?: RuntimeSchema ) { this.initialDistinctId = initialDistinctId; this.readOnly = readOnly; this.scheme = scheme; - this.schema = schema; + this.internalSchema = internalSchema; this.unstableSwallowErrors = unstableSwallowErrors; this.eventBus = eventBus; this.effectRuntime = CreateEffectRuntime( @@ -134,10 +140,7 @@ export class VoidhashClient { this.unitializedClient = VoidhashEffectClient.makeUnitializedClient(); } - private async runSideEffect( - operation: string, - effect: () => Promise - ) { + private async runSideEffect(operation: string, effect: () => Promise) { try { await effect(); } catch (error) { @@ -149,16 +152,18 @@ export class VoidhashClient { console.warn(`[voidhash] swallowed error in ${operation}`, error); } } + /** - * Initializes the voidhash client. + * Initializes the voidhash client. Fetches the runtime schema from the + * server (or uses the injected internal schema if one was provided for tests). * @throws {FailedToInitializeNativeAdapterError} If the payment adapter fails to initialize */ async init() { await this.runSideEffect("init", async () => { const initializedClient = await this.runEffect( - this.unitializedClient.init({ + this.unitializedClient.init({ distinctId: this.initialDistinctId ?? undefined, - schema: this.schema, + internalSchema: this.internalSchema, }), "FAILED_TO_INITIALIZE_VOIDHASH_CLIENT" ); @@ -196,7 +201,10 @@ export class VoidhashClient { "FAILED_TO_CAPTURE_STARTUP_EVENTS" ).catch((error) => { // biome-ignore lint/suspicious/noConsole: This warning is intentionally surfaced in all environments. - console.warn("[voidhash] failed to capture automatic startup analytics", error); + console.warn( + "[voidhash] failed to capture automatic startup analytics", + error + ); }); this.appLifecycleSubscription = this.effectRuntime.runSync( @@ -217,7 +225,10 @@ export class VoidhashClient { await this.runSideEffect("end", async () => { this.ensureInitialized(); await this.flush(); - await this.runEffect(this.initializedClient!.end(), "FAILED_TO_END_VOIDHASH_CLIENT"); + await this.runEffect( + this.initializedClient!.end(), + "FAILED_TO_END_VOIDHASH_CLIENT" + ); this.appLifecycleSubscription?.remove(); this.appLifecycleSubscription = null; this._isInitialized = false; @@ -233,16 +244,21 @@ export class VoidhashClient { /** * Returns currently identified customer. - * @returns Customer object. */ async getCurrentCustomer(forceFetch = false) { this.ensureInitialized(); - return this.runEffect(this.initializedClient!.getCurrentCustomer(forceFetch), "FAILED_TO_GET_CURRENT_CUSTOMER"); + return this.runEffect( + this.initializedClient!.getCurrentCustomer(forceFetch), + "FAILED_TO_GET_CURRENT_CUSTOMER" + ); } async getDistinctId() { this.ensureInitialized(); - return this.runEffect(this.initializedClient!.getDistinctId(), "FAILED_TO_GET_DISTINCT_ID"); + return this.runEffect( + this.initializedClient!.getDistinctId(), + "FAILED_TO_GET_DISTINCT_ID" + ); } /** @@ -280,47 +296,45 @@ export class VoidhashClient { /** * Returns feature flag evaluation results. - * @param flagKeys - Optional array of specific flag keys to evaluate. If omitted, evaluates all flags. - * @returns Feature flags evaluation result with enabled status, variant keys, and payloads. */ async getFeatureFlags(flagKeys?: string[]) { this.ensureInitialized(); - return this.runEffect(this.initializedClient!.getFeatureFlags(flagKeys), "FAILED_TO_GET_FEATURE_FLAGS"); + return this.runEffect( + this.initializedClient!.getFeatureFlags(flagKeys), + "FAILED_TO_GET_FEATURE_FLAGS" + ); } /** * Resolves the currently assigned paywall showing for a location slug. */ - async getPaywallForLocation( - locationSlug: InferGetPaywallLocationInput - ) { + async getPaywallForLocation(locationSlug: LocationSlug) { this.ensureInitialized(); - return this.runEffect(this.initializedClient!.getPaywallForLocation(locationSlug), "FAILED_TO_GET_PAYWALL_FOR_LOCATION"); + return this.runEffect( + this.initializedClient!.getPaywallForLocation(locationSlug), + "FAILED_TO_GET_PAYWALL_FOR_LOCATION" + ); } /** * Returns products available on the current platform. - * @throws {NotInitializedError} If the voidhash client is not initialized - * @throws {FailedToGetProductsError} If the payment adapter fails to get products - * @returns A map of product definitions to products. Each value can be null if the product is not available on the current platform. + * Keys are the project's product slugs (resolved via the generated + * `voidhash.gen.d.ts`). Values are `null` when the underlying store SDK + * doesn't know about that product. */ async getProducts() { this.ensureInitialized(); - return this.runEffect(this.initializedClient!.getProducts(), "FAILED_TO_GET_PRODUCTS"); + return this.runEffect( + this.initializedClient!.getProducts(), + "FAILED_TO_GET_PRODUCTS" + ); } /** * Purchases a product. - * @throws {NotInitializedError} Voidhash client is not initialized. Call init() before calling this method. - * @throws {FailedToBuyProductError} Failed to buy the product. - * @throws {ProductNotFoundError} Product not found on the current platform. - * @throws {PurchasePendingError} The purchase is pending. The purchase will be completed in the background. - * @throws {PurchaseCancelledError} The customer has cancelled the purchase */ async purchase( - product: NonNullable< - InferGetProductResponseFromSchema[keyof InferGetProductResponseFromSchema] - >, + product: SubscriptionProduct, _options: { method?: "native"; } @@ -330,16 +344,23 @@ export class VoidhashClient { throw new ReadOnlyModePurchaseNotAllowedError(); } - await this.runEffect(this.initializedClient!.purchase(product, _options), "FAILED_TO_PURCHASE"); + await this.runEffect( + this.initializedClient!.purchase(product, _options), + "FAILED_TO_PURCHASE" + ); } /** - * Restores purchases by reconciling pending/past store transactions and refreshing customer state. + * Restores purchases by reconciling pending/past store transactions and + * refreshing customer state. */ async restorePurchases() { await this.runSideEffect("restorePurchases", async () => { this.ensureInitialized(); - await this.runEffect(this.initializedClient!.restorePurchases(), "FAILED_TO_RESTORE_PURCHASES"); + await this.runEffect( + this.initializedClient!.restorePurchases(), + "FAILED_TO_RESTORE_PURCHASES" + ); }); } @@ -388,27 +409,23 @@ export class VoidhashClient { // IOS only methods // =============================== - /** - * Presents the code redemption sheet. - * @throws {UnsupportedPlatformError} If the platform does not support the code redemption sheet - * @throws {VoidhashError} If the code redemption sheet fails to present - */ async iosPresentCodeRedemptionSheet() { await this.runSideEffect("iosPresentCodeRedemptionSheet", async () => { this.ensureInitialized(); - await this.runEffect(this.initializedClient!.iosPresentCodeRedemptionSheet(), "FAILED_TO_PRESENT_CODE_REDEMPTION_SHEET"); + await this.runEffect( + this.initializedClient!.iosPresentCodeRedemptionSheet(), + "FAILED_TO_PRESENT_CODE_REDEMPTION_SHEET" + ); }); } - /** - * Shows the manage subscriptions screen. - * @throws {UnsupportedPlatformError} If the platform does not support the manage subscriptions screen - * @throws {VoidhashError} If the manage subscriptions screen fails to show - */ async iosShowManageSubscriptions() { await this.runSideEffect("iosShowManageSubscriptions", async () => { this.ensureInitialized(); - await this.runEffect(this.initializedClient!.iosShowManageSubscriptions(), "FAILED_TO_SHOW_MANAGE_SUBSCRIPTIONS"); + await this.runEffect( + this.initializedClient!.iosShowManageSubscriptions(), + "FAILED_TO_SHOW_MANAGE_SUBSCRIPTIONS" + ); }); } @@ -420,8 +437,13 @@ export class VoidhashClient { return this.eventBus; } - internal_getSchema() { - return this.schema; + /** + * Returns the runtime schema fetched at init time. Returns `null` when the + * client hasn't been initialized yet. Used by hooks that need to resolve + * slugs to product metadata. + */ + internal_getSchema(): RuntimeSchema | null { + return this.initializedClient?.getSchema() ?? null; } internal_getSuccessCallbackBaseUrl() { @@ -440,7 +462,10 @@ export class VoidhashClient { } // biome-ignore lint/suspicious/noExplicitAny: Effect requires service type parameter - private async runEffect(effect: Effect.Effect, errorCode: string): Promise { + private async runEffect( + effect: Effect.Effect, + errorCode: string + ): Promise { const result = await this.effectRuntime.runPromiseExit(effect); if (Exit.isSuccess(result)) return result.value; throw toErrorWithMessage(errorCode, Cause.squash(result.cause)); @@ -466,3 +491,6 @@ export class VoidhashClient { // TODO: Implement } } + +/** Convenience re-export to keep `ProductSlug` reachable from this module. */ +export type { ProductSlug }; diff --git a/libraries/react-native/src/core/networking/api-client.ts b/libraries/react-native/src/core/networking/api-client.ts index 3a8230e8e..0ee7d5984 100644 --- a/libraries/react-native/src/core/networking/api-client.ts +++ b/libraries/react-native/src/core/networking/api-client.ts @@ -70,8 +70,38 @@ const normalizeFeatureFlagsResponse = ( })), }); +/** + * Stub error surfaced when the server-side `GET /sdk/schema` endpoint is not + * yet deployed (the server-side work is tracked separately per the + * server-first redesign plan). When the endpoint ships, this stub is replaced + * by a call into the generated client. + */ +const SCHEMA_ENDPOINT_NOT_IMPLEMENTED_MESSAGE = + "[voidhash] The GET /sdk/schema endpoint is not yet available on the server. " + + "Schema-dependent hooks (useProducts, usePurchase) will return empty data until it ships. " + + "See the server-first redesign plan for tracking."; + const bindReactNativeSdkClient = (client: VoidhashCoreClient) => ({ sdk: { + /** + * Fetch the project's schema from the server. Called once on `Provider` + * mount and cached for the session. Authenticates via the publishable key + * (same credential the SDK uses for paywall resolution, etc.). + * + * TODO(server): wire up `client.sdkGetSchema(...)` once the server endpoint + * is implemented. For now this returns an empty schema and logs once so + * the SDK remains usable while the backend work is in flight. + */ + getSchema: (_request: { headers: ReactNativeSdkHeaders }) => + Effect.gen(function* getSchema() { + yield* Effect.logWarning(SCHEMA_ENDPOINT_NOT_IMPLEMENTED_MESSAGE); + return { + version: "", + products: {} as Readonly>, + locations: {} as Readonly>, + perks: {} as Readonly>, + }; + }), evaluateFeatureFlags: (request: { headers: ReactNativeSdkHeaders; payload: EvaluateFeatureFlagsBody; diff --git a/libraries/react-native/src/core/payment-adapters/app-store-adapter.ts b/libraries/react-native/src/core/payment-adapters/app-store-adapter.ts index afda9a1a9..985eb068f 100644 --- a/libraries/react-native/src/core/payment-adapters/app-store-adapter.ts +++ b/libraries/react-native/src/core/payment-adapters/app-store-adapter.ts @@ -7,8 +7,7 @@ import type { StorekitProduct } from "../../specs/ios/StorekitProduct.nitro"; import type { StorekitTransaction } from "../../specs/ios/StorekitTransaction.nitro"; import { Product, type SubscriptionProduct } from "../entities/product"; import { Transaction } from "../entities/transaction"; -import type { ExtractSchemaProductDefinitions, VoidhashSchema } from "../schema"; -import { ProductDefinition } from "../schema/products/base"; +import type { RuntimeProductDefinition } from "../schema/runtime"; import { FailedToAcknowledgePurchaseError, FailedToBuyProductError, @@ -187,11 +186,8 @@ export const AppStoreAdapter = Layer.succeed(PaymentAdapter, { }); }, - getProducts< - TSchema extends VoidhashSchema, - TDefinedProducts extends ExtractSchemaProductDefinitions, - >( - productDefinitions: TDefinedProducts + getProducts( + productDefinitions: Readonly> ): Effect.Effect< Product[], NativeAdapterNotInitializedError | FailedToGetProductsError, @@ -206,23 +202,21 @@ export const AppStoreAdapter = Layer.succeed(PaymentAdapter, { ); } - const productDefinitionsArray = Object.values( - productDefinitions - ) as TDefinedProducts[keyof TDefinedProducts][]; + const productDefinitionsArray = Object.values(productDefinitions); - const getProductId = ( - productDefinition: TDefinedProducts[keyof TDefinedProducts] - ) => productDefinition.configuration.providers.appleAppStore?.productId; + const getProductId = (productDefinition: RuntimeProductDefinition) => + productDefinition.configuration.providers.appleAppStore?.productId; const productIds = productDefinitionsArray .map((productDefinition) => { - if (productDefinition instanceof ProductDefinition) { - return { - id: getProductId(productDefinition), - slug: productDefinition.slug, - }; + const id = getProductId(productDefinition); + if (!id) { + return null; } - return null; + return { + id, + slug: productDefinition.slug, + }; }) .filter( (slugIdPair): slugIdPair is { slug: string; id: string } => @@ -264,15 +258,18 @@ export const AppStoreAdapter = Layer.succeed(PaymentAdapter, { }); return yield* Effect.succeed( - nativeProducts.map((nativeProduct) => - mapStorekitProductToProduct( - productDefinitionsArray.find( + nativeProducts + .map((nativeProduct) => { + const matched = productDefinitionsArray.find( (productDefinition) => getProductId(productDefinition) === nativeProduct.id - ) as TDefinedProducts[keyof TDefinedProducts], - nativeProduct - ) - ) + ); + if (!matched) { + return null; + } + return mapStorekitProductToProduct(matched, nativeProduct); + }) + .filter((p): p is Product => p !== null) ); }); }, @@ -403,11 +400,8 @@ export const AppStoreAdapter = Layer.succeed(PaymentAdapter, { }); // Helper functions for mapping StoreKit objects to our domain objects -function mapStorekitProductToProduct< - TSchema extends VoidhashSchema, - TDefinedProducts extends ExtractSchemaProductDefinitions, ->( - productDefinition: TDefinedProducts[keyof TDefinedProducts], +function mapStorekitProductToProduct( + productDefinition: RuntimeProductDefinition, nativeProduct: StorekitProduct ): Product { return new Product( diff --git a/libraries/react-native/src/core/payment-adapters/google-play-adapter.ts b/libraries/react-native/src/core/payment-adapters/google-play-adapter.ts index fafbf78de..ca1921ff7 100644 --- a/libraries/react-native/src/core/payment-adapters/google-play-adapter.ts +++ b/libraries/react-native/src/core/payment-adapters/google-play-adapter.ts @@ -7,11 +7,7 @@ import type { GoogleBillingProductDetail } from "../../specs/android/GoogleBilli import type { GoogleBillingPurchase } from "../../specs/android/GoogleBillingPurchase.nitro"; import { Product, type SubscriptionProduct } from "../entities/product"; import { Transaction } from "../entities/transaction"; -import { - type ExtractSchemaProductDefinitions, - ProductDefinition, - type VoidhashSchema, -} from "../schema"; +import type { RuntimeProductDefinition } from "../schema/runtime"; import { FailedToAcknowledgePurchaseError, FailedToBuyProductError, @@ -195,11 +191,8 @@ export const GooglePlayAdapter = Layer.succeed(PaymentAdapter, { return Effect.succeed([]); }, - getProducts< - TSchema extends VoidhashSchema, - TDefinedProducts extends ExtractSchemaProductDefinitions, - >( - productDefinitions: TDefinedProducts + getProducts( + productDefinitions: Readonly> ): Effect.Effect< Product[], NativeAdapterNotInitializedError | FailedToGetProductsError, @@ -214,15 +207,13 @@ export const GooglePlayAdapter = Layer.succeed(PaymentAdapter, { ); } - const productIds = Object.values(productDefinitions).map((product) => { - if (product instanceof ProductDefinition) { - return { - id: product.configuration.providers.googlePlay?.productId, - slug: product.slug, - }; - } - return null; - }); + const productIds = Object.values(productDefinitions) + .map((product) => { + const id = product.configuration.providers.googlePlay?.productId; + if (!id) return null; + return { id, slug: product.slug }; + }) + .filter((p): p is { id: string; slug: string } => p !== null); const inappProducts = yield* Effect.tryPromise({ catch: (error) => @@ -233,9 +224,7 @@ export const GooglePlayAdapter = Layer.succeed(PaymentAdapter, { try: () => GoogleBilling!.getItemsByType( "inapp", - productIds - .map((pair) => pair?.id) - .filter((id): id is string => id !== null) + productIds.map((pair) => pair.id) ), }); @@ -248,9 +237,7 @@ export const GooglePlayAdapter = Layer.succeed(PaymentAdapter, { try: () => GoogleBilling!.getItemsByType( "subs", - productIds - .map((pair) => pair?.id) - .filter((id): id is string => id !== null) + productIds.map((pair) => pair.id) ), }); @@ -258,8 +245,7 @@ export const GooglePlayAdapter = Layer.succeed(PaymentAdapter, { return yield* Effect.succeed( allProducts.map((nativeProduct) => mapGoogleBillingProductToProduct( - productIds.find((pair) => pair?.id === nativeProduct.id)?.slug ?? - "", + productIds.find((pair) => pair.id === nativeProduct.id)?.slug ?? "", nativeProduct ) ) diff --git a/libraries/react-native/src/core/payment-adapters/payment-adapter.ts b/libraries/react-native/src/core/payment-adapters/payment-adapter.ts index b93d4987d..f6b6bdd74 100644 --- a/libraries/react-native/src/core/payment-adapters/payment-adapter.ts +++ b/libraries/react-native/src/core/payment-adapters/payment-adapter.ts @@ -2,10 +2,7 @@ import { ServiceMap, type Effect } from "effect"; import type { Product, SubscriptionProduct } from "../entities/product"; import type { Transaction } from "../entities/transaction"; -import type { - ExtractSchemaProductDefinitions, - VoidhashSchema, -} from "../schema"; +import type { RuntimeProductDefinition } from "../schema/runtime"; import type { FailedToAcknowledgePurchaseError, FailedToBuyProductError, @@ -22,18 +19,23 @@ import type { UserCancelledError, } from "./errors"; -export class PaymentAdapter extends ServiceMap.Service void ): Effect.Effect; endConnection(): Effect.Effect; - getProducts< - TSchema extends VoidhashSchema, - TDefinedProducts extends ExtractSchemaProductDefinitions, - >( - productDefinitions: TDefinedProducts + /** + * Fetch products from the underlying native store. Receives the product + * definitions exactly as the server returned them at SDK init time (keyed + * by slug). Implementations resolve the platform-specific store productId + * from the definition's `configuration.providers.*`. + */ + getProducts( + productDefinitions: Readonly> ): Effect.Effect< Product[], NativeAdapterNotInitializedError | FailedToGetProductsError, @@ -80,83 +82,5 @@ export class PaymentAdapter extends ServiceMap.Service; - }>()("rn-voidhash/PaymentAdapter") {} - -// export interface PaymentAdapter { -// initConnection( -// onPurchase?: (transaction: Transaction) => void -// ): Effect.Effect; - -// endConnection(): Effect.Effect; - -// getProducts< -// TSchema extends VoidhashSchema, -// TDefinedProducts extends ExtractSchemaProductDefinitions -// >( -// productDefinitions: TDefinedProducts -// ): Effect.Effect< -// Product[], -// NativeAdapterNotInitializedError | FailedToGetProductsError, -// never -// >; - -// buyProduct( -// product: TSubscriptionProduct, -// quantity?: number, -// appAccountToken?: string -// ): Effect.Effect< -// Transaction, -// | UserCancelledError -// | PurchasePendingError -// | NativeAdapterNotInitializedError -// | ProductNotFoundError -// | FailedToBuyProductError, -// never -// >; - -// acknowledgePurchase( -// transaction: Transaction -// ): Effect.Effect; - -// getPurchaseHistory( -// onlyIncludeActiveItems?: boolean -// ): Effect.Effect; - -// getPendingTransactions(): Effect.Effect< -// Transaction[], -// GetPendingTransactionsError, -// never -// >; - -// // Platform specific methods -// presentCodeRedemptionSheet?(): Effect.Effect< -// void, -// FailedToPresentCodeRedemptionSheetError, -// never -// >; - -// showManageSubscriptions?(): Effect.Effect< -// void, -// FailedToShowManageSubscriptionsError, -// never -// >; -// } - -// export function createPaymentAdapter( -// platformProvider: PlatformProvider, -// logger: Logger -// ): PaymentAdapter { -// const platform = platformProvider.getPlatform(); - -// if (platform === 'ios') { -// const { AppStoreAdapter } = require('./app-store-adapter'); -// return new AppStoreAdapter(logger); -// } - -// if (platform === 'android') { -// const { GooglePlayAdapter } = require('./google-play-adapter'); -// return new GooglePlayAdapter(); -// } - -// throw new Error(`Unsupported platform: ${platform}`); -// } + } +>()("rn-voidhash/PaymentAdapter") {} diff --git a/libraries/react-native/src/core/schema/builder.ts b/libraries/react-native/src/core/schema/builder.ts deleted file mode 100644 index 23d8cef79..000000000 --- a/libraries/react-native/src/core/schema/builder.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { SCHEMA_KIND, SchemaKind } from "./constants"; -import { PaywallLocationDefinition } from "./paywall-location"; -import { - type SubscriptionDefinitionProperties, - subscription as createSubscription, -} from "./products/subscription"; -import type { - DefinedPerks, - DefinedProviders, - InferProductConfigurationPerks, - InferProductConfigurationProviders, -} from "./types"; - -/** - * Configuration for creating a schema configuration - */ -export interface SchemaConfig< - TProviders extends DefinedProviders, - TPerks extends DefinedPerks, -> { - providers: TProviders; - perks: TPerks; -} - -/** - * Simplified subscription configuration that doesn't require callback functions - */ -export interface SubscriptionConfig< - TProviders extends DefinedProviders, - TPerks extends DefinedPerks, -> { - name: string; - perks: Omit, "_">; - providers: Omit, "_">; -} - -export interface LocationConfig { - description?: string; - name: string; -} - -/** - * Schema configuration object with access to providers, perks, and product builders - */ -export interface SchemaConfiguration< - TProviders extends DefinedProviders, - TPerks extends DefinedPerks, -> { - readonly [SCHEMA_KIND]: typeof SchemaKind.SchemaConfiguration; - providers: TProviders; - perks: TPerks; - subscription: ( - slug: string, - subscriptionConfig: SubscriptionConfig - ) => ReturnType; - location: ( - slug: TSlug, - locationConfig: LocationConfig - ) => PaywallLocationDefinition; -} - -/** - * Creates a schema configuration with improved DX. - * Providers and perks are defined once, then products reference them directly. - * Export the configuration to access providers and perks later. - * - * @example - * ```typescript - * export const schema = schemaConfiguration({ - * providers: { - * googlePlay: true, - * appleAppStore: true - * }, - * perks: { - * allAccess: unlockablePerk('all-access', { name: 'All Access' }) - * } - * }); - * - * export const monthlySub = schema.subscription('monthly_sub', { - * name: 'Monthly', - * perks: { allAccess: true }, - * providers: { - * googlePlay: { productId: 'com.example.monthly' }, - * appleAppStore: { productId: 'monthly_sub' } - * } - * }); - * - * // Access perks later - * const allAccessPerk = schema.perks.allAccess; - * ``` - */ -export function schemaConfiguration< - TProviders extends DefinedProviders, - TPerks extends DefinedPerks, ->( - config: SchemaConfig -): SchemaConfiguration { - const { providers, perks } = config; - - return { - [SCHEMA_KIND]: SchemaKind.SchemaConfiguration, - providers, - perks, - /** - * Define a subscription product - */ - subscription: ( - slug: string, - subscriptionConfig: SubscriptionConfig - ) => - createSubscription(slug, (s) => { - const perksConfig = s.configurePerks( - perks, - () => subscriptionConfig.perks - ); - const providersConfig = s.configureProviders( - providers, - () => subscriptionConfig.providers - ); - - return { - name: subscriptionConfig.name, - perks: perksConfig, - providers: providersConfig, - } as SubscriptionDefinitionProperties & { - perks: InferProductConfigurationPerks; - providers: InferProductConfigurationProviders; - }; - }), - location: ( - slug: TSlug, - locationConfig: LocationConfig - ) => - new PaywallLocationDefinition(slug, { - description: locationConfig.description, - name: locationConfig.name, - }), - }; -} diff --git a/libraries/react-native/src/core/schema/constants.ts b/libraries/react-native/src/core/schema/constants.ts deleted file mode 100644 index d74d9d1f9..000000000 --- a/libraries/react-native/src/core/schema/constants.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Symbols used to identify schema entity types at runtime. - * These allow the CLI to reliably detect entity types without duck typing. - */ - -export const SCHEMA_KIND = Symbol.for("voidhash.schema.kind"); - -export const SchemaKind = { - Perk: "perk", - PaywallLocation: "paywall-location", - Product: "product", - SchemaConfiguration: "schema-configuration", -} as const; - -export type SchemaKindValue = (typeof SchemaKind)[keyof typeof SchemaKind]; diff --git a/libraries/react-native/src/core/schema/definitions.ts b/libraries/react-native/src/core/schema/definitions.ts deleted file mode 100644 index 950f4daf4..000000000 --- a/libraries/react-native/src/core/schema/definitions.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { DefinedPerks, DefinedProviders } from "./types"; - -export function paymentProviders( - config: TConfig -): Readonly { - return config; -} - -export function definePerks( - config: TConfig -): Readonly { - return config; -} diff --git a/libraries/react-native/src/core/schema/index.ts b/libraries/react-native/src/core/schema/index.ts index 5d4b28a3b..bb7af8240 100644 --- a/libraries/react-native/src/core/schema/index.ts +++ b/libraries/react-native/src/core/schema/index.ts @@ -1,10 +1,2 @@ -export * from "./builder"; -export * from "./constants"; -export * from "./perk"; -export * from "./paywall-location"; -export * from "./products/base"; -export { - type SubscriptionDefinitionProperties, - SubscriptionProductDefinition, -} from "./products/subscription"; -export * from "./types"; +export * from "./registry"; +export * from "./runtime"; diff --git a/libraries/react-native/src/core/schema/paywall-location.ts b/libraries/react-native/src/core/schema/paywall-location.ts deleted file mode 100644 index 3fa736b09..000000000 --- a/libraries/react-native/src/core/schema/paywall-location.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { SCHEMA_KIND, SchemaKind } from "./constants"; - -export interface PaywallLocationDefinitionProperties { - description?: string; - name: string; -} - -export class PaywallLocationDefinition { - readonly [SCHEMA_KIND] = SchemaKind.PaywallLocation; - slug: TSlug; - name: string; - description: string | null; - - constructor(slug: TSlug, params: PaywallLocationDefinitionProperties) { - this.slug = slug; - this.name = params.name; - this.description = params.description ?? null; - } -} diff --git a/libraries/react-native/src/core/schema/perk.ts b/libraries/react-native/src/core/schema/perk.ts deleted file mode 100644 index b7eb355dc..000000000 --- a/libraries/react-native/src/core/schema/perk.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { SCHEMA_KIND, SchemaKind } from "./constants"; - -export abstract class PerkDefinition { - readonly [SCHEMA_KIND] = SchemaKind.Perk; - slug: string; - name: string; - constructor(slug: string, params: { name: string }) { - this.slug = slug; - this.name = params.name; - } -} - -export class UnlockablePerkDefinition extends PerkDefinition {} - -export const unlockablePerk = ( - slug: string, - params: { name: string } -): UnlockablePerkDefinition => new UnlockablePerkDefinition(slug, params); diff --git a/libraries/react-native/src/core/schema/products/base.ts b/libraries/react-native/src/core/schema/products/base.ts deleted file mode 100644 index 19af2b2b4..000000000 --- a/libraries/react-native/src/core/schema/products/base.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { SCHEMA_KIND, SchemaKind } from "../constants"; -import type { - AnyDefinedPerks, - AnyDefinedProviders, - InferProductConfigurationPerks, - InferProductConfigurationProviders, - ProductDefinitionConfiguration, - ProductDefinitionConfigurePerksFn, - ProductDefinitionConfigureProvidersFn, -} from "../types"; - -export abstract class ProductDefinition< - TType, - TProductProperties extends Record, - TProductDefinitionConfiguration extends ProductDefinitionConfiguration< - AnyDefinedProviders, - AnyDefinedPerks - >, - // TProductDefinitionConfiguration extends ReturnType< - // SubscriptionProductDefinitionConfigurationFn - // >, -> { - readonly [SCHEMA_KIND] = SchemaKind.Product; - type: TType; - slug: string; - private _properties: TProductProperties; - private _configuration: TProductDefinitionConfiguration; - - constructor( - type: TType, - slug: string, - properties: TProductProperties, - configuration: TProductDefinitionConfiguration - ) { - this.type = type; - this.slug = slug; - this._properties = properties; - this._configuration = configuration; - } - - get properties(): TProductProperties { - return this._properties; - } - - get configuration(): TProductDefinitionConfiguration { - return this._configuration; - } -} - -// export class ProductPerkConfiguration< -// TPerks extends DefinedPerks>, -// > { -// private _perks: TPerks; -// private _options: ProductPerkConfigurationOptions; - -// constructor(perks: TPerks, options: ProductPerkConfigurationOptions) { -// this._perks = perks; -// this._options = options; -// } - -// get perks(): TPerks { -// return this._perks; -// } - -// get options(): ProductPerkConfigurationOptions { -// return this._options; -// } -// } - -export function productConfigurationFactory() { - const configureProviders: ProductDefinitionConfigureProvidersFn = ( - paymentProviders, - configureFn - ) => { - const configuration = configureFn() as InferProductConfigurationProviders< - typeof paymentProviders - >; - configuration._ = { - paymentProviders, - }; - return configuration; - }; - - const configurePerks: ProductDefinitionConfigurePerksFn = ( - perks, - configureFn - ) => { - const configuration = configureFn() as InferProductConfigurationPerks< - typeof perks - >; - configuration._ = { - perks, - }; - return { - ...configuration, - _: { - perks, - }, - }; - }; - - return { - configurePerks, - configureProviders, - }; -} diff --git a/libraries/react-native/src/core/schema/products/subscription.ts b/libraries/react-native/src/core/schema/products/subscription.ts deleted file mode 100644 index 04700905f..000000000 --- a/libraries/react-native/src/core/schema/products/subscription.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { - DefinedPerks, - DefinedProviders, - InferProductDefinitionConfigurationFn, -} from "../types"; -import { ProductDefinition, productConfigurationFactory } from "./base"; - -export interface SubscriptionDefinitionProperties - extends Record { - name: string; -} - -const subscriptionConfigurationFactory = productConfigurationFactory(); - -export class SubscriptionProductDefinition< - TDefinitionProperties extends SubscriptionDefinitionProperties, - TDefinedProviders extends DefinedProviders = DefinedProviders, - TDefinedPerks extends DefinedPerks = DefinedPerks, -> extends ProductDefinition< - "subscription", - TDefinitionProperties, - ReturnType< - InferProductDefinitionConfigurationFn - > -> {} - -export function subscription< - TDefinedProviders extends DefinedProviders, - TDefinedPerks extends DefinedPerks, ->( - slug: string, - configurationFn: InferProductDefinitionConfigurationFn< - TDefinedProviders, - TDefinedPerks, - SubscriptionDefinitionProperties - > -) { - const configuration = configurationFn({ - configurePerks: subscriptionConfigurationFactory.configurePerks, - configureProviders: subscriptionConfigurationFactory.configureProviders, - }); - - const properties: SubscriptionDefinitionProperties = { - name: configuration.name, - }; - - return new SubscriptionProductDefinition< - SubscriptionDefinitionProperties, - TDefinedProviders, - TDefinedPerks - >("subscription", slug, properties, configuration); -} diff --git a/libraries/react-native/src/core/schema/registry.ts b/libraries/react-native/src/core/schema/registry.ts new file mode 100644 index 000000000..79da267d7 --- /dev/null +++ b/libraries/react-native/src/core/schema/registry.ts @@ -0,0 +1,49 @@ +/** + * Schema registry — the mechanism by which a generated `voidhash.gen.d.ts` + * teaches the SDK about the project-specific schema (product slugs, paywall + * location slugs, perk slugs) defined on the server. + * + * Usage in user code: the user runs `voidhash types generate`, which writes a + * declaration file augmenting this interface. Once that file is part of the + * project's TypeScript compilation, hooks like `usePaywallByLocation(slug)` + * autocomplete the literal union of valid slugs. + * + * When no augmentation is present (e.g. before the first generate, or in + * isolated tests), the resolved types degrade gracefully to `string`. + */ + +// biome-ignore lint/suspicious/noEmptyInterface: intentional - user code augments this +export interface VoidhashRegister {} + +/** + * Shape required of the augmented schema. Generated `voidhash.gen.d.ts` + * declares `interface VoidhashRegister { schema: { products: ...; locations: ...; perks: ... } }`. + */ +export interface VoidhashRegisterShape { + schema: { + products: string; + locations: string; + perks: string; + }; +} + +type Resolve = + VoidhashRegister extends { + schema: { [P in K]: infer V extends string }; + } + ? // When the augmentation declares an empty union (`never`) — e.g. the + // placeholder `voidhash.gen.d.ts` written before the first real + // generation — fall back to `string` so user code still compiles. + [V] extends [never] + ? string + : V + : string; + +/** Resolves to the literal union of product slugs declared on the server, or `string` when the .d.ts isn't loaded. */ +export type ProductSlug = Resolve<"products">; + +/** Resolves to the literal union of paywall location slugs declared on the server, or `string` when the .d.ts isn't loaded. */ +export type LocationSlug = Resolve<"locations">; + +/** Resolves to the literal union of perk slugs declared on the server, or `string` when the .d.ts isn't loaded. */ +export type PerkSlug = Resolve<"perks">; diff --git a/libraries/react-native/src/core/schema/runtime.ts b/libraries/react-native/src/core/schema/runtime.ts new file mode 100644 index 000000000..e0d8d07d7 --- /dev/null +++ b/libraries/react-native/src/core/schema/runtime.ts @@ -0,0 +1,65 @@ +/** + * Plain-data shapes representing the schema as fetched from the server at + * runtime. After the server-first redesign, these replace the old DSL-built + * `ProductDefinition` / `PaywallLocationDefinition` / `PerkDefinition` classes. + * + * The SDK fetches a `RuntimeSchema` on `Provider` mount and uses it for things + * like resolving product slugs to native store productIds when calling + * StoreKit / Google Play. + */ + +export interface RuntimeAppleAppStoreProductConfiguration { + readonly productId: string; +} + +export interface RuntimeGooglePlayProductConfiguration { + readonly productId: string; + readonly basePlanId?: string; +} + +export interface RuntimeProductProviders { + readonly appleAppStore?: RuntimeAppleAppStoreProductConfiguration; + readonly googlePlay?: RuntimeGooglePlayProductConfiguration; +} + +export interface RuntimeProductDefinition { + readonly slug: string; + readonly type: "subscription"; + readonly properties: { readonly name: string }; + readonly configuration: { + readonly providers: RuntimeProductProviders; + readonly perks: Readonly>; + }; +} + +export interface RuntimePaywallLocationDefinition { + readonly slug: string; + readonly name: string; + readonly description: string | null; +} + +export interface RuntimePerkDefinition { + readonly slug: string; + readonly name: string; +} + +/** + * The full schema as fetched from the server. Keyed by slug. + * + * The `version` is a sha256 hash of the schema state on the server and is + * what `voidhash types check` and the dev-mode runtime warning compare + * against the generated `.d.ts` header. + */ +export interface RuntimeSchema { + readonly version: string; + readonly products: Readonly>; + readonly locations: Readonly>; + readonly perks: Readonly>; +} + +export const createEmptyRuntimeSchema = (): RuntimeSchema => ({ + version: "", + products: {}, + locations: {}, + perks: {}, +}); diff --git a/libraries/react-native/src/core/schema/types.ts b/libraries/react-native/src/core/schema/types.ts deleted file mode 100644 index 62eb0adc7..000000000 --- a/libraries/react-native/src/core/schema/types.ts +++ /dev/null @@ -1,234 +0,0 @@ -import type { SubscriptionProduct } from "../entities/product"; -import type { PerkDefinition } from "./perk"; -import type { PaywallLocationDefinition } from "./paywall-location"; -import type { ProductDefinition } from "./products/base"; - -export type Simplify = { - [K in keyof T]: T[K]; -} & {}; - -// =============================== -// Payment Providers -// =============================== - -export interface GooglePlayProductConfiguration { - /** - * The unique identifier for the in-app product or subscription as configured in the Google Play Console. - * For subscriptions, this is the subscription product ID. - */ - productId: string; -} - -export type GooglePlaySubscriptionProductConfiguration = - GooglePlayProductConfiguration & { - /** - * The unique identifier for the base plan within a subscription product in the Google Play Console. - * This field is only relevant for subscription products. For one-time products, leave undefined. - */ - basePlanId?: string; - }; - -export interface AppleAppStoreProductConfiguration { - /** - * The unique identifier for the in-app purchase or subscription as configured in App Store Connect. - */ - productId: string; -} - -export interface PaymentProviders { - googlePlay: { - definition: true; - productTypes: { - subscription: GooglePlaySubscriptionProductConfiguration; - }; - }; - appleAppStore: { - definition: true; - productTypes: { - subscription: AppleAppStoreProductConfiguration; - }; - }; -} - -// =============================== -// Definitions -// =============================== - -export type DefinedProviders = { - [K in keyof PaymentProviders]?: PaymentProviders[K]["definition"]; -}; - -export type DefinedPerks = Record; - -// =============================== -// Products -// =============================== - -export interface BaseProductDefinitionProperties { - name: string; -} - -export type InferProductConfigurationProviders< - TDefinedProviders extends DefinedProviders, -> = { - [K in keyof TDefinedProviders]: TDefinedProviders[K] extends true - ? K extends keyof PaymentProviders - ? PaymentProviders[K]["productTypes"]["subscription"] - : never - : never; -} & { - _: { - paymentProviders: TDefinedProviders; - }; -}; - -export type InferProductConfigurationPerks = - { - [K in keyof TDefinedPerks]?: TDefinedPerks[K] extends PerkDefinition - ? true - : never; - } & { - _: { - perks: TDefinedPerks; - }; - }; - -export type AnyDefinedProviders = DefinedProviders; -export type AnyDefinedPerks = DefinedPerks; -export type AnyPaywallLocationDefinition = PaywallLocationDefinition; -export type AnyProductDefinition = ProductDefinition< - // biome-ignore lint/suspicious/noExplicitAny: ok - any, - // biome-ignore lint/suspicious/noExplicitAny: ok - Record, - // biome-ignore lint/suspicious/noExplicitAny: ok - any ->; - -/** - * Type representing any schema configuration object. - * Schema configurations contain providers and perks definitions, - * along with methods to create product definitions. - */ -export interface AnySchemaConfiguration { - providers: AnyDefinedProviders; - perks: AnyDefinedPerks; - // biome-ignore lint/suspicious/noExplicitAny: needed for flexibility - subscription: (...args: any[]) => AnyProductDefinition; - // biome-ignore lint/suspicious/noExplicitAny: needed for flexibility - location: (...args: any[]) => AnyPaywallLocationDefinition; -} - -export interface ProductDefinitionConfiguration< - TDefinedProviders extends DefinedProviders, - TDefinedPerks extends DefinedPerks, -> { - providers: InferProductConfigurationProviders; - perks: InferProductConfigurationPerks; -} - -export type ProductDefinitionConfigureProvidersFn = < - TDefinedProviders extends DefinedProviders, ->( - providers: TDefinedProviders, - configureFn: () => Omit< - InferProductConfigurationProviders, - "_" - > -) => InferProductConfigurationProviders; - -export type ProductDefinitionConfigurePerksFn = < - TDefinedPerks extends DefinedPerks, ->( - perks: TDefinedPerks, - configureFn: () => Omit, "_"> -) => InferProductConfigurationPerks; - -export type InferProductDefinitionConfigurationFn< - TDefinedProviders extends DefinedProviders = DefinedProviders, - TDefinedPerks extends DefinedPerks = DefinedPerks, - TDefinitionProperties extends Record = Record< - string, - unknown - >, -> = (funs: { - configureProviders: ProductDefinitionConfigureProvidersFn; - configurePerks: ProductDefinitionConfigurePerksFn; -}) => ProductDefinitionConfiguration & - TDefinitionProperties; - -// =============================== -// Get products -// =============================== - -export type InferGetProductResponseFromSchema = - Simplify<{ - [K in ExtractSchemaProductKeys]: TSchema[K] extends AnyProductDefinition - ? TSchema[K]["type"] extends "subscription" - ? SubscriptionProduct | null - : never - : never; - }>; - -// =============================== -// Schema -// =============================== - -/** - * A Voidhash schema can contain: - * - Product definitions (subscriptions, one-time purchases, etc.) - * - Schema configuration objects (which contain providers and perks) - */ -export type VoidhashSchema = Record< - string, - AnyProductDefinition | AnySchemaConfiguration | AnyPaywallLocationDefinition ->; - -export type ExtractSchemaKeys = keyof TSchema; - -export type ExtractSchemaProductKeys = { - [K in keyof TSchema]: TSchema[K] extends AnyProductDefinition ? K : never; -}[keyof TSchema]; - -export type ExtractSchemaProductDefinitions = { - [K in ExtractSchemaProductKeys]: TSchema[K] extends AnyProductDefinition - ? TSchema[K] - : never; -}; - -export type ExtractSchemaConfigurationKeys = { - [K in keyof TSchema]: TSchema[K] extends AnySchemaConfiguration ? K : never; -}[keyof TSchema]; - -export type ExtractSchemaConfigurations = { - [K in ExtractSchemaConfigurationKeys]: TSchema[K] extends AnySchemaConfiguration - ? TSchema[K] - : never; -}; - -export type ExtractSchemaPaywallLocationKeys = { - [K in keyof TSchema]: TSchema[K] extends AnyPaywallLocationDefinition - ? K - : never; -}[keyof TSchema]; - -export type ExtractSchemaPaywallLocationDefinitions< - TSchema extends VoidhashSchema, -> = { - [K in ExtractSchemaPaywallLocationKeys]: TSchema[K] extends AnyPaywallLocationDefinition - ? TSchema[K] - : never; -}; - -export type ExtractSchemaPaywallLocationSlugs< - TSchema extends VoidhashSchema, -> = { - [K in ExtractSchemaPaywallLocationKeys]: TSchema[K] extends AnyPaywallLocationDefinition - ? TSchema[K]["slug"] - : never; -}[ExtractSchemaPaywallLocationKeys]; - -export type InferGetPaywallLocationInput = - [ExtractSchemaPaywallLocationSlugs] extends [never] - ? string - : ExtractSchemaPaywallLocationSlugs; diff --git a/libraries/react-native/src/core/schema/utils.ts b/libraries/react-native/src/core/schema/utils.ts deleted file mode 100644 index 90980acd7..000000000 --- a/libraries/react-native/src/core/schema/utils.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { ProductDefinition } from "./products/base"; -import { PaywallLocationDefinition } from "./paywall-location"; -import type { - AnyPaywallLocationDefinition, - AnySchemaConfiguration, - ExtractSchemaConfigurations, - ExtractSchemaPaywallLocationDefinitions, - ExtractSchemaProductDefinitions, - VoidhashSchema, -} from "./types"; - -export function extractProductDefinitions( - schema: TSchema -): ExtractSchemaProductDefinitions { - const productDefinitions = {} as ExtractSchemaProductDefinitions; - - for (const [key, value] of Object.entries(schema)) { - if (value instanceof ProductDefinition) { - // @ts-expect-error - TypeScript can't infer that key is a valid product key, but we know it is - productDefinitions[key] = value; - } - } - - return productDefinitions; -} - -export function extractPaywallLocationDefinitions( - schema: TSchema -): ExtractSchemaPaywallLocationDefinitions { - const paywallLocationDefinitions = - {} as ExtractSchemaPaywallLocationDefinitions; - - for (const [key, value] of Object.entries(schema)) { - if (value instanceof PaywallLocationDefinition) { - // @ts-expect-error - TypeScript can't infer that key is a valid paywall location key, but we know it is - paywallLocationDefinitions[key] = value as AnyPaywallLocationDefinition; - } - } - - return paywallLocationDefinitions; -} - -/** - * Extracts schema configuration objects from a schema module. - * Schema configurations contain providers, perks, and product builder methods. - */ -export function extractSchemaConfigurations( - schema: TSchema -): ExtractSchemaConfigurations { - const configurations = {} as ExtractSchemaConfigurations; - - for (const [key, value] of Object.entries(schema)) { - // Check if this is a schema configuration by looking for the required properties - if ( - value && - typeof value === "object" && - "providers" in value && - "perks" in value && - "subscription" in value && - typeof value.subscription === "function" && - "location" in value && - typeof value.location === "function" - ) { - // @ts-expect-error - TypeScript can't infer that key is a valid configuration key, but we know it is - configurations[key] = value as AnySchemaConfiguration; - } - } - - return configurations; -} diff --git a/libraries/react-native/src/core/testing/payment-adapter.ts b/libraries/react-native/src/core/testing/payment-adapter.ts index f26a6fecd..649c81c54 100644 --- a/libraries/react-native/src/core/testing/payment-adapter.ts +++ b/libraries/react-native/src/core/testing/payment-adapter.ts @@ -18,10 +18,7 @@ import type { UserCancelledError, } from "../payment-adapters/errors"; import { PaymentAdapter } from "../payment-adapters/payment-adapter"; -import type { - ExtractSchemaProductDefinitions, - VoidhashSchema, -} from "../schema"; +import type { RuntimeProductDefinition } from "../schema/runtime"; export const TestPaymentAdapter = Layer.succeed(PaymentAdapter, { acknowledgePurchase( @@ -79,19 +76,14 @@ export const TestPaymentAdapter = Layer.succeed(PaymentAdapter, { return Effect.succeed([]); }, - getProducts< - TSchema extends VoidhashSchema, - TDefinedProducts extends ExtractSchemaProductDefinitions, - >( - productDefinitions: TDefinedProducts + getProducts( + productDefinitions: Readonly> ): Effect.Effect< Product[], NativeAdapterNotInitializedError | FailedToGetProductsError, never > { - const productDefinitionsArray = Object.values( - productDefinitions - ) as TDefinedProducts[keyof TDefinedProducts][]; + const productDefinitionsArray = Object.values(productDefinitions); Effect.logDebug("TestPaymentAdapter: Getting products", { count: productDefinitionsArray.length, diff --git a/libraries/react-native/src/index.ts b/libraries/react-native/src/index.ts index adb823ad3..1cbd9d098 100644 --- a/libraries/react-native/src/index.ts +++ b/libraries/react-native/src/index.ts @@ -1,46 +1,3 @@ -// import { Platform } from "react-native"; - -// import { PayKit } from "./nitro"; - -// export * from "./specs/PurchasedItem.nitro"; - -// export { PayKit }; - -// type iOSAvailableProduct = { -// sku: string; -// }; - -// type PayKitIntegrationOptions = { -// ios?: { -// /** -// * Returns currently available products that the user can purchase or subscribe to -// * @returns The offerings -// */ -// getAvailableProducts: () => Promise; -// }; -// android?: { -// getAvailableProducts?: () => Promise; -// }; -// }; - -// export class PayKitIntegration { -// constructor(private readonly options: PayKitIntegrationOptions) { -// console.log(options); -// } - -// purchase(sku: string) { -// return PayKit.purchase(sku); -// } - -// getProducts() { -// if (Platform.OS === "ios") { -// return this.options.ios?.getAvailableProducts?.(); -// } - -// throw new Error("Not implemented"); -// } -// } - export * from "./client"; export * from "./client-react-native"; export * from "./core/schema"; diff --git a/libraries/react-native/src/metro/index.ts b/libraries/react-native/src/metro/index.ts new file mode 100644 index 000000000..3e9548356 --- /dev/null +++ b/libraries/react-native/src/metro/index.ts @@ -0,0 +1,146 @@ +/** + * Metro plugin — Node-only entry, never bundled into the RN app. + * + * Wire this into the user's `metro.config.js` to automatically regenerate + * `voidhash.gen.d.ts` when the dashboard schema changes during `expo start`: + * + * ```js + * const { getDefaultConfig } = require("expo/metro-config"); + * const { withVoidhash } = require("@voidhash/react-native/metro"); + * + * module.exports = withVoidhash(getDefaultConfig(__dirname), { + * pollIntervalMs: 5000, // optional + * }); + * ``` + * + * Spawns `voidhash types generate --watch` as a child process on Metro server + * start and tears it down on shutdown. Errors from the spawned CLI are logged + * but do not crash Metro — the user can keep developing against the + * last-known-good `.d.ts`. + * + * `voidhash-cli` is an optional peer dependency. If the binary isn't on PATH, + * `withVoidhash` returns the original Metro config unchanged with a warning. + */ + +// We avoid pulling in `@types/node` so the SDK's TypeScript compilation +// stays free of Node ambient types — this module only runs in the Metro +// (Node) context. The minimal ambient declarations below describe just what +// we need. + +declare const require: (id: string) => unknown; +declare const process: { + env: Record; + on(event: string, listener: () => void): void; +}; + +interface ChildProcessLike { + killed: boolean; + kill(signal?: string): boolean; + on(event: "error", listener: (error: { message: string }) => void): void; + on(event: "exit", listener: () => void): void; +} + +interface ChildProcessModule { + spawn( + command: string, + args: ReadonlyArray, + options: { + env: Record; + stdio: "inherit"; + } + ): ChildProcessLike; +} + +// Metro config has a deep, framework-specific type. We treat it as opaque +// here and return it unchanged so we don't have to depend on `metro-config`. +type MetroConfig = unknown; + +export interface WithVoidhashOptions { + /** How often to poll the server for schema changes (ms). Default 5000. */ + pollIntervalMs?: number; + /** Path to the `voidhash` CLI binary. Defaults to picking it up via $PATH. */ + cliBinary?: string; + /** Additional CLI arguments forwarded after `types generate --watch`. */ + extraArgs?: ReadonlyArray; +} + +let activeChildProcess: ChildProcessLike | null = null; +let teardownInstalled = false; + +function ensureTeardownHandlers() { + if (teardownInstalled) return; + teardownInstalled = true; + + const teardown = () => { + if (activeChildProcess && !activeChildProcess.killed) { + activeChildProcess.kill("SIGTERM"); + activeChildProcess = null; + } + }; + + process.on("exit", teardown); + process.on("SIGINT", teardown); + process.on("SIGTERM", teardown); +} + +function startWatcher(options: WithVoidhashOptions) { + if (activeChildProcess) { + return; + } + + const binary = options.cliBinary ?? "voidhash"; + const args = [ + "types", + "generate", + "--watch", + "--poll-interval-ms", + String(options.pollIntervalMs ?? 5000), + ...(options.extraArgs ?? []), + ]; + + try { + const childProcessModule = require("node:child_process") as ChildProcessModule; + activeChildProcess = childProcessModule.spawn(binary, args, { + env: process.env, + stdio: "inherit", + }); + + activeChildProcess.on("error", (error) => { + // biome-ignore lint/suspicious/noConsole: dev-time diagnostic + console.warn( + `[voidhash/metro] Failed to spawn '${binary} ${args.join(" ")}': ${error.message}. ` + + "Ensure voidhash-cli is installed in this project." + ); + activeChildProcess = null; + }); + + activeChildProcess.on("exit", () => { + activeChildProcess = null; + }); + + ensureTeardownHandlers(); + } catch (error) { + // biome-ignore lint/suspicious/noConsole: dev-time diagnostic + console.warn( + `[voidhash/metro] Could not start types watcher: ${ + error instanceof Error ? error.message : String(error) + }` + ); + activeChildProcess = null; + } +} + +/** + * Wrap a Metro config so that `voidhash types generate --watch` runs alongside + * the Metro dev server. Returns the original config unchanged — the watcher + * runs as a sibling process, not via Metro's transformer/resolver pipeline. + */ +export function withVoidhash( + metroConfig: TConfig, + options: WithVoidhashOptions = {} +): TConfig { + // Kick the watcher off lazily so that simply *importing* this module from a + // non-dev context doesn't spawn a background process. + globalThis.queueMicrotask?.(() => startWatcher(options)); + return metroConfig; +} diff --git a/libraries/react-native/src/react/components/provider.tsx b/libraries/react-native/src/react/components/provider.tsx index 073a0df4d..26195a883 100644 --- a/libraries/react-native/src/react/components/provider.tsx +++ b/libraries/react-native/src/react/components/provider.tsx @@ -1,21 +1,18 @@ import React, { type ReactNode, createContext } from "react"; import type { VoidhashClient } from "../../client"; -import type { VoidhashSchema } from "../../core/schema"; export interface VoidhashProviderBaseProps { children: ReactNode; } -export interface VoidhashContext { +export interface VoidhashContext { isInitialized: boolean; - client: VoidhashClient; + client: VoidhashClient; } -export function voidhashProviderFactory( - initialClient: VoidhashClient -) { - const VoidhashContext = createContext | null>(null); +export function voidhashProviderFactory(initialClient: VoidhashClient) { + const VoidhashContext = createContext(null); function VoidhashProvider({ children }: VoidhashProviderBaseProps) { const client = React.useRef(initialClient); @@ -48,25 +45,4 @@ export function voidhashProviderFactory( } return { context: VoidhashContext, provider: VoidhashProvider, useVoidhash }; - - // React.useEffect(() => { - // const listener = Linking.addEventListener("url", (event) => { - // if ( - // event.url.startsWith( - // client.current.internal_getSuccessCallbackBaseUrl() - // ) - // ) { - // client.current.internal_onWebCheckoutSuccess(event.url); - // } else if ( - // event.url.startsWith( - // client.current.internal_getErrorCallbackBaseUrl() - // ) - // ) { - // client.current.internal_onWebCheckoutError(event.url); - // } - // }); - // return () => { - // listener.remove(); - // }; - // }, []); } diff --git a/libraries/react-native/src/react/hooks/use-customer.ts b/libraries/react-native/src/react/hooks/use-customer.ts index 9188b0ce6..c0ea42fad 100644 --- a/libraries/react-native/src/react/hooks/use-customer.ts +++ b/libraries/react-native/src/react/hooks/use-customer.ts @@ -2,13 +2,12 @@ import type { SdkPerson as SdkCustomer } from "@voidhash/generated-clients"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import type { VoidhashClient } from "../../client"; -import type { VoidhashSchema } from "../../core/schema"; import type { VoidhashContext } from "../components/provider"; import useAsyncFunction from "./use-async-function"; -export function currentCustomerHookFactory( - client: VoidhashClient, - vhContext: React.Context | null> +export function currentCustomerHookFactory( + client: VoidhashClient, + vhContext: React.Context ) { function useCurrentCustomer() { const voidhashContext = React.useContext(vhContext); diff --git a/libraries/react-native/src/react/hooks/use-feature-flags.ts b/libraries/react-native/src/react/hooks/use-feature-flags.ts index a1cbce864..6c2c39ef2 100644 --- a/libraries/react-native/src/react/hooks/use-feature-flags.ts +++ b/libraries/react-native/src/react/hooks/use-feature-flags.ts @@ -2,15 +2,14 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import type { VoidhashClient } from "../../client"; import type { FeatureFlagsFetchedEvent } from "../../core/event-bus"; -import type { VoidhashSchema } from "../../core/schema"; import type { VoidhashContext } from "../components/provider"; import useAsyncFunction from "./use-async-function"; type FeatureFlagsResult = FeatureFlagsFetchedEvent; -export function featureFlagsHookFactory( - client: VoidhashClient, - vhContext: React.Context | null> +export function featureFlagsHookFactory( + client: VoidhashClient, + vhContext: React.Context ) { function useFeatureFlags(flagKeys?: string[]) { const voidhashContext = React.useContext(vhContext); diff --git a/libraries/react-native/src/react/hooks/use-paywall-by-location.ts b/libraries/react-native/src/react/hooks/use-paywall-by-location.ts index b9cc91843..584d1a21f 100644 --- a/libraries/react-native/src/react/hooks/use-paywall-by-location.ts +++ b/libraries/react-native/src/react/hooks/use-paywall-by-location.ts @@ -2,11 +2,8 @@ import React, { useCallback, useEffect } from "react"; import { AppState, Linking } from "react-native"; import type { VoidhashClient } from "../../client"; -import type { - ExtractSchemaPaywallLocationSlugs, - InferGetProductResponseFromSchema, - VoidhashSchema, -} from "../../core/schema"; +import type { SubscriptionProduct } from "../../core/entities/product"; +import type { LocationSlug } from "../../core/schema/registry"; import { PaywallPresenter } from "../../nitro"; import { createPaywallBridgeErrorResponse, @@ -31,16 +28,6 @@ export interface UsePaywallByLocationOptions { onRestore?: (context: { requestId?: string }) => void; } -type InferGetPaywallLocationInput = - [ExtractSchemaPaywallLocationSlugs] extends [never] - ? string - : ExtractSchemaPaywallLocationSlugs; - -type ResolvedProduct = Exclude< - InferGetProductResponseFromSchema[keyof InferGetProductResponseFromSchema], - null ->; - const resolvedPaywallHtmlByLocation = new Map(); const activeHookCountByLocation = new Map(); const inFlightActionByLocation = new Set(); @@ -57,7 +44,7 @@ function normalizeLocation(locationSlug: string): string { function getResolvedHtmlUrl( resolvedPaywall: - | Awaited["getPaywallForLocation"]>> + | Awaited> | null | undefined ): string | null { @@ -87,12 +74,12 @@ function getErrorPayload(error: unknown): { code: string; message: string } { }; } -function findProductByBridgeProductId( - products: InferGetProductResponseFromSchema, +function findProductByBridgeProductId( + products: Record, productId: string -): ResolvedProduct | null { +): SubscriptionProduct | null { const productList = Object.values(products).filter( - (product): product is ResolvedProduct => product !== null + (product): product is SubscriptionProduct => product !== null ); const byId = productList.find((product) => product.id === productId); @@ -109,8 +96,8 @@ interface PaywallPresenterBridgeAdapter { postMessage: (locationSlug: string, data: string) => void; } -async function handlePaywallBridgeEvent(options: { - client: VoidhashClient; +async function handlePaywallBridgeEvent(options: { + client: VoidhashClient; locationKey: string; paywallOptions?: UsePaywallByLocationOptions; openExternalUrl: (url: string) => Promise; @@ -239,10 +226,8 @@ async function handlePaywallBridgeEvent(options: } } -export async function __internal_handlePaywallBridgeEventForTests< - TSchema extends VoidhashSchema, ->(options: { - client: VoidhashClient; +export async function __internal_handlePaywallBridgeEventForTests(options: { + client: VoidhashClient; locationKey: string; paywallOptions?: UsePaywallByLocationOptions; openExternalUrl: (url: string) => Promise; @@ -269,12 +254,12 @@ function decrementActiveHookCount(locationSlug: string) { return nextCount; } -export function paywallByLocationHookFactory( - client: VoidhashClient, - vhContext: React.Context | null> +export function paywallByLocationHookFactory( + client: VoidhashClient, + vhContext: React.Context ) { function usePaywallByLocation( - locationSlug: InferGetPaywallLocationInput, + locationSlug: LocationSlug, paywallOptions?: UsePaywallByLocationOptions ): UsePaywallByLocationResult { const voidhashContext = React.useContext(vhContext); diff --git a/libraries/react-native/src/react/hooks/use-products.ts b/libraries/react-native/src/react/hooks/use-products.ts index 6fc5c4e9f..de9505e19 100644 --- a/libraries/react-native/src/react/hooks/use-products.ts +++ b/libraries/react-native/src/react/hooks/use-products.ts @@ -2,18 +2,13 @@ import React, { useCallback, useMemo } from "react"; import type { VoidhashClient } from "../../client"; import type { SubscriptionProduct } from "../../core/entities/product"; -import type { - ExtractSchemaProductDefinitions, - InferGetProductResponseFromSchema, - SubscriptionProductDefinition, - VoidhashSchema, -} from "../../core/schema"; +import type { ProductSlug } from "../../core/schema/registry"; import type { VoidhashContext } from "../components/provider"; import useAsyncFunction from "./use-async-function"; -export function productsHookFactory( - client: VoidhashClient, - vhContext: React.Context | null> +export function productsHookFactory( + client: VoidhashClient, + vhContext: React.Context ) { function useProducts() { const voidhashContext = React.useContext(vhContext); @@ -29,42 +24,31 @@ export function productsHookFactory( }); const getProduct = useCallback( - ( - productDefinition: ExtractSchemaProductDefinitions[keyof ExtractSchemaProductDefinitions] - ) => { + (productSlug: ProductSlug): SubscriptionProduct | null => { if (!products) { return null; } - - const product = Object.values(products).find( - (product) => - (product as SubscriptionProduct).slug === - // biome-ignore lint/suspicious/noExplicitAny: any is ok in this case - (productDefinition as SubscriptionProductDefinition) - .slug - ) as SubscriptionProduct | null; - - return product; + // ProductSlug is `string` at runtime; the index access is safe. + return (products as Record)[ + String(productSlug) + ] ?? null; }, [products] ); const toList = useCallback( - () => + (): SubscriptionProduct[] => products - ? ( - Object.values(products) as Exclude< - Exclude[keyof typeof products], - null - >[] - ).filter((product) => product !== null) + ? (Object.values(products) as Array).filter( + (product): product is SubscriptionProduct => product !== null + ) : [], [products] ); const data = useMemo( () => ({ - ...products, + ...(products ?? {}), get: getProduct, toList, }), @@ -72,7 +56,7 @@ export function productsHookFactory( ); return { - data: data as InferGetProductResponseFromSchema & { + data: data as Record & { get: typeof getProduct; toList: typeof toList; }, diff --git a/libraries/react-native/src/react/hooks/use-purchase.ts b/libraries/react-native/src/react/hooks/use-purchase.ts index c2139dfa8..725fbbd0a 100644 --- a/libraries/react-native/src/react/hooks/use-purchase.ts +++ b/libraries/react-native/src/react/hooks/use-purchase.ts @@ -1,10 +1,7 @@ import { useCallback, useState } from "react"; import type { VoidhashClient } from "../../client"; -import type { - InferGetProductResponseFromSchema, - VoidhashSchema, -} from "../../core/schema"; +import type { SubscriptionProduct } from "../../core/entities/product"; export interface UsePurchaseOptions { method?: "native"; @@ -13,19 +10,14 @@ export interface UsePurchaseOptions { onSettled?: () => void; } -export function purchaseHookFactory( - client: VoidhashClient -) { +export function purchaseHookFactory(client: VoidhashClient) { function usePurchase(hookOptions?: UsePurchaseOptions) { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const purchase = useCallback( ( - product: Exclude< - InferGetProductResponseFromSchema[keyof InferGetProductResponseFromSchema], - null - >, + product: SubscriptionProduct, options?: { method?: "native"; onSuccess?: () => void; From 9be4ae2787d88c182f55c0d29b0c465c19daaca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Mon, 11 May 2026 09:00:53 +0200 Subject: [PATCH 013/129] fix: cli error --- apps/cli/src/cli/commands/auth-login.ts | 3 +-- apps/cli/src/cli/commands/auth-logout.ts | 3 +-- apps/cli/src/cli/commands/auth-status.ts | 3 +-- apps/cli/src/cli/commands/auth.ts | 3 +-- apps/cli/src/cli/commands/config-reset.ts | 3 +-- apps/cli/src/cli/commands/config-set.ts | 3 +-- apps/cli/src/cli/commands/config.ts | 3 +-- apps/cli/src/cli/commands/init.ts | 3 +-- apps/cli/src/cli/commands/types-check.ts | 3 +-- apps/cli/src/cli/commands/types-generate.ts | 2 -- apps/cli/src/cli/commands/types.ts | 3 +-- apps/cli/src/cli/index.ts | 5 ++++- 12 files changed, 14 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/cli/commands/auth-login.ts b/apps/cli/src/cli/commands/auth-login.ts index abbbce10d..ced9af4b5 100644 --- a/apps/cli/src/cli/commands/auth-login.ts +++ b/apps/cli/src/cli/commands/auth-login.ts @@ -2,9 +2,8 @@ import { Command, Prompt } from "effect/unstable/cli"; import { Console, Effect } from "effect"; import { Auth } from "../../domain/services/auth"; -import { debugOption } from "../shared-options"; -export const loginCommand = Command.make("login", { debug: debugOption }, () => +export const loginCommand = Command.make("login", {}, () => Effect.gen(function* loginCommand() { const auth = yield* Auth; const user = yield* auth.getSignedInSession.pipe( diff --git a/apps/cli/src/cli/commands/auth-logout.ts b/apps/cli/src/cli/commands/auth-logout.ts index 61f2eda2f..7237ef0d5 100644 --- a/apps/cli/src/cli/commands/auth-logout.ts +++ b/apps/cli/src/cli/commands/auth-logout.ts @@ -2,11 +2,10 @@ import { Command, Prompt } from "effect/unstable/cli"; import { Console, Effect } from "effect"; import { Auth } from "../../domain/services/auth"; -import { debugOption } from "../shared-options"; export const logoutCommand = Command.make( "logout", - { debug: debugOption }, + {}, () => Effect.gen(function* logoutCommand() { const auth = yield* Auth; diff --git a/apps/cli/src/cli/commands/auth-status.ts b/apps/cli/src/cli/commands/auth-status.ts index 1d582ea11..8e02fc705 100644 --- a/apps/cli/src/cli/commands/auth-status.ts +++ b/apps/cli/src/cli/commands/auth-status.ts @@ -3,9 +3,8 @@ import { Console, Effect } from "effect"; import { Auth } from "../../domain/services/auth"; import { userError } from "../../utils/error-formatter"; -import { debugOption } from "../shared-options"; -export const authStatusCommand = Command.make("status", { debug: debugOption }, () => +export const authStatusCommand = Command.make("status", {}, () => Effect.gen(function* authStatusCommand() { const auth = yield* Auth; const user = yield* auth.getSignedInSession.pipe( diff --git a/apps/cli/src/cli/commands/auth.ts b/apps/cli/src/cli/commands/auth.ts index ed7c688f8..c3528dfec 100644 --- a/apps/cli/src/cli/commands/auth.ts +++ b/apps/cli/src/cli/commands/auth.ts @@ -1,12 +1,11 @@ import { Command } from "effect/unstable/cli"; import { Effect } from "effect"; -import { debugOption } from "../shared-options"; import { loginCommand } from "./auth-login"; import { logoutCommand } from "./auth-logout"; import { authStatusCommand } from "./auth-status"; -export const authCommand = Command.make("auth", { debug: debugOption }, () => +export const authCommand = Command.make("auth", {}, () => Effect.gen(function* authCommand() { // TODO: Show sucommands documentation }) diff --git a/apps/cli/src/cli/commands/config-reset.ts b/apps/cli/src/cli/commands/config-reset.ts index aeab15137..bf456f013 100644 --- a/apps/cli/src/cli/commands/config-reset.ts +++ b/apps/cli/src/cli/commands/config-reset.ts @@ -3,9 +3,8 @@ import { Console, Effect } from "effect"; import { CliConfig } from "../../domain/services/cli-config"; import { userError } from "../../utils/error-formatter"; -import { debugOption } from "../shared-options"; -export const configResetCommand = Command.make("reset", { debug: debugOption }, () => +export const configResetCommand = Command.make("reset", {}, () => Effect.gen(function* configResetCommand() { const cliConfig = yield* CliConfig; diff --git a/apps/cli/src/cli/commands/config-set.ts b/apps/cli/src/cli/commands/config-set.ts index f4a133792..4a295ac7b 100644 --- a/apps/cli/src/cli/commands/config-set.ts +++ b/apps/cli/src/cli/commands/config-set.ts @@ -3,14 +3,13 @@ import { Console, Effect } from "effect"; import { CliConfig } from "../../domain/services/cli-config"; import { userError } from "../../utils/error-formatter"; -import { debugOption } from "../shared-options"; const keyArg = Argument.string("key"); const valueArg = Argument.string("value"); export const configSetCommand = Command.make( "set", - { debug: debugOption, key: keyArg, value: valueArg }, + { key: keyArg, value: valueArg }, ({ key, value }) => Effect.gen(function* configSetCommand() { const cliConfig = yield* CliConfig; diff --git a/apps/cli/src/cli/commands/config.ts b/apps/cli/src/cli/commands/config.ts index 7fcec09fc..6dc561fb4 100644 --- a/apps/cli/src/cli/commands/config.ts +++ b/apps/cli/src/cli/commands/config.ts @@ -2,11 +2,10 @@ import { Command } from "effect/unstable/cli"; import { Console, Effect } from "effect"; import { CliConfig } from "../../domain/services/cli-config"; -import { debugOption } from "../shared-options"; import { configResetCommand } from "./config-reset"; import { configSetCommand } from "./config-set"; -export const configCommand = Command.make("config", { debug: debugOption }, () => +export const configCommand = Command.make("config", {}, () => Effect.gen(function* configCommand() { const cliConfig = yield* CliConfig; yield* Console.log("Current configuration:"); diff --git a/apps/cli/src/cli/commands/init.ts b/apps/cli/src/cli/commands/init.ts index 473a1b14b..38c6930e1 100644 --- a/apps/cli/src/cli/commands/init.ts +++ b/apps/cli/src/cli/commands/init.ts @@ -11,7 +11,6 @@ import { userError } from "../../utils/error-formatter"; import { assertFileCanBeCreated } from "../../utils/fs"; import { selectOrganization } from "../../utils/organizations/select-organization"; import { selectProject } from "../../utils/projects/select-project"; -import { debugOption } from "../shared-options"; /** * `voidhash init` @@ -24,7 +23,7 @@ import { debugOption } from "../shared-options"; * after the server-first redesign. Likewise the client file is the user's to * write (it's two lines now: import + `createVoidhashClient`). */ -export const initCommand = Command.make("init", { debug: debugOption }, () => +export const initCommand = Command.make("init", {}, () => Effect.gen(function* initCommand() { const auth = yield* Auth; const apiClient = yield* ApiClient; diff --git a/apps/cli/src/cli/commands/types-check.ts b/apps/cli/src/cli/commands/types-check.ts index cf19e2261..c2e2aa99f 100644 --- a/apps/cli/src/cli/commands/types-check.ts +++ b/apps/cli/src/cli/commands/types-check.ts @@ -8,7 +8,6 @@ import { Codegen } from "../../domain/services/codegen"; import { SchemaService } from "../../domain/services/schema"; import { SourceCode } from "../../domain/services/source-code"; import { userError } from "../../utils/error-formatter"; -import { debugOption } from "../shared-options"; /** * `voidhash types check` @@ -20,7 +19,7 @@ import { debugOption } from "../shared-options"; */ export const typesCheckCommand = Command.make( "check", - { debug: debugOption }, + {}, () => Effect.gen(function* typesCheckCommand() { const auth = yield* Auth; diff --git a/apps/cli/src/cli/commands/types-generate.ts b/apps/cli/src/cli/commands/types-generate.ts index 26d81c31b..ded8554fe 100644 --- a/apps/cli/src/cli/commands/types-generate.ts +++ b/apps/cli/src/cli/commands/types-generate.ts @@ -7,7 +7,6 @@ import { Codegen } from "../../domain/services/codegen"; import { SchemaService } from "../../domain/services/schema"; import { SourceCode } from "../../domain/services/source-code"; import { userError } from "../../utils/error-formatter"; -import { debugOption } from "../shared-options"; /** * `voidhash types generate [--watch]` @@ -20,7 +19,6 @@ import { debugOption } from "../shared-options"; export const typesGenerateCommand = Command.make( "generate", { - debug: debugOption, pollIntervalMs: Flag.integer("poll-interval-ms").pipe( Flag.withDescription( "Polling interval for --watch mode, in milliseconds" diff --git a/apps/cli/src/cli/commands/types.ts b/apps/cli/src/cli/commands/types.ts index 7aefd2b6e..25be40f74 100644 --- a/apps/cli/src/cli/commands/types.ts +++ b/apps/cli/src/cli/commands/types.ts @@ -1,13 +1,12 @@ import { Command } from "effect/unstable/cli"; import { Effect } from "effect"; -import { debugOption } from "../shared-options"; import { typesCheckCommand } from "./types-check"; import { typesGenerateCommand } from "./types-generate"; export const typesCommand = Command.make( "types", - { debug: debugOption }, + {}, () => Effect.gen(function* typesCommand() {}) ).pipe( Command.withDescription( diff --git a/apps/cli/src/cli/index.ts b/apps/cli/src/cli/index.ts index e475791d2..85284d429 100644 --- a/apps/cli/src/cli/index.ts +++ b/apps/cli/src/cli/index.ts @@ -17,8 +17,11 @@ import { authCommand } from "./commands/auth"; import { configCommand } from "./commands/config"; import { initCommand } from "./commands/init"; import { typesCommand } from "./commands/types"; +import { debugOption } from "./shared-options"; -const command = Command.make("voidhash").pipe( +const command = Command.make("voidhash", { debug: debugOption }, () => + Effect.void +).pipe( Command.withDescription("Voidhash CLI application."), Command.withSubcommands([ initCommand, From bf92623fadcf5eca23cf71f2d8d4371b326c0fa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Mon, 11 May 2026 17:12:40 +0200 Subject: [PATCH 014/129] wip --- .gitignore | 2 +- AGENTS.md | 3 + CLAUDE.md | 5 + apps/cli/package.json | 2 +- apps/cli/src/cli/commands/auth-status.ts | 2 +- apps/cli/src/cli/commands/init.ts | 16 +- apps/cli/src/cli/commands/types-check.ts | 12 +- apps/cli/src/cli/commands/types-generate.ts | 29 +- apps/cli/src/domain/services/codegen.ts | 21 +- apps/cli/src/domain/services/schema.ts | 166 ++--- apps/cli/src/utils/api-client.ts | 2 +- apps/cli/src/utils/error-formatter.ts | 2 +- apps/cli/src/utils/schema/version.ts | 51 +- docs/server-first-schema-server-spec.md | 12 +- examples/react-native-example/package.json | 8 +- .../utils/voidhash/client.ts | 2 +- .../react-native-example/voidhash.gen.d.ts | 6 +- .../node/src/generated/grouped-client.ts | 7 +- libraries/node/tests/client.test.ts | 7 +- libraries/react-native/src/client-effect.ts | 54 +- .../react-native/src/client-react-native.ts | 2 +- .../src/core/networking/api-client.ts | 38 +- .../react-native/src/core/schema/registry.ts | 2 +- .../react-native/src/core/schema/runtime.ts | 2 +- libraries/react-native/src/metro/index.ts | 8 +- packages/generated-clients/openapi/core.json | 2 +- .../generated-clients/src/core/generated.ts | 602 +++++++++--------- 27 files changed, 505 insertions(+), 560 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index ff58f646d..e485f1016 100644 --- a/.gitignore +++ b/.gitignore @@ -294,4 +294,4 @@ yalc.lock # Typescript **/tsconfig.tsbuildinfo -# resources/ \ No newline at end of file +resources/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..7cc0a3b53 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +You have access to read-only clones of some key dependencies we use and get inspired by in ./resources + +- /effect-smol - Effect v4 codebase. Great to find all primitives available in Effect. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..f918c6961 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +You have access to read-only clones of some key dependencies we use and get inspired by in ./resources + +- /effect-smol - Effect v4 codebase. Great to find all primitives available in Effect. + +Avoid adding unneccessary comments. Add jsdoc comments to public functions and explaination comments if doing something unorthodox / uncommon. diff --git a/apps/cli/package.json b/apps/cli/package.json index 31a956b7b..38fb9fb79 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -26,7 +26,7 @@ "start": "dotenv -- tsx ./src/index.ts", "build": "rm -rf ./dist && tsx build.ts && cp package.json dist/ && chmod +x ./dist/bin.cjs", "build:dev": "rm -rf ./dist && tsx build.dev.ts && chmod +x ./dist/index.cjs", - "dev:set-local-urls": "npx voidhash-cli config set api_url http://localhost:5001 && npx voidhash-cli config set web_url https://voidhash.localhost:1355", + "dev:set-local-urls": "npx voidhash-cli config set api_url http://localhost:8787 && npx voidhash-cli config set web_url https://localhost:3000", "typecheck": "tsgo --noEmit", "test": "vitest run -c vitest.unit.mts", "test:watch": "vitest -c vitest.unit.mts" diff --git a/apps/cli/src/cli/commands/auth-status.ts b/apps/cli/src/cli/commands/auth-status.ts index 8e02fc705..b18f91196 100644 --- a/apps/cli/src/cli/commands/auth-status.ts +++ b/apps/cli/src/cli/commands/auth-status.ts @@ -12,7 +12,7 @@ export const authStatusCommand = Command.make("status", {}, () => FailedToGetSessionError: () => Effect.fail( userError( - "Failed to get user session. Please try again or run 'voidhash auth login'." + "Failed to get user session. Please try again or run 'voidhash-cli auth login'." ) ), NoSignedInUserError: () => Effect.succeed(null), diff --git a/apps/cli/src/cli/commands/init.ts b/apps/cli/src/cli/commands/init.ts index 38c6930e1..6ce7459da 100644 --- a/apps/cli/src/cli/commands/init.ts +++ b/apps/cli/src/cli/commands/init.ts @@ -13,7 +13,7 @@ import { selectOrganization } from "../../utils/organizations/select-organizatio import { selectProject } from "../../utils/projects/select-project"; /** - * `voidhash init` + * `voidhash-cli init` * * One-time setup: authenticate, select team/project, write `voidhash.config.ts` * at the project root, and produce the initial `voidhash.gen.d.ts` so type @@ -135,16 +135,20 @@ export const initCommand = Command.make("init", {}, () => // Produce the initial declaration file so the user has working // autocomplete on the first run. Failures here are non-fatal — the user - // can re-run `voidhash types generate` later. + // can re-run `voidhash-cli types generate` later. const generatedVersion = yield* schemaService .fetchRemoteSchema() .pipe( - Effect.flatMap((schema) => - codegen.generateTypesDeclarationFile(typesOutputPath, schema) + Effect.flatMap(({ schema, version }) => + codegen.generateTypesDeclarationFile( + typesOutputPath, + schema, + version + ) ), Effect.catch((e) => Effect.logWarning( - `Failed to generate initial types: ${String(e)}. You can run 'voidhash types generate' later.` + `Failed to generate initial types: ${String(e)}. You can run 'voidhash-cli types generate' later.` ).pipe(Effect.as(null)) ) ); @@ -162,7 +166,7 @@ export const initCommand = Command.make("init", {}, () => ` 2. Create your products and paywall locations in the Voidhash dashboard.` ); yield* Console.log( - ` 3. Re-run 'voidhash types generate' whenever the dashboard schema changes.` + ` 3. Re-run 'voidhash-cli types generate' whenever the dashboard schema changes.` ); }) ).pipe(Command.withDescription("Initialize a new Voidhash project.")); diff --git a/apps/cli/src/cli/commands/types-check.ts b/apps/cli/src/cli/commands/types-check.ts index c2e2aa99f..a2e3b67e1 100644 --- a/apps/cli/src/cli/commands/types-check.ts +++ b/apps/cli/src/cli/commands/types-check.ts @@ -10,7 +10,7 @@ import { SourceCode } from "../../domain/services/source-code"; import { userError } from "../../utils/error-formatter"; /** - * `voidhash types check` + * `voidhash-cli types check` * * CI gate. Compares the `@voidhash:version` header inside the local * `voidhash.gen.d.ts` against the server's current schema version. Exits @@ -32,7 +32,7 @@ export const typesCheckCommand = Command.make( Effect.catchTag("NoSignedInUserError", () => Effect.fail( userError( - "You must be logged in to check types. Run 'voidhash auth login' first." + "You must be logged in to check types. Run 'voidhash-cli auth login' first." ) ) ) @@ -42,7 +42,7 @@ export const typesCheckCommand = Command.make( Effect.catchTag("VoidhashConfigNotFoundError", () => Effect.fail( userError( - "voidhash.config.ts not found. Run 'voidhash init' to create one." + "voidhash.config.ts not found. Run 'voidhash-cli init' to create one." ) ) ) @@ -55,7 +55,7 @@ export const typesCheckCommand = Command.make( Effect.catch(() => Effect.fail( userError( - `Could not read generated types at ${typesOutput}. Run 'voidhash types generate' first.` + `Could not read generated types at ${typesOutput}. Run 'voidhash-cli types generate' first.` ) ) ) @@ -64,7 +64,7 @@ export const typesCheckCommand = Command.make( if (localVersion === null) { return yield* Effect.fail( userError( - `${typesOutput} is missing the @voidhash:version header. Re-run 'voidhash types generate' to regenerate.` + `${typesOutput} is missing the @voidhash:version header. Re-run 'voidhash-cli types generate' to regenerate.` ) ); } @@ -90,7 +90,7 @@ export const typesCheckCommand = Command.make( yield* Console.log(` Local: ${localVersion}`); yield* Console.log(` Server: ${remoteVersion}`); yield* Console.log( - "\nRun 'voidhash types generate' to refresh, then commit the updated declaration file." + "\nRun 'voidhash-cli types generate' to refresh, then commit the updated declaration file." ); return yield* Effect.fail( diff --git a/apps/cli/src/cli/commands/types-generate.ts b/apps/cli/src/cli/commands/types-generate.ts index ded8554fe..5c938fc97 100644 --- a/apps/cli/src/cli/commands/types-generate.ts +++ b/apps/cli/src/cli/commands/types-generate.ts @@ -9,7 +9,7 @@ import { SourceCode } from "../../domain/services/source-code"; import { userError } from "../../utils/error-formatter"; /** - * `voidhash types generate [--watch]` + * `voidhash-cli types generate [--watch]` * * Fetches the schema from the server and emits `voidhash.gen.d.ts` (path * configurable via `voidhash.config.ts#typesOutput`). With `--watch`, polls @@ -44,7 +44,7 @@ export const typesGenerateCommand = Command.make( Effect.catchTag("NoSignedInUserError", () => Effect.fail( userError( - "You must be logged in to generate types. Run 'voidhash auth login' first." + "You must be logged in to generate types. Run 'voidhash-cli auth login' first." ) ) ) @@ -54,7 +54,7 @@ export const typesGenerateCommand = Command.make( Effect.catchTag("VoidhashConfigNotFoundError", () => Effect.fail( userError( - "voidhash.config.ts not found. Run 'voidhash init' to create one." + "voidhash.config.ts not found. Run 'voidhash-cli init' to create one." ) ) ) @@ -65,18 +65,17 @@ export const typesGenerateCommand = Command.make( const regenerate = Effect.gen(function* regenerate() { yield* Console.log("Fetching remote schema..."); - const remoteSchema = yield* schemaService.fetchRemoteSchema().pipe( - Effect.catchTag("RemoteSchemaFetchError", (e) => - Effect.fail( - userError(`Failed to fetch remote schema: ${String(e.cause)}`) + const { schema, version } = yield* schemaService + .fetchRemoteSchema() + .pipe( + Effect.catchTag("RemoteSchemaFetchError", (e) => + Effect.fail( + userError(`Failed to fetch remote schema: ${String(e.cause)}`) + ) ) - ) - ); + ); - const version = yield* codegen.generateTypesDeclarationFile( - outPath, - remoteSchema - ); + yield* codegen.generateTypesDeclarationFile(outPath, schema, version); yield* Console.log( `✓ Types written to ${typesOutput} (version ${version.slice( @@ -105,7 +104,7 @@ export const typesGenerateCommand = Command.make( const latest = yield* schemaService.fetchSchemaVersion().pipe( Effect.catch((e) => Effect.logWarning( - `[voidhash types --watch] Skipping poll due to error: ${String(e)}` + `[voidhash-cli types --watch] Skipping poll due to error: ${String(e)}` ).pipe(Effect.as(null)) ) ); @@ -118,7 +117,7 @@ export const typesGenerateCommand = Command.make( const next = yield* regenerate.pipe( Effect.catch((e) => Effect.logWarning( - `[voidhash types --watch] Regeneration failed: ${String(e)}` + `[voidhash-cli types --watch] Regeneration failed: ${String(e)}` ).pipe(Effect.as(null)) ) ); diff --git a/apps/cli/src/domain/services/codegen.ts b/apps/cli/src/domain/services/codegen.ts index 0db126d99..3a5f1f8df 100644 --- a/apps/cli/src/domain/services/codegen.ts +++ b/apps/cli/src/domain/services/codegen.ts @@ -3,7 +3,6 @@ import { Effect, FileSystem, Layer, ServiceMap } from "effect"; import { VOIDHASH_FETCHED_AT_COMMENT_PREFIX, VOIDHASH_VERSION_COMMENT_PREFIX, - computeSchemaVersionFromNormalized, parseVersionFromDeclaration, } from "../../utils/schema/version"; import type { Writable } from "../../utils/types"; @@ -20,8 +19,12 @@ function toUnionType(slugs: string[]): string { /** * Generate the contents of the `voidhash.gen.d.ts` declaration file. * + * The version baked into the header is supplied by the caller (and ultimately + * by the server's `GET /api/v1/schema` / `GET /api/v1/schema/version` + * endpoints) so client and server can never disagree on the hash. + * * The file: - * - Starts with a `@voidhash:version` header so `voidhash types check` and the + * - Starts with a `@voidhash:version` header so `voidhash-cli types check` and the * dev-mode runtime warning can detect staleness without re-downloading the * full schema. * - Augments `@voidhash/react-native`'s `VoidhashRegister` interface so all @@ -29,9 +32,9 @@ function toUnionType(slugs: string[]): string { */ export function generateTypesDeclaration( schema: NormalizedSchema, + version: string, options: { fetchedAt?: Date } = {} -): { content: string; version: string } { - const version = computeSchemaVersionFromNormalized(schema); +): string { const fetchedAt = (options.fetchedAt ?? new Date()).toISOString(); const productSlugs = [...schema.products.keys()].sort(); @@ -56,7 +59,7 @@ export function generateTypesDeclaration( lines.push("export {};"); lines.push(""); - return { content: lines.join("\n"), version }; + return lines.join("\n"); } const make = Effect.gen(function* effect() { @@ -83,14 +86,16 @@ const make = Effect.gen(function* effect() { /** * Generate the `.d.ts` declaration file from the remote schema and write it - * to disk. Returns the version hash that was baked into the header. + * to disk. `version` is the server-provided hash that gets baked into the + * header so the CI gate / dev-mode warning can detect drift. */ const generateTypesDeclarationFile = ( filePath: string, - schema: NormalizedSchema + schema: NormalizedSchema, + version: string ) => Effect.gen(function* generateTypesDeclarationFile() { - const { content, version } = generateTypesDeclaration(schema); + const content = generateTypesDeclaration(schema, version); yield* fileSystem.writeFileString(filePath, content); return version; }); diff --git a/apps/cli/src/domain/services/schema.ts b/apps/cli/src/domain/services/schema.ts index 72a8432ff..4ff0ca706 100644 --- a/apps/cli/src/domain/services/schema.ts +++ b/apps/cli/src/domain/services/schema.ts @@ -1,11 +1,11 @@ -import { Effect, Layer, Schedule, ServiceMap } from "effect"; +import { Effect, Layer, ServiceMap } from "effect"; import { ApiClient } from "../../utils/api-client"; -import { computeSchemaVersionFromNormalized } from "../../utils/schema/version"; import { RemoteSchemaFetchError } from "../errors/schema"; import { type ProviderId, createEmptyNormalizedSchema, + type NormalizedSchema, } from "../schema/normalized-schema"; const make = Effect.gen(function* effect() { @@ -13,30 +13,28 @@ const make = Effect.gen(function* effect() { /** * Fetch the full schema (perks, locations, products, provider configs) from - * the server. Used by `voidhash types generate` to assemble the data the - * `.d.ts` is generated from. + * the server's consolidated `GET /api/v1/schema` endpoint and project it + * into the CLI's `NormalizedSchema`. * - * Once the server ships a consolidated `GET /schema` endpoint this collapses - * to a single call; for now it composes the per-entity endpoints. + * Replaces the five round-trips the CLI used to make against the + * per-entity endpoints. The server is now the canonical source for the + * version hash too — we no longer re-derive it on the client. */ const fetchRemoteSchema = () => Effect.gen(function* fetchRemoteSchema() { yield* Effect.logDebug("Fetching remote schema from API"); - const schema = createEmptyNormalizedSchema(); + const response = yield* apiClient.schemaGetSchema(); - // Perks - const remotePerks = yield* apiClient.perksListPerks(); - for (const perk of remotePerks) { + const schema: NormalizedSchema = createEmptyNormalizedSchema(); + + for (const perk of response.perks) { schema.perks.set(perk.slug, { name: perk.name, slug: perk.slug, }); } - // Paywall locations - const remoteLocations = - yield* apiClient.paywallLocationsListPaywallLocations(); - for (const location of remoteLocations) { + for (const location of response.locations) { schema.locations.set(location.slug, { description: location.description, name: location.name, @@ -44,106 +42,71 @@ const make = Effect.gen(function* effect() { }); } - // Products - const remoteProducts = yield* apiClient.productsListProducts(); - - // Payment provider configurations - const providerConfigs = - yield* apiClient.paymentProviderConfigurationsListPaymentProviderConfigurations(); - for (const config of providerConfigs) { - if ( - config.providerId === "appleAppStore" || - config.providerId === "googlePlay" - ) { - schema.enabledProviders.add(config.providerId); - } - } - - // Payment provider products - const providerProducts = - yield* apiClient.paymentProviderProductsListPaymentProviderProducts(); - const productProviderMap = new Map< - string, - { providerId: ProviderId; configuration: Record }[] - >(); - for (const pp of providerProducts) { - if ( - pp.providerId !== "appleAppStore" && - pp.providerId !== "googlePlay" - ) { - continue; - } - const existing = productProviderMap.get(pp.productId) || []; - existing.push({ - configuration: pp.configuration as Record, - providerId: pp.providerId, + const SUPPORTED_PROVIDER_IDS: ReadonlySet = new Set([ + "appleAppStore", + "googlePlay", + ]); + + for (const product of response.products) { + schema.products.set(product.slug, { + name: product.name, + perks: [...product.perks], + providers: product.providers + .filter((provider) => + SUPPORTED_PROVIDER_IDS.has(provider.providerId as string) + ) + .map((provider) => ({ + configuration: provider.configuration, + providerId: provider.providerId as ProviderId, + })), + slug: product.slug, + type: product.type, }); - productProviderMap.set(pp.productId, existing); } - // For each product, fetch its perks - yield* Effect.all( - remoteProducts.map((product) => - Effect.gen(function* () { - const productPerks = yield* apiClient - .productPerksListProductPerksByProductId(product.id) - .pipe( - Effect.retry({ - schedule: Schedule.exponential(1000), - times: 3, - }), - ); - - const perkSlugs: string[] = []; - for (const pp of productPerks) { - const perk = remotePerks.find((p) => p.id === pp.perkId); - if (perk) { - perkSlugs.push(perk.slug); - } - } - - schema.products.set(product.slug, { - name: product.name, - perks: perkSlugs, - providers: productProviderMap.get(product.id) || [], - slug: product.slug, - type: "subscription", - }); - }), - ), - { - concurrency: 8, - }, - ); + for (const providerId of response.enabledProviders) { + schema.enabledProviders.add(providerId); + } yield* Effect.logDebug( `Fetched ${schema.locations.size} locations, ${schema.perks.size} perks, ${schema.products.size} products` ); - return schema; + + // The server-side version is the canonical hash and trumps any local + // re-derivation. Surface it so callers (codegen, `types check`) can + // bake it into the `.d.ts` header / compare against the local one. + return { schema, version: response.version }; }).pipe( Effect.withSpan("SchemaService.fetchRemoteSchema"), - Effect.catch( - (e) => - Effect.fail(new RemoteSchemaFetchError({ + Effect.catch((e) => + Effect.fail( + new RemoteSchemaFetchError({ cause: e, - })), - ), + }) + ) + ) ); /** - * Fetch just the schema version hash from the server. Used by `types check`, - * the `--watch` poll loop, and the dev-mode runtime warning to detect - * staleness cheaply without re-downloading the full schema. - * - * Today this re-derives the version from the full schema fetch (since the - * consolidated `GET /schema/version` endpoint isn't shipped yet). Once - * that endpoint exists this collapses to a single sub-kilobyte request. + * Cheap version probe used by `voidhash-cli types check`, the `--watch` poll + * loop, and (indirectly) the dev-mode SDK drift warning. Hits the dedicated + * `GET /api/v1/schema/version` endpoint so we don't ship the whole schema + * just to compare hashes. */ const fetchSchemaVersion = () => Effect.gen(function* fetchSchemaVersion() { - const schema = yield* fetchRemoteSchema(); - return computeSchemaVersionFromNormalized(schema); - }); + const response = yield* apiClient.schemaGetSchemaVersion(); + return response.version; + }).pipe( + Effect.withSpan("SchemaService.fetchSchemaVersion"), + Effect.catch((e) => + Effect.fail( + new RemoteSchemaFetchError({ + cause: e, + }) + ) + ) + ); return { fetchRemoteSchema, @@ -153,9 +116,10 @@ const make = Effect.gen(function* effect() { type SchemaServiceShape = Effect.Success; -export class SchemaService extends ServiceMap.Service()( - "voidhash-cli/Schema" -) { +export class SchemaService extends ServiceMap.Service< + SchemaService, + SchemaServiceShape +>()("voidhash-cli/Schema") { static Default = Layer.effect(SchemaService, make).pipe( Layer.provide(ApiClient.Default) ); diff --git a/apps/cli/src/utils/api-client.ts b/apps/cli/src/utils/api-client.ts index a521d3eda..46ee36824 100644 --- a/apps/cli/src/utils/api-client.ts +++ b/apps/cli/src/utils/api-client.ts @@ -28,7 +28,7 @@ const make = Effect.gen(function* effect() { ); return HttpClientRequest.setHeaders( - HttpClientRequest.prependUrl(request, "http://localhost:5001"), + HttpClientRequest.prependUrl(request, "http://localhost:8787"), config.api_key ? { "x-api-key": config.api_key } : {} ); }).pipe(Effect.withSpan("ApiClient.transformRequest")) diff --git a/apps/cli/src/utils/error-formatter.ts b/apps/cli/src/utils/error-formatter.ts index 10b754089..124cb168a 100644 --- a/apps/cli/src/utils/error-formatter.ts +++ b/apps/cli/src/utils/error-formatter.ts @@ -16,7 +16,7 @@ export const isDebugMode = (): boolean => * @example * ```ts * Effect.catchTag("NoSignedInUserError", () => - * Effect.fail(userError("You must be logged in. Run 'voidhash auth login' first.")) + * Effect.fail(userError("You must be logged in. Run 'voidhash-cli auth login' first.")) * ) * ``` */ diff --git a/apps/cli/src/utils/schema/version.ts b/apps/cli/src/utils/schema/version.ts index bfecd1993..52461a327 100644 --- a/apps/cli/src/utils/schema/version.ts +++ b/apps/cli/src/utils/schema/version.ts @@ -1,53 +1,8 @@ -import { createHash } from "node:crypto"; - -import type { NormalizedSchema } from "../../domain/schema/normalized-schema"; - /** - * Compute a deterministic sha256 hash of a normalized schema. The result is - * what `voidhash types generate` bakes into the `.d.ts` header and what - * `voidhash types check` compares against the server. - * - * The hash is order-independent (slugs sorted) so unrelated reorderings - * don't churn the version. + * Header-comment helpers used by the generated `voidhash.gen.d.ts`. The + * version hash itself is supplied by the server (`GET /api/v1/schema`, + * `GET /api/v1/schema/version`) — there is no client-side derivation. */ -export function computeSchemaVersionFromNormalized( - schema: NormalizedSchema -): string { - const sortedProducts = [...schema.products.values()] - .map((product) => ({ - name: product.name, - perks: [...product.perks].sort(), - providers: [...product.providers] - .map((provider) => ({ - providerId: provider.providerId, - configuration: provider.configuration, - })) - .sort((a, b) => a.providerId.localeCompare(b.providerId)), - slug: product.slug, - type: product.type, - })) - .sort((a, b) => a.slug.localeCompare(b.slug)); - - const sortedLocations = [...schema.locations.values()] - .map((location) => ({ - description: location.description, - name: location.name, - slug: location.slug, - })) - .sort((a, b) => a.slug.localeCompare(b.slug)); - - const sortedPerks = [...schema.perks.values()] - .map((perk) => ({ name: perk.name, slug: perk.slug })) - .sort((a, b) => a.slug.localeCompare(b.slug)); - - const payload = JSON.stringify({ - locations: sortedLocations, - perks: sortedPerks, - products: sortedProducts, - }); - - return `sha256:${createHash("sha256").update(payload).digest("hex")}`; -} export const VOIDHASH_VERSION_COMMENT_PREFIX = "// @voidhash:version "; export const VOIDHASH_FETCHED_AT_COMMENT_PREFIX = "// @voidhash:fetched-at "; diff --git a/docs/server-first-schema-server-spec.md b/docs/server-first-schema-server-spec.md index 41c615f2a..ab3b14a87 100644 --- a/docs/server-first-schema-server-spec.md +++ b/docs/server-first-schema-server-spec.md @@ -11,7 +11,7 @@ The CLI and React Native SDK no longer treat the user's code as the source of truth for the schema (perks, products, paywall locations, payment provider mappings). The dashboard is. After the client-side refactor: -- `voidhash schema push / pull / check` are gone. +- `voidhash-cli schema push / pull / check` are gone. - The CLI's only job vs the schema is to generate a `.d.ts` declaration file (`voidhash.gen.d.ts`) consumed by the SDK via module augmentation. - The SDK fetches the runtime schema on `Provider` mount instead of receiving @@ -47,8 +47,8 @@ Replaces the five round-trips the CLI currently makes to assemble a `NormalizedSchema`. Returns everything in one response. **Authentication:** Bearer token (session). Same auth as today's per-entity -admin endpoints. The CLI calls this from `voidhash types generate` (and -indirectly from `voidhash init`). +admin endpoints. The CLI calls this from `voidhash-cli types generate` (and +indirectly from `voidhash-cli init`). **Response (200):** @@ -109,8 +109,8 @@ version hash (see below). Returns just the version hash. Used by: -- `voidhash types generate --watch` (polls every 5s by default). -- `voidhash types check` (CI gate — compares against the `@voidhash:version` +- `voidhash-cli types generate --watch` (polls every 5s by default). +- `voidhash-cli types check` (CI gate — compares against the `@voidhash:version` header in the local `.d.ts`). - The SDK's dev-mode drift warning. @@ -344,7 +344,7 @@ to `string` so user code still compiles. `If-None-Match: ` returns 304 before the mutation and 200 after. 3. From a fresh CLI clone with a generated `voidhash.gen.d.ts` for the - pre-mutation schema, run `voidhash types check` — assert non-zero + pre-mutation schema, run `voidhash-cli types check` — assert non-zero exit + the printed diff cites the new version. 4. From the SDK example app, mount `Provider`, assert `useProducts()` returns the slugs/configurations now living on the server. diff --git a/examples/react-native-example/package.json b/examples/react-native-example/package.json index 661ae7739..83f8c2645 100644 --- a/examples/react-native-example/package.json +++ b/examples/react-native-example/package.json @@ -4,13 +4,13 @@ "private": true, "main": "expo-router/entry", "scripts": { - "run:android": "expo run:android", - "run:ios": "expo run:ios --device", + "android:device": "expo run:android --device", + "ios:device": "expo run:ios --device", "eas:build-dev:local": "eas build --local -e development", "start": "expo start --dev-client", "prebuild": "expo prebuild", - "voidhash:types": "voidhash types generate", - "voidhash:types-check": "voidhash types check", + "voidhash:types": "voidhash-cli types generate", + "voidhash:types-check": "voidhash-cli types check", "lint": "biome check .", "typecheck": "tsc --noEmit", "format": "biome format .", diff --git a/examples/react-native-example/utils/voidhash/client.ts b/examples/react-native-example/utils/voidhash/client.ts index db76c615c..a43e8dc2a 100644 --- a/examples/react-native-example/utils/voidhash/client.ts +++ b/examples/react-native-example/utils/voidhash/client.ts @@ -3,7 +3,7 @@ import { createVoidhashClient } from "@voidhash/react-native"; /** * Voidhash client for the example app. * - * Schema lives on the server now — run `voidhash types generate` to refresh + * Schema lives on the server now — run `voidhash-cli types generate` to refresh * the local `voidhash.gen.d.ts` whenever the dashboard schema changes. */ export const voidhash = createVoidhashClient( diff --git a/examples/react-native-example/voidhash.gen.d.ts b/examples/react-native-example/voidhash.gen.d.ts index ea5c4866a..4cddd9f3f 100644 --- a/examples/react-native-example/voidhash.gen.d.ts +++ b/examples/react-native-example/voidhash.gen.d.ts @@ -1,13 +1,13 @@ // voidhash.gen.d.ts — generated by voidhash-cli, do not edit -// @voidhash:version sha256:placeholder -// @voidhash:fetched-at 1970-01-01T00:00:00.000Z +// @voidhash:version sha256:e6f6dbac538ef9956b435013a314f220ac028d21762b4777096a0b1df7966a13 +// @voidhash:fetched-at 2026-05-11T14:56:24.982Z declare module "@voidhash/react-native" { interface VoidhashRegister { schema: { products: never; locations: never; - perks: never; + perks: "test" | "test-2"; }; } } diff --git a/libraries/node/src/generated/grouped-client.ts b/libraries/node/src/generated/grouped-client.ts index 989908c25..6d0e26185 100644 --- a/libraries/node/src/generated/grouped-client.ts +++ b/libraries/node/src/generated/grouped-client.ts @@ -11,9 +11,6 @@ export const groupCoreClient = (client: VoidhashCoreClient) => ({ auth: { session: () => client.authSession(), }, - changesets: { - deployChangeset: (request: { payload: Parameters[0] }) => client.changesetsDeployChangeset(request.payload), - }, organizations: { createOrganization: (request: { payload: Parameters[0] }) => client.organizationsCreateOrganization(request.payload), }, @@ -45,6 +42,10 @@ export const groupCoreClient = (client: VoidhashCoreClient) => ({ createProject: (request: { payload: Parameters[0] }) => client.projectsCreateProject(request.payload), listProjects: (request: { params: { readonly "organizationId": string } }) => client.projectsListProjects(request.params["organizationId"]), }, + schema: { + getSchema: () => client.schemaGetSchema(), + getSchemaVersion: () => client.schemaGetSchemaVersion(), + }, users: { getUser: () => client.usersGetUser(), }, diff --git a/libraries/node/tests/client.test.ts b/libraries/node/tests/client.test.ts index d61efd1c9..2ec318158 100644 --- a/libraries/node/tests/client.test.ts +++ b/libraries/node/tests/client.test.ts @@ -22,7 +22,6 @@ import { createJsonResponse, installFetchMock } from "./helpers"; const EXPECTED_GROUPS = [ "apiKeys", "auth", - "changesets", "organizations", "paymentProviderConfigurations", "paymentProviderProducts", @@ -32,6 +31,7 @@ const EXPECTED_GROUPS = [ "productPerks", "products", "projects", + "schema", "users", "webhooks", ] as const; @@ -76,9 +76,12 @@ describe("@voidhash/node", () => { expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); }); @@ -352,7 +355,7 @@ describe("@voidhash/node", () => { _tag: (effectError as { _tag?: string })._tag, }); expect(promiseError).toMatchObject({ - _tag: "ActionForbiddenError", + _tag: "ApiActionForbiddenError", }); }); }); diff --git a/libraries/react-native/src/client-effect.ts b/libraries/react-native/src/client-effect.ts index 69a2de9c9..38b1f1efb 100644 --- a/libraries/react-native/src/client-effect.ts +++ b/libraries/react-native/src/client-effect.ts @@ -162,10 +162,13 @@ const makeUnitializedClient = () => ({ yield* customerInfoManager.getCustomer(distinctId, "fetch"); } - // Fetch the runtime schema. The server endpoint is still being built; - // until it ships the API client returns an empty schema with a warning, - // and product-related calls return empty data. - let runtimeSchema = initOptions.internalSchema ?? createEmptyRuntimeSchema(); + // Fetch the runtime schema from the server's `GET /sdk/schema` + // endpoint and cache it on the initialized client. A failure here is + // non-fatal — non-schema hooks (paywall resolution, feature flags, + // identify) still work — but product-related hooks will return empty + // data until the next successful init. + let runtimeSchema = + initOptions.internalSchema ?? createEmptyRuntimeSchema(); if (!initOptions.internalSchema) { const commonHeaders = yield* getCommonSdkHeaders(); const distinctId = yield* identityManager.getDistinctId(); @@ -178,7 +181,7 @@ const makeUnitializedClient = () => ({ }) ); if (fetched._tag === "Success") { - runtimeSchema = fetched.value as RuntimeSchema; + runtimeSchema = fetched.value; } else { yield* Effect.logWarning( "[voidhash] Failed to fetch schema at init — product-related hooks will return empty data." @@ -502,7 +505,10 @@ const makeInitializedClient = (options: { schema: RuntimeSchema }) => { ...commonHeaders, "x-distinct-id": distinctId, }, - payload: mapTransactionToSyncPayload(transaction), + payload: mapTransactionToSyncPayload( + transaction, + options.schema.products + ), }); yield* cacheManager.set(processedTransactionCacheKey, true, { @@ -942,11 +948,41 @@ const buildTransactionProcessingKey = (transaction: Transaction) => const getProcessedTransactionCacheKey = (transactionProcessingKey: string) => `processed-transaction:${transactionProcessingKey}`; -const mapTransactionToSyncPayload = (transaction: Transaction) => { +const resolveTransactionProductSlug = ( + transaction: Transaction, + productDefinitions: Readonly> +) => { + const matchedProduct = Object.values(productDefinitions).find( + (productDefinition) => { + if (productDefinition.slug === transaction.productId) { + return true; + } + + const provider = + transaction.platform === "ios" + ? productDefinition.configuration.providers.appleAppStore + : productDefinition.configuration.providers.googlePlay; + + return provider?.productId === transaction.productId; + } + ); + + return matchedProduct?.slug ?? transaction.productId; +}; + +const mapTransactionToSyncPayload = ( + transaction: Transaction, + productDefinitions: Readonly> +) => { + const productSlug = resolveTransactionProductSlug( + transaction, + productDefinitions + ); + if (transaction.platform === "ios") { return { platform: "ios" as const, - productId: transaction.productId, + productSlug, purchaseDate: transaction.purchaseDate, quantity: transaction.quantity, receipt: transaction.receipt, @@ -956,7 +992,7 @@ const mapTransactionToSyncPayload = (transaction: Transaction) => { return { platform: "android" as const, - productId: transaction.productId, + productSlug, purchaseDate: transaction.purchaseDate, purchaseToken: transaction.purchaseToken ?? "", quantity: transaction.quantity, diff --git a/libraries/react-native/src/client-react-native.ts b/libraries/react-native/src/client-react-native.ts index 0dd0b0d0a..1088b24bd 100644 --- a/libraries/react-native/src/client-react-native.ts +++ b/libraries/react-native/src/client-react-native.ts @@ -22,7 +22,7 @@ import { purchaseHookFactory } from "./react/hooks/use-purchase"; * - There is no schema argument. The schema lives on the server and is * fetched on `Provider` mount. * - Type safety for product / location / perk slugs comes from the generated - * `voidhash.gen.d.ts` (run `voidhash types generate`). + * `voidhash.gen.d.ts` (run `voidhash-cli types generate`). */ export function createVoidhashClient( publishableKey: string, diff --git a/libraries/react-native/src/core/networking/api-client.ts b/libraries/react-native/src/core/networking/api-client.ts index 0ee7d5984..b6451813a 100644 --- a/libraries/react-native/src/core/networking/api-client.ts +++ b/libraries/react-native/src/core/networking/api-client.ts @@ -5,6 +5,7 @@ import { type SdkEvaluateFeatureFlagsParams, type SdkFeatureFlagsResponse, type SdkGetPersonParams, + type SdkGetSchemaParams, type SdkIdentifyPersonParams, type SdkIdentifyBody, type SdkResolvePaywallBody, @@ -15,6 +16,7 @@ import { import { Effect, Layer, ServiceMap } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import type { RuntimeSchema } from "../schema/runtime"; import { SdkConfiguration } from "../sdk-configuration"; import { withHttpDebugLogging } from "./http-debug-client"; @@ -29,7 +31,7 @@ export interface ReactNativeFeatureFlagsResponse { export interface ReactNativeSyncTransactionRequest { readonly platform: "android" | "ios"; - readonly productId: string; + readonly productSlug: string; readonly purchaseDate: number; readonly quantity: number; readonly receipt?: string | undefined; @@ -70,17 +72,6 @@ const normalizeFeatureFlagsResponse = ( })), }); -/** - * Stub error surfaced when the server-side `GET /sdk/schema` endpoint is not - * yet deployed (the server-side work is tracked separately per the - * server-first redesign plan). When the endpoint ships, this stub is replaced - * by a call into the generated client. - */ -const SCHEMA_ENDPOINT_NOT_IMPLEMENTED_MESSAGE = - "[voidhash] The GET /sdk/schema endpoint is not yet available on the server. " + - "Schema-dependent hooks (useProducts, usePurchase) will return empty data until it ships. " + - "See the server-first redesign plan for tracking."; - const bindReactNativeSdkClient = (client: VoidhashCoreClient) => ({ sdk: { /** @@ -88,20 +79,17 @@ const bindReactNativeSdkClient = (client: VoidhashCoreClient) => ({ * mount and cached for the session. Authenticates via the publishable key * (same credential the SDK uses for paywall resolution, etc.). * - * TODO(server): wire up `client.sdkGetSchema(...)` once the server endpoint - * is implemented. For now this returns an empty schema and logs once so - * the SDK remains usable while the backend work is in flight. + * The server's `SdkSchema` response is structurally identical to + * `RuntimeSchema` (slug-keyed records). The generated client types the + * nested fields loosely as `Record`; we tighten the + * boundary with a cast here so the rest of the SDK can treat it as a + * `RuntimeSchema`. */ - getSchema: (_request: { headers: ReactNativeSdkHeaders }) => - Effect.gen(function* getSchema() { - yield* Effect.logWarning(SCHEMA_ENDPOINT_NOT_IMPLEMENTED_MESSAGE); - return { - version: "", - products: {} as Readonly>, - locations: {} as Readonly>, - perks: {} as Readonly>, - }; - }), + getSchema: (request: { headers: ReactNativeSdkHeaders }) => + Effect.map( + client.sdkGetSchema(request.headers as SdkGetSchemaParams), + (response): RuntimeSchema => response as unknown as RuntimeSchema + ), evaluateFeatureFlags: (request: { headers: ReactNativeSdkHeaders; payload: EvaluateFeatureFlagsBody; diff --git a/libraries/react-native/src/core/schema/registry.ts b/libraries/react-native/src/core/schema/registry.ts index 79da267d7..6bd84a522 100644 --- a/libraries/react-native/src/core/schema/registry.ts +++ b/libraries/react-native/src/core/schema/registry.ts @@ -3,7 +3,7 @@ * teaches the SDK about the project-specific schema (product slugs, paywall * location slugs, perk slugs) defined on the server. * - * Usage in user code: the user runs `voidhash types generate`, which writes a + * Usage in user code: the user runs `voidhash-cli types generate`, which writes a * declaration file augmenting this interface. Once that file is part of the * project's TypeScript compilation, hooks like `usePaywallByLocation(slug)` * autocomplete the literal union of valid slugs. diff --git a/libraries/react-native/src/core/schema/runtime.ts b/libraries/react-native/src/core/schema/runtime.ts index e0d8d07d7..23c24ddc8 100644 --- a/libraries/react-native/src/core/schema/runtime.ts +++ b/libraries/react-native/src/core/schema/runtime.ts @@ -47,7 +47,7 @@ export interface RuntimePerkDefinition { * The full schema as fetched from the server. Keyed by slug. * * The `version` is a sha256 hash of the schema state on the server and is - * what `voidhash types check` and the dev-mode runtime warning compare + * what `voidhash-cli types check` and the dev-mode runtime warning compare * against the generated `.d.ts` header. */ export interface RuntimeSchema { diff --git a/libraries/react-native/src/metro/index.ts b/libraries/react-native/src/metro/index.ts index 3e9548356..65cd4c4f1 100644 --- a/libraries/react-native/src/metro/index.ts +++ b/libraries/react-native/src/metro/index.ts @@ -13,7 +13,7 @@ * }); * ``` * - * Spawns `voidhash types generate --watch` as a child process on Metro server + * Spawns `voidhash-cli types generate --watch` as a child process on Metro server * start and tears it down on shutdown. Errors from the spawned CLI are logged * but do not crash Metro — the user can keep developing against the * last-known-good `.d.ts`. @@ -58,7 +58,7 @@ type MetroConfig = unknown; export interface WithVoidhashOptions { /** How often to poll the server for schema changes (ms). Default 5000. */ pollIntervalMs?: number; - /** Path to the `voidhash` CLI binary. Defaults to picking it up via $PATH. */ + /** Path to the `voidhash-cli` binary. Defaults to picking it up via $PATH. */ cliBinary?: string; /** Additional CLI arguments forwarded after `types generate --watch`. */ extraArgs?: ReadonlyArray; @@ -88,7 +88,7 @@ function startWatcher(options: WithVoidhashOptions) { return; } - const binary = options.cliBinary ?? "voidhash"; + const binary = options.cliBinary ?? "voidhash-cli"; const args = [ "types", "generate", @@ -131,7 +131,7 @@ function startWatcher(options: WithVoidhashOptions) { } /** - * Wrap a Metro config so that `voidhash types generate --watch` runs alongside + * Wrap a Metro config so that `voidhash-cli types generate --watch` runs alongside * the Metro dev server. Returns the original config unchanged — the watcher * runs as a sibling process, not via Metro's transformer/resolver pipeline. */ diff --git a/packages/generated-clients/openapi/core.json b/packages/generated-clients/openapi/core.json index 58e3cf7e3..735c243a5 100644 --- a/packages/generated-clients/openapi/core.json +++ b/packages/generated-clients/openapi/core.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Api","version":"0.0.1"},"paths":{"/api/v1/auth/session":{"get":{"tags":["auth"],"operationId":"auth.session","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"method":{"anyOf":[{"type":"string","enum":["api-key"]},{"type":"string","enum":["publishable-key"]},{"type":"string","enum":["secret-key"]}]},"name":{"type":"string"},"organizations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false}},"projects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"organizationId":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","organizationId","slug"],"additionalProperties":false}}},"required":["method","name","organizations","projects"],"additionalProperties":false}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"Error","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}}}}}}},"/api/v1/api-keys":{"post":{"tags":["api_keys"],"operationId":"api_keys.createSecretKey","parameters":[],"security":[],"responses":{"200":{"description":"ApiKeyWithRawKey","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyWithRawKey"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSecretKeyBody"}}},"required":true}},"get":{"tags":["api_keys"],"operationId":"api_keys.listApiKeys","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/api-keys/{apiKeyId}":{"get":{"tags":["api_keys"],"operationId":"api_keys.getApiKeyById","parameters":[{"name":"apiKeyId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"ApiKey","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKey"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"ApiKeyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyNotFoundError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}},"delete":{"tags":["api_keys"],"operationId":"api_keys.deleteApiKey","parameters":[{"name":"apiKeyId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"ApiKeyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyNotFoundError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/api-keys/{apiKeyId}/rotate":{"post":{"tags":["api_keys"],"operationId":"api_keys.rotateSecretKey","parameters":[{"name":"apiKeyId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"ApiKeyWithRawKey","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyWithRawKey"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"ApiKeyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyNotFoundError"}}}},"500":{"description":"ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/persons":{"post":{"tags":["persons"],"operationId":"persons.createPerson","parameters":[],"security":[],"responses":{"200":{"description":"Person","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"400":{"description":"PersonInvalidAnonymousIdError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonInvalidAnonymousIdError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePersonBody"}}},"required":true}},"get":{"tags":["persons"],"operationId":"persons.listPersons","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Person"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/persons/{personId}":{"get":{"tags":["persons"],"operationId":"persons.getPersonById","parameters":[{"name":"personId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"Person","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"PersonNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonNotFoundError"}}}},"500":{"description":"PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/persons/by-distinct-id/{distinctId}":{"get":{"tags":["persons"],"operationId":"persons.getPersonByDistinctId","parameters":[{"name":"distinctId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"Person","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"PersonNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonNotFoundError"}}}},"500":{"description":"PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/organizations":{"post":{"tags":["organizations"],"operationId":"organizations.createOrganization","parameters":[],"security":[],"responses":{"200":{"description":"Organization","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"500":{"description":"OrganizationServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/OrganizationServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrganizationBody"}}},"required":true}}},"/api/v1/perks":{"get":{"tags":["perks"],"operationId":"perks.listPerks","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Perk"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PerkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PerkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/paywall-locations":{"get":{"tags":["paywall_locations"],"operationId":"paywall_locations.listPaywallLocations","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PaywallLocation"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PaywallLocationServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PaywallLocationServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/projects":{"post":{"tags":["projects"],"operationId":"projects.createProject","parameters":[],"security":[],"responses":{"200":{"description":"Project","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Project"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"AuthenticationError | ProjectServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/ProjectServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectBody"}}},"required":true}}},"/api/v1/projects/{organizationId}":{"get":{"tags":["projects"],"operationId":"projects.listProjects","parameters":[{"name":"organizationId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Project"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ProjectServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProjectServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/products":{"get":{"tags":["products"],"operationId":"products.listProducts","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Product"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ProductServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProductServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/product-perks/by-product-id/{productId}":{"get":{"tags":["product_perks"],"operationId":"product_perks.listProductPerksByProductId","parameters":[{"name":"productId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProductPerk"}}}}},"400":{"description":"ProductPerkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProductPerkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"ProductPerkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProductPerkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/sdk/person":{"get":{"tags":["sdk"],"operationId":"sdk.getPerson","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SdkPerson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPerson"}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"404":{"description":"SdkPersonNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPersonNotFoundError"}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/sdk/identify":{"post":{"tags":["sdk"],"operationId":"sdk.identifyPerson","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SdkPerson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPerson"}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"409":{"description":"SdkPersonAlreadyIdentifiedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPersonAlreadyIdentifiedError"}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkIdentifyBody"}}},"required":true}}},"/api/v1/sdk/person/traits":{"post":{"tags":["sdk"],"operationId":"sdk.syncPersonAttributes","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SdkPerson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPerson"}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkSyncPersonAttributesBody"}}},"required":true}}},"/api/v1/sdk/sync-transaction":{"post":{"tags":["sdk"],"operationId":"sdk.syncTransaction","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SdkSyncTransactionResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkSyncTransactionResponse"}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"anyOf":[{"type":"string","enum":["ios"]},{"type":"string","enum":["android"]}]},"productId":{"type":"string"},"purchaseDate":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"purchaseToken":{"anyOf":[{"type":"string"},{"type":"null"}]},"quantity":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"receipt":{"anyOf":[{"type":"string"},{"type":"null"}]},"transactionId":{"type":"string"}},"required":["platform","productId","purchaseDate","quantity","transactionId"],"additionalProperties":false}}},"required":true}}},"/api/v1/sdk/evaluate-flags":{"post":{"tags":["sdk"],"operationId":"sdk.evaluateFeatureFlags","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SdkFeatureFlagsResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkFeatureFlagsResponse"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluateFeatureFlagsBody"}}},"required":true}}},"/api/v1/sdk/resolve-paywall":{"post":{"tags":["sdk"],"operationId":"sdk.resolvePaywall","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkResolvedPaywall"},{"type":"null"}]}}}},"400":{"description":"SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"500":{"description":"AuthenticationError | SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkResolvePaywallBody"}}},"required":true}}},"/api/v1/users/current":{"get":{"tags":["users"],"operationId":"users.getUser","parameters":[],"security":[],"responses":{"200":{"description":"User","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"500":{"description":"AuthenticationError | UserServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/UserServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/payment-provider-configurations":{"get":{"tags":["payment_provider_configurations"],"operationId":"payment_provider_configurations.listPaymentProviderConfigurations","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PaymentProviderConfiguration"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PaymentProviderConfigurationServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PaymentProviderConfigurationServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/payment-provider-products":{"get":{"tags":["payment_provider_products"],"operationId":"payment_provider_products.listPaymentProviderProducts","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PaymentProviderProduct"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"PaymentProviderProductServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PaymentProviderProductServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/changesets/deploy":{"post":{"tags":["changesets"],"operationId":"changesets.deployChangeset","parameters":[],"security":[],"responses":{"200":{"description":"DeployChangesetResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeployChangesetResponse"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"AuthenticationError | ChangesetDeploymentServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/ChangesetDeploymentServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeployChangesetBody"}}},"required":true}}},"/api/v1/webhooks/endpoints":{"post":{"tags":["webhooks"],"operationId":"webhooks.createWebhookEndpoint","parameters":[],"security":[],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"WebhookValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhookEndpointBody"}}},"required":true}},"get":{"tags":["webhooks"],"operationId":"webhooks.listWebhookEndpoints","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEndpoint"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/endpoints/{endpointId}":{"get":{"tags":["webhooks"],"operationId":"webhooks.getWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}},"patch":{"tags":["webhooks"],"operationId":"webhooks.updateWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"WebhookValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWebhookEndpointBody"}}},"required":true}},"delete":{"tags":["webhooks"],"operationId":"webhooks.deleteWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/endpoints/{endpointId}/rotate-secret":{"post":{"tags":["webhooks"],"operationId":"webhooks.rotateWebhookSecret","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/endpoints/{endpointId}/test":{"post":{"tags":["webhooks"],"operationId":"webhooks.testWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookDelivery","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDelivery"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/deliveries":{"get":{"tags":["webhooks"],"operationId":"webhooks.listWebhookDeliveries","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDelivery"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/deliveries/{deliveryId}":{"get":{"tags":["webhooks"],"operationId":"webhooks.getWebhookDelivery","parameters":[{"name":"deliveryId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookDeliveryWithAttempts","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryWithAttempts"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookDeliveryNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/deliveries/{deliveryId}/retry":{"post":{"tags":["webhooks"],"operationId":"webhooks.retryWebhookDelivery","parameters":[{"name":"deliveryId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookDelivery","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDelivery"}}}},"400":{"description":"WebhookValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionForbiddenError"}}}},"404":{"description":"WebhookDeliveryNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryNotFoundError"}}}},"500":{"description":"WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/AuthenticationError"},{"$ref":"#/components/schemas/NotAuthenticatedError"}]}]}}}}}}}},"components":{"schemas":{"ActionForbiddenError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ActionForbiddenError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"AuthenticationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["AuthenticationError"]},"cause":{"type":"string"},"message":{"type":"string"}},"required":["_tag","cause","message"],"additionalProperties":false},"NotAuthenticatedError":{"type":"object","properties":{"_tag":{"type":"string","enum":["NotAuthenticatedError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"effect_HttpApiSchemaError":{"type":"object","properties":{"_tag":{"type":"string","enum":["HttpApiSchemaError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"CreateSecretKeyBody":{"type":"object","properties":{"name":{"type":"string"},"projectId":{"type":"string"}},"required":["name","projectId"],"additionalProperties":false},"ApiKeyWithRawKey":{"type":"object","properties":{"end":{"type":"string"},"id":{"type":"string"},"isPublic":{"type":"boolean"},"name":{"type":"string"},"prefix":{"type":"string"},"projectId":{"type":"string"},"rawKey":{"type":"string"}},"required":["end","id","isPublic","name","prefix","projectId","rawKey"],"additionalProperties":false},"ApiKeyServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ApiKeyServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"ApiKey":{"type":"object","properties":{"end":{"type":"string"},"id":{"type":"string"},"isPublic":{"type":"boolean"},"name":{"type":"string"},"prefix":{"type":"string"},"projectId":{"type":"string"},"rawKey":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["end","id","isPublic","name","prefix","projectId"],"additionalProperties":false},"ApiKeyNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ApiKeyNotFoundError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"CreatePersonBody":{"type":"object","properties":{"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["distinctId"],"additionalProperties":false},"Person":{"type":"object","properties":{"personId":{"type":"string"},"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["personId","distinctId","email","name"],"additionalProperties":false},"PersonInvalidAnonymousIdError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PersonInvalidAnonymousIdError"]},"id":{"type":"string","allOf":[{"minLength":1}]}},"required":["_tag","id"],"additionalProperties":false},"PersonServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PersonServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PersonNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PersonNotFoundError"]},"id":{"type":"string","allOf":[{"minLength":1}]}},"required":["_tag","id"],"additionalProperties":false},"CreateOrganizationBody":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false},"Organization":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},"OrganizationServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["OrganizationServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"Perk":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","projectId","slug"],"additionalProperties":false},"PerkServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PerkServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PaywallLocation":{"type":"object","properties":{"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"slug":{"type":"string"}},"required":["description","id","name","projectId","slug"],"additionalProperties":false},"PaywallLocationServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PaywallLocationServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"CreateProjectBody":{"type":"object","properties":{"name":{"type":"string"},"organizationId":{"type":"string"}},"required":["name","organizationId"],"additionalProperties":false},"Project":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},"ProjectServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProjectServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"Product":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"slug":{"type":"string"},"type":{"anyOf":[{"type":"string","enum":["subscription"]},{"type":"string","enum":["one-time"]},{"type":"string","enum":["one-time-consumable"]}]}},"required":["id","name","projectId","slug","type"],"additionalProperties":false},"ProductServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProductServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"ProductPerk":{"type":"object","properties":{"id":{"type":"string"},"perkId":{"type":"string"},"productId":{"type":"string"}},"required":["id","perkId","productId"],"additionalProperties":false},"ProductPerkServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProductPerkServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"ProductPerkValidationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProductPerkValidationError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"SdkEntitlementGrant":{"type":"object","properties":{"expiresAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"perkId":{"type":"string"},"source":{"anyOf":[{"type":"string","enum":["subscription"]},{"type":"string","enum":["purchase"]},{"type":"string","enum":["manual"]}]},"sourceId":{"anyOf":[{"type":"string"},{"type":"null"}]},"sourcePersonId":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["active"]},{"type":"string","enum":["expired"]}]}},"required":["expiresAt","perkId","source","sourceId","sourcePersonId","status"],"additionalProperties":false},"SdkPurchaseHistoryEntry":{"type":"object","properties":{"createdAt":{"type":"string"},"productId":{"anyOf":[{"type":"string"},{"type":"null"}]},"providerKey":{"type":"string"},"purchaseId":{"type":"string"},"sourcePersonId":{"type":"string"},"type":{"anyOf":[{"type":"string","enum":["one_time"]},{"type":"string","enum":["subscription"]}]}},"required":["createdAt","productId","providerKey","purchaseId","sourcePersonId","type"],"additionalProperties":false},"SdkCurrentSubscription":{"type":"object","properties":{"expiresAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"productId":{"anyOf":[{"type":"string"},{"type":"null"}]},"status":{"anyOf":[{"type":"string","enum":["none"]},{"type":"string","enum":["active"]},{"type":"string","enum":["canceled"]},{"type":"string","enum":["past_due"]},{"type":"string","enum":["trialing"]}]},"subscriptionId":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["expiresAt","productId","status","subscriptionId"],"additionalProperties":false},"SdkSubscriptionHistoryEntry":{"type":"object","properties":{"canceledAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"expiresAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"isTrial":{"type":"boolean"},"productId":{"anyOf":[{"type":"string"},{"type":"null"}]},"sourcePersonId":{"type":"string"},"startsAt":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["active"]},{"type":"string","enum":["canceled"]},{"type":"string","enum":["expired"]},{"type":"string","enum":["trialing"]},{"type":"string","enum":["past_due"]}]},"subscriptionId":{"type":"string"}},"required":["canceledAt","expiresAt","isTrial","productId","sourcePersonId","startsAt","status","subscriptionId"],"additionalProperties":false},"SdkPerson":{"type":"object","properties":{"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"entitlements":{"type":"object","properties":{"grants":{"type":"array","items":{"$ref":"#/components/schemas/SdkEntitlementGrant"}}},"required":["grants"],"additionalProperties":false},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"personId":{"type":"string"},"purchases":{"type":"object","properties":{"history":{"type":"array","items":{"$ref":"#/components/schemas/SdkPurchaseHistoryEntry"}}},"required":["history"],"additionalProperties":false},"snapshotContext":{"type":"object","properties":{"includedPersonIds":{"type":"array","items":{"type":"string"}},"migrationJobId":{"anyOf":[{"type":"string"},{"type":"null"}]},"mode":{"anyOf":[{"type":"string","enum":["persisted"]},{"type":"string","enum":["temporary_pending_transfer"]}]}},"required":["includedPersonIds","migrationJobId","mode"],"additionalProperties":false},"subscriptions":{"type":"object","properties":{"current":{"anyOf":[{"$ref":"#/components/schemas/SdkCurrentSubscription"},{"type":"null"}]},"history":{"type":"array","items":{"$ref":"#/components/schemas/SdkSubscriptionHistoryEntry"}}},"required":["current","history"],"additionalProperties":false}},"required":["distinctId","email","entitlements","name","personId","purchases","snapshotContext","subscriptions"],"additionalProperties":false},"SdkServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["SdkServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"SdkPersonNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["SdkPersonNotFoundError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"SdkValidationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["SdkValidationError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"SdkIdentifyBody":{"type":"object","properties":{"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"traits":{"anyOf":[{"type":"object","additionalProperties":{"anyOf":[{"type":"string"},{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"boolean"},{"type":"null"}]}},{"type":"null"}]}},"required":["distinctId"],"additionalProperties":false},"SdkPersonAlreadyIdentifiedError":{"type":"object","properties":{"_tag":{"type":"string","enum":["SdkPersonAlreadyIdentifiedError"]},"distinctId":{"type":"string"}},"required":["_tag","distinctId"],"additionalProperties":false},"SdkSyncPersonAttributesBody":{"type":"object","properties":{"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"traits":{"anyOf":[{"type":"object","additionalProperties":{"anyOf":[{"type":"string"},{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"boolean"},{"type":"null"}]}},{"type":"null"}]}},"additionalProperties":false},"SdkSyncTransactionResponse":{"type":"object","properties":{"accepted":{"type":"boolean"}},"required":["accepted"],"additionalProperties":false},"EvaluateFeatureFlagsBody":{"type":"object","properties":{"flagKeys":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]}},"additionalProperties":false},"SdkFeatureFlagResult":{"type":"object","properties":{"enabled":{"type":"boolean"},"key":{"type":"string"},"payload":{"anyOf":[{"type":"null"},{"type":"null"}]},"variantKey":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["enabled","key","payload","variantKey"],"additionalProperties":false},"SdkFeatureFlagsResponse":{"type":"object","properties":{"flags":{"type":"array","items":{"$ref":"#/components/schemas/SdkFeatureFlagResult"}}},"required":["flags"],"additionalProperties":false},"SdkResolvePaywallBody":{"type":"object","properties":{"locationSlug":{"type":"string"}},"required":["locationSlug"],"additionalProperties":false},"SdkResolvedPaywallShowing":{"type":"object","properties":{"id":{"type":"string"},"paywall":{"anyOf":[{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},{"type":"null"}]},"paywallId":{"anyOf":[{"type":"string"},{"type":"null"}]},"paywallRelease":{"anyOf":[{"type":"object","properties":{"htmlUrl":{"type":"string"},"publishedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"releaseId":{"type":"string"},"version":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]}},"required":["htmlUrl","publishedAt","releaseId","version"],"additionalProperties":false},{"type":"null"}]},"paywallReleaseId":{"anyOf":[{"type":"string"},{"type":"null"}]},"startedAt":{"type":"string"},"type":{"anyOf":[{"type":"string","enum":["paywall_release"]},{"type":"string","enum":["feature_flag"]}]}},"required":["id","paywall","paywallId","paywallRelease","paywallReleaseId","startedAt","type"],"additionalProperties":false},"SdkResolvedPaywall":{"type":"object","properties":{"location":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},"showing":{"$ref":"#/components/schemas/SdkResolvedPaywallShowing"}},"required":["location","showing"],"additionalProperties":false},"User":{"type":"object","properties":{"createdAt":{"type":"string"},"email":{"type":"string"},"emailVerified":{"type":"boolean"},"id":{"type":"string"},"image":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"organizations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"logo":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","logo","name","slug"],"additionalProperties":false}},"projects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"logo":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"organizationId":{"type":"string"},"slug":{"type":"string"}},"required":["id","logo","name","organizationId","slug"],"additionalProperties":false}},"updatedAt":{"type":"string"}},"required":["createdAt","email","emailVerified","id","image","name","organizations","projects","updatedAt"],"additionalProperties":false},"UserServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["UserServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PaymentProviderConfiguration":{"type":"object","properties":{"enabled":{"type":"boolean"},"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"providerId":{"type":"string"}},"required":["enabled","id","name","projectId","providerId"],"additionalProperties":false},"PaymentProviderConfigurationServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PaymentProviderConfigurationServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PaymentProviderProduct":{"type":"object","properties":{"configuration":{"type":"object","additionalProperties":{"type":"null"}},"id":{"type":"string"},"paymentProviderConfigurationId":{"type":"string"},"productId":{"type":"string"},"providerId":{"type":"string"}},"required":["configuration","id","paymentProviderConfigurationId","productId","providerId"],"additionalProperties":false},"PaymentProviderProductServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["PaymentProviderProductServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"DeployChangesetBody":{"type":"object","properties":{"changeset":{"type":"object","properties":{"changes":{"type":"array","items":{"anyOf":[{"type":"object","properties":{"changeType":{"type":"string","enum":["create-paywall-location"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"description":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"null"}]},{"type":"null"}]},"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["update-paywall-location"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"description":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"null"}]},{"type":"null"}]},"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["archive-paywall-location"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"slug":{"type":"string"}},"required":["slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["create-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["update-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["delete-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"slug":{"type":"string"}},"required":["slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["create-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["update-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["delete-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"slug":{"type":"string"}},"required":["slug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["create-product-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"perkSlug":{"type":"string"},"productSlug":{"type":"string"}},"required":["perkSlug","productSlug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["delete-product-perk"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"perkSlug":{"type":"string"},"productSlug":{"type":"string"}},"required":["perkSlug","productSlug"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["create-payment-provider-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"configuration":{"type":"object","additionalProperties":{"type":"null"}},"productSlug":{"type":"string"},"providerId":{"type":"string"}},"required":["configuration","productSlug","providerId"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["update-payment-provider-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"configuration":{"type":"object","additionalProperties":{"type":"null"}},"productSlug":{"type":"string"},"providerId":{"type":"string"}},"required":["configuration","productSlug","providerId"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false},{"type":"object","properties":{"changeType":{"type":"string","enum":["delete-payment-provider-product"]},"key":{"type":"string"},"payload":{"type":"object","properties":{"productSlug":{"type":"string"},"providerId":{"type":"string"}},"required":["productSlug","providerId"],"additionalProperties":false}},"required":["changeType","key","payload"],"additionalProperties":false}]}}},"required":["changes"],"additionalProperties":false}},"required":["changeset"],"additionalProperties":false},"DeployChangesetResponse":{"type":"object","properties":{"deploymentId":{"type":"string"}},"required":["deploymentId"],"additionalProperties":false},"ChangesetDeploymentServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["ChangesetDeploymentServiceError"]},"cause":{"type":"null"}},"required":["_tag","cause"],"additionalProperties":false},"CreateWebhookEndpointBody":{"type":"object","properties":{"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"events":{"type":"array","items":{"type":"string"}},"name":{"type":"string"},"url":{"type":"string"}},"required":["events","name","url"],"additionalProperties":false},"WebhookEndpoint":{"type":"object","properties":{"consecutiveFailures":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"events":{"type":"array","items":{"anyOf":[{"type":"string","enum":["person.created"]},{"type":"string","enum":["person.updated"]},{"type":"string","enum":["person.deleted"]},{"type":"string","enum":["subscription.created"]},{"type":"string","enum":["subscription.renewed"]},{"type":"string","enum":["subscription.cancelled"]},{"type":"string","enum":["subscription.expired"]},{"type":"string","enum":["purchase.completed"]},{"type":"string","enum":["purchase.refunded"]}]}},"id":{"type":"string"},"lastSuccessAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"projectId":{"type":"string"},"secret":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["active"]},{"type":"string","enum":["disabled"]},{"type":"string","enum":["failed"]}]},"url":{"type":"string"}},"required":["consecutiveFailures","createdAt","description","events","id","lastSuccessAt","name","projectId","secret","status","url"],"additionalProperties":false},"WebhookValidationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["WebhookValidationError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"WebhookServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["WebhookServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"WebhookEndpointNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["WebhookEndpointNotFoundError"]},"endpointId":{"type":"string"}},"required":["_tag","endpointId"],"additionalProperties":false},"UpdateWebhookEndpointBody":{"type":"object","properties":{"description":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"null"}]},{"type":"null"}]},"events":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"status":{"anyOf":[{"anyOf":[{"type":"string","enum":["active"]},{"type":"string","enum":["disabled"]}]},{"type":"null"}]},"url":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},"WebhookDelivery":{"type":"object","properties":{"attemptCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"completedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"eventOccurredAt":{"type":"string"},"eventType":{"type":"string"},"id":{"type":"string"},"maxAttempts":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"nextAttemptAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"payload":{"type":"null"},"projectId":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["pending"]},{"type":"string","enum":["in_progress"]},{"type":"string","enum":["succeeded"]},{"type":"string","enum":["failed"]},{"type":"string","enum":["exhausted"]}]},"webhookEndpointId":{"type":"string"}},"required":["attemptCount","completedAt","createdAt","eventOccurredAt","eventType","id","maxAttempts","nextAttemptAt","payload","projectId","status","webhookEndpointId"],"additionalProperties":false},"WebhookDeliveryAttempt":{"type":"object","properties":{"attemptNumber":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"durationMs":{"anyOf":[{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"null"}]},"errorMessage":{"anyOf":[{"type":"string"},{"type":"null"}]},"id":{"type":"string"},"responseBody":{"anyOf":[{"type":"string"},{"type":"null"}]},"statusCode":{"anyOf":[{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"null"}]},"succeeded":{"type":"boolean"}},"required":["attemptNumber","createdAt","durationMs","errorMessage","id","responseBody","statusCode","succeeded"],"additionalProperties":false},"WebhookDeliveryWithAttempts":{"type":"object","properties":{"attemptCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"attempts":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDeliveryAttempt"}},"completedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"eventOccurredAt":{"type":"string"},"eventType":{"type":"string"},"id":{"type":"string"},"maxAttempts":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"nextAttemptAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"payload":{"type":"null"},"projectId":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["pending"]},{"type":"string","enum":["in_progress"]},{"type":"string","enum":["succeeded"]},{"type":"string","enum":["failed"]},{"type":"string","enum":["exhausted"]}]},"webhookEndpointId":{"type":"string"}},"required":["attemptCount","attempts","completedAt","createdAt","eventOccurredAt","eventType","id","maxAttempts","nextAttemptAt","payload","projectId","status","webhookEndpointId"],"additionalProperties":false},"WebhookDeliveryNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["WebhookDeliveryNotFoundError"]},"deliveryId":{"type":"string"}},"required":["_tag","deliveryId"],"additionalProperties":false}},"securitySchemes":{}},"security":[],"tags":[{"name":"auth"},{"name":"api_keys"},{"name":"persons"},{"name":"organizations"},{"name":"perks"},{"name":"paywall_locations"},{"name":"projects"},{"name":"products"},{"name":"product_perks"},{"name":"sdk"},{"name":"users"},{"name":"payment_provider_configurations"},{"name":"payment_provider_products"},{"name":"changesets"},{"name":"webhooks"}]} +{"openapi":"3.1.0","info":{"title":"Api","version":"0.0.1"},"paths":{"/api/v1/auth/session":{"get":{"tags":["auth"],"operationId":"auth.session","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"method":{"anyOf":[{"type":"string","enum":["api-key"]},{"type":"string","enum":["publishable-key"]},{"type":"string","enum":["secret-key"]}]},"name":{"type":"string"},"organizations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false}},"projects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"organizationId":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","organizationId","slug"],"additionalProperties":false}}},"required":["method","name","organizations","projects"],"additionalProperties":false}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Error","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}}}}}}},"/api/v1/api-keys":{"post":{"tags":["api_keys"],"operationId":"api_keys.createSecretKey","parameters":[],"security":[],"responses":{"200":{"description":"ApiKeyWithRawKey","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyWithRawKey"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Api/ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSecretKeyBody"}}},"required":true}},"get":{"tags":["api_keys"],"operationId":"api_keys.listApiKeys","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Api/ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/api-keys/{apiKeyId}":{"get":{"tags":["api_keys"],"operationId":"api_keys.getApiKeyById","parameters":[{"name":"apiKeyId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"ApiKey","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKey"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"404":{"description":"Api/ApiKeyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ApiKeyNotFoundError"}}}},"500":{"description":"Api/ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}},"delete":{"tags":["api_keys"],"operationId":"api_keys.deleteApiKey","parameters":[{"name":"apiKeyId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"404":{"description":"Api/ApiKeyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ApiKeyNotFoundError"}}}},"500":{"description":"Api/ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/api-keys/{apiKeyId}/rotate":{"post":{"tags":["api_keys"],"operationId":"api_keys.rotateSecretKey","parameters":[{"name":"apiKeyId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"ApiKeyWithRawKey","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyWithRawKey"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"404":{"description":"Api/ApiKeyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ApiKeyNotFoundError"}}}},"500":{"description":"Api/ApiKeyServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_ApiKeyServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/persons":{"post":{"tags":["persons"],"operationId":"persons.createPerson","parameters":[],"security":[],"responses":{"200":{"description":"Person","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"400":{"description":"Api/PersonInvalidAnonymousIdError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_PersonInvalidAnonymousIdError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Api/PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePersonBody"}}},"required":true}},"get":{"tags":["persons"],"operationId":"persons.listPersons","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Person"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Api/PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/persons/{personId}":{"get":{"tags":["persons"],"operationId":"persons.getPersonById","parameters":[{"name":"personId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"Person","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"404":{"description":"Api/PersonNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_PersonNotFoundError"}}}},"500":{"description":"Api/PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/persons/by-distinct-id/{distinctId}":{"get":{"tags":["persons"],"operationId":"persons.getPersonByDistinctId","parameters":[{"name":"distinctId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"Person","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"404":{"description":"Api/PersonNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_PersonNotFoundError"}}}},"500":{"description":"Api/PersonServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_PersonServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/organizations":{"post":{"tags":["organizations"],"operationId":"organizations.createOrganization","parameters":[],"security":[],"responses":{"200":{"description":"Organization","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"500":{"description":"Api/OrganizationServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_OrganizationServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrganizationBody"}}},"required":true}}},"/api/v1/perks":{"get":{"tags":["perks"],"operationId":"perks.listPerks","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Perk"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Api/PerkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_PerkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/paywall-locations":{"get":{"tags":["paywall_locations"],"operationId":"paywall_locations.listPaywallLocations","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PaywallLocation"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Api/PaywallLocationServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_PaywallLocationServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/schema":{"get":{"tags":["schema"],"operationId":"schema.getSchema","parameters":[],"security":[],"responses":{"200":{"description":"ProjectSchemaResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectSchemaResponse"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Api/SchemaServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_SchemaServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/schema/version":{"get":{"tags":["schema"],"operationId":"schema.getSchemaVersion","parameters":[],"security":[],"responses":{"200":{"description":"SchemaVersion","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SchemaVersion"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Api/SchemaServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_SchemaServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/projects":{"post":{"tags":["projects"],"operationId":"projects.createProject","parameters":[],"security":[],"responses":{"200":{"description":"Project","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Project"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Api/AuthenticationError | Api/ProjectServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_ProjectServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectBody"}}},"required":true}}},"/api/v1/projects/{organizationId}":{"get":{"tags":["projects"],"operationId":"projects.listProjects","parameters":[{"name":"organizationId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Project"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Api/ProjectServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_ProjectServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/products":{"get":{"tags":["products"],"operationId":"products.listProducts","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Product"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Api/ProductServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_ProductServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/product-perks/by-product-id/{productId}":{"get":{"tags":["product_perks"],"operationId":"product_perks.listProductPerksByProductId","parameters":[{"name":"productId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProductPerk"}}}}},"400":{"description":"Api/ProductPerkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_ProductPerkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Api/ProductPerkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_ProductPerkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/sdk/person":{"get":{"tags":["sdk"],"operationId":"sdk.getPerson","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SdkPerson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPerson"}}}},"400":{"description":"Api/SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"404":{"description":"Api/SdkPersonNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_SdkPersonNotFoundError"}}}},"500":{"description":"Api/AuthenticationError | Api/SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/sdk/identify":{"post":{"tags":["sdk"],"operationId":"sdk.identifyPerson","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SdkPerson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPerson"}}}},"400":{"description":"Api/SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"404":{"description":"Api/SdkPersonNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_SdkPersonNotFoundError"}}}},"409":{"description":"Api/SdkPersonAlreadyIdentifiedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_SdkPersonAlreadyIdentifiedError"}}}},"500":{"description":"Api/AuthenticationError | Api/SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkIdentifyBody"}}},"required":true}}},"/api/v1/sdk/person/traits":{"post":{"tags":["sdk"],"operationId":"sdk.syncPersonAttributes","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SdkPerson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkPerson"}}}},"400":{"description":"Api/SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"404":{"description":"Api/SdkPersonNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_SdkPersonNotFoundError"}}}},"500":{"description":"Api/AuthenticationError | Api/SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkSyncPersonAttributesBody"}}},"required":true}}},"/api/v1/sdk/sync-transaction":{"post":{"tags":["sdk"],"operationId":"sdk.syncTransaction","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SdkSyncTransactionResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkSyncTransactionResponse"}}}},"400":{"description":"Api/SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"500":{"description":"Api/AuthenticationError | Api/SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"anyOf":[{"type":"string","enum":["ios"]},{"type":"string","enum":["android"]}]},"productSlug":{"type":"string"},"purchaseDate":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"purchaseToken":{"anyOf":[{"type":"string"},{"type":"null"}]},"quantity":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"receipt":{"anyOf":[{"type":"string"},{"type":"null"}]},"transactionId":{"type":"string"}},"required":["platform","productSlug","purchaseDate","quantity","transactionId"],"additionalProperties":false}}},"required":true}}},"/api/v1/sdk/evaluate-flags":{"post":{"tags":["sdk"],"operationId":"sdk.evaluateFeatureFlags","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SdkFeatureFlagsResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkFeatureFlagsResponse"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"500":{"description":"Api/AuthenticationError | Api/SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluateFeatureFlagsBody"}}},"required":true}}},"/api/v1/sdk/resolve-paywall":{"post":{"tags":["sdk"],"operationId":"sdk.resolvePaywall","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SdkResolvedPaywall"},{"type":"null"}]}}}},"400":{"description":"Api/SdkValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_SdkValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"500":{"description":"Api/AuthenticationError | Api/SdkServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_SdkServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkResolvePaywallBody"}}},"required":true}}},"/api/v1/sdk/schema":{"get":{"tags":["sdk"],"operationId":"sdk.getSchema","parameters":[{"name":"x-distinct-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-publishable-key","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-bundle-id","in":"header","schema":{"type":"string"},"required":true},{"name":"x-client-locale","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-client-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-is-backgrounded","in":"header","schema":{"type":"string","enum":["false"]},"required":true},{"name":"x-is-debug-build","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-nonce","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-observer-mode","in":"header","schema":{"anyOf":[{"type":"string","enum":["true"]},{"type":"string","enum":["false"]}]},"required":true},{"name":"x-platform","in":"header","schema":{"type":"string"},"required":true},{"name":"x-platform-brand","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-device","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-flavor","in":"header","schema":{"anyOf":[{"type":"string","enum":["native"]},{"type":"string","enum":["browser"]}]},"required":true},{"name":"x-platform-flavor-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-platform-version","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-preferred-locales","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"x-sdk","in":"header","schema":{"anyOf":[{"type":"string","enum":["react-native"]},{"type":"string","enum":["web"]}]},"required":true},{"name":"x-sdk-version","in":"header","schema":{"type":"string"},"required":true},{"name":"x-storefront","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SdkSchema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SdkSchema"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"500":{"description":"Api/AuthenticationError | Api/SchemaServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_SchemaServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/users/current":{"get":{"tags":["users"],"operationId":"users.getUser","parameters":[],"security":[],"responses":{"200":{"description":"User","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"500":{"description":"Api/AuthenticationError | Api/UserServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_UserServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/payment-provider-configurations":{"get":{"tags":["payment_provider_configurations"],"operationId":"payment_provider_configurations.listPaymentProviderConfigurations","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PaymentProviderConfiguration"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Api/PaymentProviderConfigurationServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_PaymentProviderConfigurationServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/payment-provider-products":{"get":{"tags":["payment_provider_products"],"operationId":"payment_provider_products.listPaymentProviderProducts","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PaymentProviderProduct"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Api/PaymentProviderProductServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_PaymentProviderProductServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/endpoints":{"post":{"tags":["webhooks"],"operationId":"webhooks.createWebhookEndpoint","parameters":[],"security":[],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"Api/WebhookValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_WebhookValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Api/WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhookEndpointBody"}}},"required":true}},"get":{"tags":["webhooks"],"operationId":"webhooks.listWebhookEndpoints","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEndpoint"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Api/WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/endpoints/{endpointId}":{"get":{"tags":["webhooks"],"operationId":"webhooks.getWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"404":{"description":"Api/WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_WebhookEndpointNotFoundError"}}}},"500":{"description":"Api/WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}},"patch":{"tags":["webhooks"],"operationId":"webhooks.updateWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"Api/WebhookValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_WebhookValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"404":{"description":"Api/WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_WebhookEndpointNotFoundError"}}}},"500":{"description":"Api/WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWebhookEndpointBody"}}},"required":true}},"delete":{"tags":["webhooks"],"operationId":"webhooks.deleteWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"404":{"description":"Api/WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_WebhookEndpointNotFoundError"}}}},"500":{"description":"Api/WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/endpoints/{endpointId}/rotate-secret":{"post":{"tags":["webhooks"],"operationId":"webhooks.rotateWebhookSecret","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookEndpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"404":{"description":"Api/WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_WebhookEndpointNotFoundError"}}}},"500":{"description":"Api/WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/endpoints/{endpointId}/test":{"post":{"tags":["webhooks"],"operationId":"webhooks.testWebhookEndpoint","parameters":[{"name":"endpointId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookDelivery","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDelivery"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"404":{"description":"Api/WebhookEndpointNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_WebhookEndpointNotFoundError"}}}},"500":{"description":"Api/WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/deliveries":{"get":{"tags":["webhooks"],"operationId":"webhooks.listWebhookDeliveries","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDelivery"}}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"500":{"description":"Api/WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/deliveries/{deliveryId}":{"get":{"tags":["webhooks"],"operationId":"webhooks.getWebhookDelivery","parameters":[{"name":"deliveryId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookDeliveryWithAttempts","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryWithAttempts"}}}},"400":{"description":"The request or response did not match the expected schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"404":{"description":"Api/WebhookDeliveryNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_WebhookDeliveryNotFoundError"}}}},"500":{"description":"Api/WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}},"/api/v1/webhooks/deliveries/{deliveryId}/retry":{"post":{"tags":["webhooks"],"operationId":"webhooks.retryWebhookDelivery","parameters":[{"name":"deliveryId","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"WebhookDelivery","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDelivery"}}}},"400":{"description":"Api/WebhookValidationError | The request or response did not match the expected schema","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_WebhookValidationError"},{"$ref":"#/components/schemas/effect_HttpApiSchemaError"}]}}}},"403":{"description":"Api/ActionForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_ActionForbiddenError"}}}},"404":{"description":"Api/WebhookDeliveryNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Api_WebhookDeliveryNotFoundError"}}}},"500":{"description":"Api/WebhookServiceError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/Api_WebhookServiceError"},{"anyOf":[{"$ref":"#/components/schemas/Api_AuthenticationError"},{"$ref":"#/components/schemas/Api_NotAuthenticatedError"}]}]}}}}}}}},"components":{"schemas":{"Api_ActionForbiddenError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/ActionForbiddenError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"Api_AuthenticationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/AuthenticationError"]},"cause":{"type":"string"},"message":{"type":"string"}},"required":["_tag","cause","message"],"additionalProperties":false},"Api_NotAuthenticatedError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/NotAuthenticatedError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"effect_HttpApiSchemaError":{"type":"object","properties":{"_tag":{"type":"string","enum":["HttpApiSchemaError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"CreateSecretKeyBody":{"type":"object","properties":{"name":{"type":"string"},"projectId":{"type":"string"}},"required":["name","projectId"],"additionalProperties":false},"ApiKeyWithRawKey":{"type":"object","properties":{"end":{"type":"string"},"id":{"type":"string"},"isPublic":{"type":"boolean"},"name":{"type":"string"},"prefix":{"type":"string"},"projectId":{"type":"string"},"rawKey":{"type":"string"}},"required":["end","id","isPublic","name","prefix","projectId","rawKey"],"additionalProperties":false},"Api_ApiKeyServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/ApiKeyServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"ApiKey":{"type":"object","properties":{"end":{"type":"string"},"id":{"type":"string"},"isPublic":{"type":"boolean"},"name":{"type":"string"},"prefix":{"type":"string"},"projectId":{"type":"string"},"rawKey":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["end","id","isPublic","name","prefix","projectId"],"additionalProperties":false},"Api_ApiKeyNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/ApiKeyNotFoundError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"CreatePersonBody":{"type":"object","properties":{"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["distinctId"],"additionalProperties":false},"Person":{"type":"object","properties":{"personId":{"type":"string"},"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["personId","distinctId","email","name"],"additionalProperties":false},"Api_PersonInvalidAnonymousIdError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/PersonInvalidAnonymousIdError"]},"id":{"type":"string","allOf":[{"minLength":1}]}},"required":["_tag","id"],"additionalProperties":false},"Api_PersonServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/PersonServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"Api_PersonNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/PersonNotFoundError"]},"id":{"type":"string","allOf":[{"minLength":1}]}},"required":["_tag","id"],"additionalProperties":false},"CreateOrganizationBody":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false},"Organization":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},"Api_OrganizationServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/OrganizationServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"Perk":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","projectId","slug"],"additionalProperties":false},"Api_PerkServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/PerkServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PaywallLocation":{"type":"object","properties":{"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"slug":{"type":"string"}},"required":["description","id","name","projectId","slug"],"additionalProperties":false},"Api_PaywallLocationServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/PaywallLocationServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"SchemaLocation":{"type":"object","properties":{"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"slug":{"type":"string"}},"required":["description","name","slug"],"additionalProperties":false},"SchemaPerk":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false},"SchemaProductProvider":{"type":"object","properties":{"configuration":{"type":"object","additionalProperties":{"type":"null"}},"providerId":{"anyOf":[{"type":"string","enum":["appleAppStore"]},{"type":"string","enum":["googlePlay"]}]}},"required":["configuration","providerId"],"additionalProperties":false},"SchemaProduct":{"type":"object","properties":{"name":{"type":"string"},"perks":{"type":"array","items":{"type":"string"}},"providers":{"type":"array","items":{"$ref":"#/components/schemas/SchemaProductProvider"}},"slug":{"type":"string"},"type":{"type":"string","enum":["subscription"]}},"required":["name","perks","providers","slug","type"],"additionalProperties":false},"ProjectSchemaResponse":{"type":"object","properties":{"enabledProviders":{"type":"array","items":{"anyOf":[{"type":"string","enum":["appleAppStore"]},{"type":"string","enum":["googlePlay"]}]}},"locations":{"type":"array","items":{"$ref":"#/components/schemas/SchemaLocation"}},"perks":{"type":"array","items":{"$ref":"#/components/schemas/SchemaPerk"}},"products":{"type":"array","items":{"$ref":"#/components/schemas/SchemaProduct"}},"version":{"type":"string"}},"required":["enabledProviders","locations","perks","products","version"],"additionalProperties":false},"Api_SchemaServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/SchemaServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"SchemaVersion":{"type":"object","properties":{"version":{"type":"string"}},"required":["version"],"additionalProperties":false},"CreateProjectBody":{"type":"object","properties":{"name":{"type":"string"},"organizationId":{"type":"string"}},"required":["name","organizationId"],"additionalProperties":false},"Project":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},"Api_ProjectServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/ProjectServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"Product":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"slug":{"type":"string"},"type":{"anyOf":[{"type":"string","enum":["subscription"]},{"type":"string","enum":["one-time"]},{"type":"string","enum":["one-time-consumable"]}]}},"required":["id","name","projectId","slug","type"],"additionalProperties":false},"Api_ProductServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/ProductServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"ProductPerk":{"type":"object","properties":{"id":{"type":"string"},"perkId":{"type":"string"},"productId":{"type":"string"}},"required":["id","perkId","productId"],"additionalProperties":false},"Api_ProductPerkServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/ProductPerkServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"Api_ProductPerkValidationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/ProductPerkValidationError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"SdkEntitlementGrant":{"type":"object","properties":{"expiresAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"perkId":{"type":"string"},"source":{"anyOf":[{"type":"string","enum":["subscription"]},{"type":"string","enum":["purchase"]},{"type":"string","enum":["manual"]}]},"sourceId":{"anyOf":[{"type":"string"},{"type":"null"}]},"sourcePersonId":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["active"]},{"type":"string","enum":["expired"]}]}},"required":["expiresAt","perkId","source","sourceId","sourcePersonId","status"],"additionalProperties":false},"SdkPurchaseHistoryEntry":{"type":"object","properties":{"createdAt":{"type":"string"},"productId":{"anyOf":[{"type":"string"},{"type":"null"}]},"providerKey":{"type":"string"},"purchaseId":{"type":"string"},"sourcePersonId":{"type":"string"},"type":{"anyOf":[{"type":"string","enum":["one_time"]},{"type":"string","enum":["subscription"]}]}},"required":["createdAt","productId","providerKey","purchaseId","sourcePersonId","type"],"additionalProperties":false},"SdkCurrentSubscription":{"type":"object","properties":{"expiresAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"productId":{"anyOf":[{"type":"string"},{"type":"null"}]},"status":{"anyOf":[{"type":"string","enum":["none"]},{"type":"string","enum":["active"]},{"type":"string","enum":["canceled"]},{"type":"string","enum":["past_due"]},{"type":"string","enum":["trialing"]}]},"subscriptionId":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["expiresAt","productId","status","subscriptionId"],"additionalProperties":false},"SdkSubscriptionHistoryEntry":{"type":"object","properties":{"canceledAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"expiresAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"isTrial":{"type":"boolean"},"productId":{"anyOf":[{"type":"string"},{"type":"null"}]},"sourcePersonId":{"type":"string"},"startsAt":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["active"]},{"type":"string","enum":["canceled"]},{"type":"string","enum":["expired"]},{"type":"string","enum":["trialing"]},{"type":"string","enum":["past_due"]}]},"subscriptionId":{"type":"string"}},"required":["canceledAt","expiresAt","isTrial","productId","sourcePersonId","startsAt","status","subscriptionId"],"additionalProperties":false},"SdkPerson":{"type":"object","properties":{"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"entitlements":{"type":"object","properties":{"grants":{"type":"array","items":{"$ref":"#/components/schemas/SdkEntitlementGrant"}}},"required":["grants"],"additionalProperties":false},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"personId":{"type":"string"},"purchases":{"type":"object","properties":{"history":{"type":"array","items":{"$ref":"#/components/schemas/SdkPurchaseHistoryEntry"}}},"required":["history"],"additionalProperties":false},"snapshotContext":{"type":"object","properties":{"includedPersonIds":{"type":"array","items":{"type":"string"}},"migrationJobId":{"anyOf":[{"type":"string"},{"type":"null"}]},"mode":{"anyOf":[{"type":"string","enum":["persisted"]},{"type":"string","enum":["temporary_pending_transfer"]}]}},"required":["includedPersonIds","migrationJobId","mode"],"additionalProperties":false},"subscriptions":{"type":"object","properties":{"current":{"anyOf":[{"$ref":"#/components/schemas/SdkCurrentSubscription"},{"type":"null"}]},"history":{"type":"array","items":{"$ref":"#/components/schemas/SdkSubscriptionHistoryEntry"}}},"required":["current","history"],"additionalProperties":false}},"required":["distinctId","email","entitlements","name","personId","purchases","snapshotContext","subscriptions"],"additionalProperties":false},"Api_SdkServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/SdkServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"Api_SdkPersonNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/SdkPersonNotFoundError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"Api_SdkValidationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/SdkValidationError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"SdkIdentifyBody":{"type":"object","properties":{"distinctId":{"type":"string"},"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"traits":{"anyOf":[{"type":"object","additionalProperties":{"anyOf":[{"type":"string"},{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"boolean"},{"type":"null"}]}},{"type":"null"}]}},"required":["distinctId"],"additionalProperties":false},"Api_SdkPersonAlreadyIdentifiedError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/SdkPersonAlreadyIdentifiedError"]},"distinctId":{"type":"string"}},"required":["_tag","distinctId"],"additionalProperties":false},"SdkSyncPersonAttributesBody":{"type":"object","properties":{"email":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"traits":{"anyOf":[{"type":"object","additionalProperties":{"anyOf":[{"type":"string"},{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"boolean"},{"type":"null"}]}},{"type":"null"}]}},"additionalProperties":false},"SdkSyncTransactionResponse":{"type":"object","properties":{"accepted":{"type":"boolean"}},"required":["accepted"],"additionalProperties":false},"EvaluateFeatureFlagsBody":{"type":"object","properties":{"flagKeys":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]}},"additionalProperties":false},"SdkFeatureFlagResult":{"type":"object","properties":{"enabled":{"type":"boolean"},"key":{"type":"string"},"payload":{"anyOf":[{"type":"null"},{"type":"null"}]},"variantKey":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["enabled","key","payload","variantKey"],"additionalProperties":false},"SdkFeatureFlagsResponse":{"type":"object","properties":{"flags":{"type":"array","items":{"$ref":"#/components/schemas/SdkFeatureFlagResult"}}},"required":["flags"],"additionalProperties":false},"SdkResolvePaywallBody":{"type":"object","properties":{"locationSlug":{"type":"string"}},"required":["locationSlug"],"additionalProperties":false},"SdkResolvedPaywallShowing":{"type":"object","properties":{"id":{"type":"string"},"paywall":{"anyOf":[{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},{"type":"null"}]},"paywallId":{"anyOf":[{"type":"string"},{"type":"null"}]},"paywallRelease":{"anyOf":[{"type":"object","properties":{"htmlUrl":{"type":"string"},"publishedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"releaseId":{"type":"string"},"version":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]}},"required":["htmlUrl","publishedAt","releaseId","version"],"additionalProperties":false},{"type":"null"}]},"paywallReleaseId":{"anyOf":[{"type":"string"},{"type":"null"}]},"startedAt":{"type":"string"},"type":{"anyOf":[{"type":"string","enum":["paywall_release"]},{"type":"string","enum":["feature_flag"]}]}},"required":["id","paywall","paywallId","paywallRelease","paywallReleaseId","startedAt","type"],"additionalProperties":false},"SdkResolvedPaywall":{"type":"object","properties":{"location":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"additionalProperties":false},"showing":{"$ref":"#/components/schemas/SdkResolvedPaywallShowing"}},"required":["location","showing"],"additionalProperties":false},"SdkSchemaLocation":{"type":"object","properties":{"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"slug":{"type":"string"}},"required":["description","name","slug"],"additionalProperties":false},"SdkSchemaPerk":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"additionalProperties":false},"SdkSchemaProduct":{"type":"object","properties":{"configuration":{"type":"object","properties":{"perks":{"type":"object","additionalProperties":{"type":"boolean","enum":[true]}},"providers":{"type":"object","properties":{"appleAppStore":{"anyOf":[{"type":"object","additionalProperties":{"type":"null"}},{"type":"null"}]},"googlePlay":{"anyOf":[{"type":"object","additionalProperties":{"type":"null"}},{"type":"null"}]}},"additionalProperties":false}},"required":["perks","providers"],"additionalProperties":false},"properties":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false},"slug":{"type":"string"},"type":{"type":"string","enum":["subscription"]}},"required":["configuration","properties","slug","type"],"additionalProperties":false},"SdkSchema":{"type":"object","properties":{"locations":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/SdkSchemaLocation"}},"perks":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/SdkSchemaPerk"}},"products":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/SdkSchemaProduct"}},"version":{"type":"string"}},"required":["locations","perks","products","version"],"additionalProperties":false},"User":{"type":"object","properties":{"createdAt":{"type":"string"},"email":{"type":"string"},"emailVerified":{"type":"boolean"},"id":{"type":"string"},"image":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"organizations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"logo":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","logo","name","slug"],"additionalProperties":false}},"projects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"logo":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"organizationId":{"type":"string"},"slug":{"type":"string"}},"required":["id","logo","name","organizationId","slug"],"additionalProperties":false}},"updatedAt":{"type":"string"}},"required":["createdAt","email","emailVerified","id","image","name","organizations","projects","updatedAt"],"additionalProperties":false},"Api_UserServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/UserServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PaymentProviderConfiguration":{"type":"object","properties":{"enabled":{"type":"boolean"},"id":{"type":"string"},"name":{"type":"string"},"projectId":{"type":"string"},"providerId":{"type":"string"}},"required":["enabled","id","name","projectId","providerId"],"additionalProperties":false},"Api_PaymentProviderConfigurationServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/PaymentProviderConfigurationServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"PaymentProviderProduct":{"type":"object","properties":{"configuration":{"type":"object","additionalProperties":{"type":"null"}},"id":{"type":"string"},"paymentProviderConfigurationId":{"type":"string"},"productId":{"type":"string"},"providerId":{"type":"string"}},"required":["configuration","id","paymentProviderConfigurationId","productId","providerId"],"additionalProperties":false},"Api_PaymentProviderProductServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/PaymentProviderProductServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"CreateWebhookEndpointBody":{"type":"object","properties":{"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"events":{"type":"array","items":{"type":"string"}},"name":{"type":"string"},"url":{"type":"string"}},"required":["events","name","url"],"additionalProperties":false},"WebhookEndpoint":{"type":"object","properties":{"consecutiveFailures":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"events":{"type":"array","items":{"anyOf":[{"type":"string","enum":["person.created"]},{"type":"string","enum":["person.updated"]},{"type":"string","enum":["person.deleted"]},{"type":"string","enum":["subscription.created"]},{"type":"string","enum":["subscription.renewed"]},{"type":"string","enum":["subscription.cancelled"]},{"type":"string","enum":["subscription.expired"]},{"type":"string","enum":["purchase.completed"]},{"type":"string","enum":["purchase.refunded"]}]}},"id":{"type":"string"},"lastSuccessAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"name":{"type":"string"},"projectId":{"type":"string"},"secret":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["active"]},{"type":"string","enum":["disabled"]},{"type":"string","enum":["failed"]}]},"url":{"type":"string"}},"required":["consecutiveFailures","createdAt","description","events","id","lastSuccessAt","name","projectId","secret","status","url"],"additionalProperties":false},"Api_WebhookValidationError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/WebhookValidationError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"Api_WebhookServiceError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/WebhookServiceError"]},"cause":{"type":"string"}},"required":["_tag","cause"],"additionalProperties":false},"Api_WebhookEndpointNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/WebhookEndpointNotFoundError"]},"endpointId":{"type":"string"}},"required":["_tag","endpointId"],"additionalProperties":false},"UpdateWebhookEndpointBody":{"type":"object","properties":{"description":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"null"}]},{"type":"null"}]},"events":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}]},"name":{"anyOf":[{"type":"string"},{"type":"null"}]},"status":{"anyOf":[{"anyOf":[{"type":"string","enum":["active"]},{"type":"string","enum":["disabled"]}]},{"type":"null"}]},"url":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},"WebhookDelivery":{"type":"object","properties":{"attemptCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"completedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"eventOccurredAt":{"type":"string"},"eventType":{"type":"string"},"id":{"type":"string"},"maxAttempts":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"nextAttemptAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"payload":{"type":"null"},"projectId":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["pending"]},{"type":"string","enum":["in_progress"]},{"type":"string","enum":["succeeded"]},{"type":"string","enum":["failed"]},{"type":"string","enum":["exhausted"]}]},"webhookEndpointId":{"type":"string"}},"required":["attemptCount","completedAt","createdAt","eventOccurredAt","eventType","id","maxAttempts","nextAttemptAt","payload","projectId","status","webhookEndpointId"],"additionalProperties":false},"WebhookDeliveryAttempt":{"type":"object","properties":{"attemptNumber":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"durationMs":{"anyOf":[{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"null"}]},"errorMessage":{"anyOf":[{"type":"string"},{"type":"null"}]},"id":{"type":"string"},"responseBody":{"anyOf":[{"type":"string"},{"type":"null"}]},"statusCode":{"anyOf":[{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},{"type":"null"}]},"succeeded":{"type":"boolean"}},"required":["attemptNumber","createdAt","durationMs","errorMessage","id","responseBody","statusCode","succeeded"],"additionalProperties":false},"WebhookDeliveryWithAttempts":{"type":"object","properties":{"attemptCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"attempts":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDeliveryAttempt"}},"completedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"eventOccurredAt":{"type":"string"},"eventType":{"type":"string"},"id":{"type":"string"},"maxAttempts":{"anyOf":[{"type":"number"},{"type":"string","enum":["NaN"]},{"type":"string","enum":["Infinity"]},{"type":"string","enum":["-Infinity"]}]},"nextAttemptAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"payload":{"type":"null"},"projectId":{"type":"string"},"status":{"anyOf":[{"type":"string","enum":["pending"]},{"type":"string","enum":["in_progress"]},{"type":"string","enum":["succeeded"]},{"type":"string","enum":["failed"]},{"type":"string","enum":["exhausted"]}]},"webhookEndpointId":{"type":"string"}},"required":["attemptCount","attempts","completedAt","createdAt","eventOccurredAt","eventType","id","maxAttempts","nextAttemptAt","payload","projectId","status","webhookEndpointId"],"additionalProperties":false},"Api_WebhookDeliveryNotFoundError":{"type":"object","properties":{"_tag":{"type":"string","enum":["Api/WebhookDeliveryNotFoundError"]},"deliveryId":{"type":"string"}},"required":["_tag","deliveryId"],"additionalProperties":false}},"securitySchemes":{}},"security":[],"tags":[{"name":"auth"},{"name":"api_keys"},{"name":"persons"},{"name":"organizations"},{"name":"perks"},{"name":"paywall_locations"},{"name":"schema"},{"name":"projects"},{"name":"products"},{"name":"product_perks"},{"name":"sdk"},{"name":"users"},{"name":"payment_provider_configurations"},{"name":"payment_provider_products"},{"name":"webhooks"}]} diff --git a/packages/generated-clients/src/core/generated.ts b/packages/generated-clients/src/core/generated.ts index e738e0727..5eab012e7 100644 --- a/packages/generated-clients/src/core/generated.ts +++ b/packages/generated-clients/src/core/generated.ts @@ -30,29 +30,29 @@ export interface EffectHttpApiSchemaError { readonly "message": string } -export type ActionForbiddenErrorTag = "ActionForbiddenError" +export type ApiActionForbiddenErrorTag = "Api/ActionForbiddenError" -export interface ActionForbiddenError { - readonly "_tag": ActionForbiddenErrorTag; +export interface ApiActionForbiddenError { + readonly "_tag": ApiActionForbiddenErrorTag; readonly "message": string } -export type AuthenticationErrorTag = "AuthenticationError" +export type ApiAuthenticationErrorTag = "Api/AuthenticationError" -export interface AuthenticationError { - readonly "_tag": AuthenticationErrorTag; +export interface ApiAuthenticationError { + readonly "_tag": ApiAuthenticationErrorTag; readonly "cause": string; readonly "message": string } -export type NotAuthenticatedErrorTag = "NotAuthenticatedError" +export type ApiNotAuthenticatedErrorTag = "Api/NotAuthenticatedError" -export interface NotAuthenticatedError { - readonly "_tag": NotAuthenticatedErrorTag; +export interface ApiNotAuthenticatedError { + readonly "_tag": ApiNotAuthenticatedErrorTag; readonly "message": string } -export type AuthSession500 = AuthenticationError | NotAuthenticatedError +export type AuthSession500 = ApiAuthenticationError | ApiNotAuthenticatedError export interface ApiKey { readonly "end": string; @@ -66,14 +66,14 @@ export interface ApiKey { export type ApiKeysListApiKeys200 = ReadonlyArray -export type ApiKeyServiceErrorTag = "ApiKeyServiceError" +export type ApiApiKeyServiceErrorTag = "Api/ApiKeyServiceError" -export interface ApiKeyServiceError { - readonly "_tag": ApiKeyServiceErrorTag; +export interface ApiApiKeyServiceError { + readonly "_tag": ApiApiKeyServiceErrorTag; readonly "cause": string } -export type ApiKeysListApiKeys500 = ApiKeyServiceError | AuthenticationError | NotAuthenticatedError +export type ApiKeysListApiKeys500 = ApiApiKeyServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export interface CreateSecretKeyBody { readonly "name": string; @@ -90,20 +90,20 @@ export interface ApiKeyWithRawKey { readonly "rawKey": string } -export type ApiKeysCreateSecretKey500 = ApiKeyServiceError | AuthenticationError | NotAuthenticatedError +export type ApiKeysCreateSecretKey500 = ApiApiKeyServiceError | ApiAuthenticationError | ApiNotAuthenticatedError -export type ApiKeyNotFoundErrorTag = "ApiKeyNotFoundError" +export type ApiApiKeyNotFoundErrorTag = "Api/ApiKeyNotFoundError" -export interface ApiKeyNotFoundError { - readonly "_tag": ApiKeyNotFoundErrorTag; +export interface ApiApiKeyNotFoundError { + readonly "_tag": ApiApiKeyNotFoundErrorTag; readonly "message": string } -export type ApiKeysGetApiKeyById500 = ApiKeyServiceError | AuthenticationError | NotAuthenticatedError +export type ApiKeysGetApiKeyById500 = ApiApiKeyServiceError | ApiAuthenticationError | ApiNotAuthenticatedError -export type ApiKeysDeleteApiKey500 = ApiKeyServiceError | AuthenticationError | NotAuthenticatedError +export type ApiKeysDeleteApiKey500 = ApiApiKeyServiceError | ApiAuthenticationError | ApiNotAuthenticatedError -export type ApiKeysRotateSecretKey500 = ApiKeyServiceError | AuthenticationError | NotAuthenticatedError +export type ApiKeysRotateSecretKey500 = ApiApiKeyServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export interface Person { readonly "personId": string; @@ -114,14 +114,14 @@ export interface Person { export type PersonsListPersons200 = ReadonlyArray -export type PersonServiceErrorTag = "PersonServiceError" +export type ApiPersonServiceErrorTag = "Api/PersonServiceError" -export interface PersonServiceError { - readonly "_tag": PersonServiceErrorTag; +export interface ApiPersonServiceError { + readonly "_tag": ApiPersonServiceErrorTag; readonly "cause": string } -export type PersonsListPersons500 = PersonServiceError | AuthenticationError | NotAuthenticatedError +export type PersonsListPersons500 = ApiPersonServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export interface CreatePersonBody { readonly "distinctId": string; @@ -129,27 +129,27 @@ export interface CreatePersonBody { readonly "name"?: string | null | undefined } -export type PersonInvalidAnonymousIdErrorTag = "PersonInvalidAnonymousIdError" +export type ApiPersonInvalidAnonymousIdErrorTag = "Api/PersonInvalidAnonymousIdError" -export interface PersonInvalidAnonymousIdError { - readonly "_tag": PersonInvalidAnonymousIdErrorTag; +export interface ApiPersonInvalidAnonymousIdError { + readonly "_tag": ApiPersonInvalidAnonymousIdErrorTag; readonly "id": string } -export type PersonsCreatePerson400 = PersonInvalidAnonymousIdError | EffectHttpApiSchemaError +export type PersonsCreatePerson400 = ApiPersonInvalidAnonymousIdError | EffectHttpApiSchemaError -export type PersonsCreatePerson500 = PersonServiceError | AuthenticationError | NotAuthenticatedError +export type PersonsCreatePerson500 = ApiPersonServiceError | ApiAuthenticationError | ApiNotAuthenticatedError -export type PersonNotFoundErrorTag = "PersonNotFoundError" +export type ApiPersonNotFoundErrorTag = "Api/PersonNotFoundError" -export interface PersonNotFoundError { - readonly "_tag": PersonNotFoundErrorTag; +export interface ApiPersonNotFoundError { + readonly "_tag": ApiPersonNotFoundErrorTag; readonly "id": string } -export type PersonsGetPersonById500 = PersonServiceError | AuthenticationError | NotAuthenticatedError +export type PersonsGetPersonById500 = ApiPersonServiceError | ApiAuthenticationError | ApiNotAuthenticatedError -export type PersonsGetPersonByDistinctId500 = PersonServiceError | AuthenticationError | NotAuthenticatedError +export type PersonsGetPersonByDistinctId500 = ApiPersonServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export interface CreateOrganizationBody { readonly "name": string @@ -161,14 +161,14 @@ export interface Organization { readonly "slug": string } -export type OrganizationServiceErrorTag = "OrganizationServiceError" +export type ApiOrganizationServiceErrorTag = "Api/OrganizationServiceError" -export interface OrganizationServiceError { - readonly "_tag": OrganizationServiceErrorTag; +export interface ApiOrganizationServiceError { + readonly "_tag": ApiOrganizationServiceErrorTag; readonly "cause": string } -export type OrganizationsCreateOrganization500 = OrganizationServiceError | AuthenticationError | NotAuthenticatedError +export type OrganizationsCreateOrganization500 = ApiOrganizationServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export interface Perk { readonly "id": string; @@ -179,14 +179,14 @@ export interface Perk { export type PerksListPerks200 = ReadonlyArray -export type PerkServiceErrorTag = "PerkServiceError" +export type ApiPerkServiceErrorTag = "Api/PerkServiceError" -export interface PerkServiceError { - readonly "_tag": PerkServiceErrorTag; +export interface ApiPerkServiceError { + readonly "_tag": ApiPerkServiceErrorTag; readonly "cause": string } -export type PerksListPerks500 = PerkServiceError | AuthenticationError | NotAuthenticatedError +export type PerksListPerks500 = ApiPerkServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export interface PaywallLocation { readonly "description": string | null; @@ -198,14 +198,65 @@ export interface PaywallLocation { export type PaywallLocationsListPaywallLocations200 = ReadonlyArray -export type PaywallLocationServiceErrorTag = "PaywallLocationServiceError" +export type ApiPaywallLocationServiceErrorTag = "Api/PaywallLocationServiceError" -export interface PaywallLocationServiceError { - readonly "_tag": PaywallLocationServiceErrorTag; +export interface ApiPaywallLocationServiceError { + readonly "_tag": ApiPaywallLocationServiceErrorTag; readonly "cause": string } -export type PaywallLocationsListPaywallLocations500 = PaywallLocationServiceError | AuthenticationError | NotAuthenticatedError +export type PaywallLocationsListPaywallLocations500 = ApiPaywallLocationServiceError | ApiAuthenticationError | ApiNotAuthenticatedError + +export interface SchemaLocation { + readonly "description": string | null; + readonly "name": string; + readonly "slug": string +} + +export interface SchemaPerk { + readonly "name": string; + readonly "slug": string +} + +export type SchemaProductProviderProviderIdEnum = "googlePlay" + +export interface SchemaProductProvider { + readonly "configuration": Record; + readonly "providerId": SchemaProductProviderProviderIdEnum | SchemaProductProviderProviderIdEnum +} + +export type SchemaProductType = "subscription" + +export interface SchemaProduct { + readonly "name": string; + readonly "perks": ReadonlyArray; + readonly "providers": ReadonlyArray; + readonly "slug": string; + readonly "type": SchemaProductType +} + +export interface ProjectSchemaResponse { + readonly "enabledProviders": ReadonlyArray<"appleAppStore" | "googlePlay">; + readonly "locations": ReadonlyArray; + readonly "perks": ReadonlyArray; + readonly "products": ReadonlyArray; + readonly "version": string +} + +export type ApiSchemaServiceErrorTag = "Api/SchemaServiceError" + +export interface ApiSchemaServiceError { + readonly "_tag": ApiSchemaServiceErrorTag; + readonly "cause": string +} + +export type SchemaGetSchema500 = ApiSchemaServiceError | ApiAuthenticationError | ApiNotAuthenticatedError + +export interface SchemaVersion { + readonly "version": string +} + +export type SchemaGetSchemaVersion500 = ApiSchemaServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export interface CreateProjectBody { readonly "name": string; @@ -218,18 +269,18 @@ export interface Project { readonly "slug": string } -export type ProjectServiceErrorTag = "ProjectServiceError" +export type ApiProjectServiceErrorTag = "Api/ProjectServiceError" -export interface ProjectServiceError { - readonly "_tag": ProjectServiceErrorTag; +export interface ApiProjectServiceError { + readonly "_tag": ApiProjectServiceErrorTag; readonly "cause": string } -export type ProjectsCreateProject500 = AuthenticationError | ProjectServiceError | AuthenticationError | NotAuthenticatedError +export type ProjectsCreateProject500 = ApiAuthenticationError | ApiProjectServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export type ProjectsListProjects200 = ReadonlyArray -export type ProjectsListProjects500 = ProjectServiceError | AuthenticationError | NotAuthenticatedError +export type ProjectsListProjects500 = ApiProjectServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export type ProductTypeEnum = "one-time-consumable" @@ -243,14 +294,14 @@ export interface Product { export type ProductsListProducts200 = ReadonlyArray -export type ProductServiceErrorTag = "ProductServiceError" +export type ApiProductServiceErrorTag = "Api/ProductServiceError" -export interface ProductServiceError { - readonly "_tag": ProductServiceErrorTag; +export interface ApiProductServiceError { + readonly "_tag": ApiProductServiceErrorTag; readonly "cause": string } -export type ProductsListProducts500 = ProductServiceError | AuthenticationError | NotAuthenticatedError +export type ProductsListProducts500 = ApiProductServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export interface ProductPerk { readonly "id": string; @@ -260,23 +311,23 @@ export interface ProductPerk { export type ProductPerksListProductPerksByProductId200 = ReadonlyArray -export type ProductPerkValidationErrorTag = "ProductPerkValidationError" +export type ApiProductPerkValidationErrorTag = "Api/ProductPerkValidationError" -export interface ProductPerkValidationError { - readonly "_tag": ProductPerkValidationErrorTag; +export interface ApiProductPerkValidationError { + readonly "_tag": ApiProductPerkValidationErrorTag; readonly "message": string } -export type ProductPerksListProductPerksByProductId400 = ProductPerkValidationError | EffectHttpApiSchemaError +export type ProductPerksListProductPerksByProductId400 = ApiProductPerkValidationError | EffectHttpApiSchemaError -export type ProductPerkServiceErrorTag = "ProductPerkServiceError" +export type ApiProductPerkServiceErrorTag = "Api/ProductPerkServiceError" -export interface ProductPerkServiceError { - readonly "_tag": ProductPerkServiceErrorTag; +export interface ApiProductPerkServiceError { + readonly "_tag": ApiProductPerkServiceErrorTag; readonly "cause": string } -export type ProductPerksListProductPerksByProductId500 = ProductPerkServiceError | AuthenticationError | NotAuthenticatedError +export type ProductPerksListProductPerksByProductId500 = ApiProductPerkServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export type SdkGetPersonParamsXIsBackgrounded = "false" @@ -380,30 +431,30 @@ export interface SdkPerson { } } -export type SdkValidationErrorTag = "SdkValidationError" +export type ApiSdkValidationErrorTag = "Api/SdkValidationError" -export interface SdkValidationError { - readonly "_tag": SdkValidationErrorTag; +export interface ApiSdkValidationError { + readonly "_tag": ApiSdkValidationErrorTag; readonly "message": string } -export type SdkGetPerson400 = SdkValidationError | EffectHttpApiSchemaError +export type SdkGetPerson400 = ApiSdkValidationError | EffectHttpApiSchemaError -export type SdkPersonNotFoundErrorTag = "SdkPersonNotFoundError" +export type ApiSdkPersonNotFoundErrorTag = "Api/SdkPersonNotFoundError" -export interface SdkPersonNotFoundError { - readonly "_tag": SdkPersonNotFoundErrorTag; +export interface ApiSdkPersonNotFoundError { + readonly "_tag": ApiSdkPersonNotFoundErrorTag; readonly "message": string } -export type SdkServiceErrorTag = "SdkServiceError" +export type ApiSdkServiceErrorTag = "Api/SdkServiceError" -export interface SdkServiceError { - readonly "_tag": SdkServiceErrorTag; +export interface ApiSdkServiceError { + readonly "_tag": ApiSdkServiceErrorTag; readonly "cause": string } -export type SdkGetPerson500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError +export type SdkGetPerson500 = ApiAuthenticationError | ApiSdkServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export type SdkIdentifyPersonParamsXIsBackgrounded = "false" @@ -444,16 +495,16 @@ export interface SdkIdentifyBody { readonly "traits"?: Record | null | undefined } -export type SdkIdentifyPerson400 = SdkValidationError | EffectHttpApiSchemaError +export type SdkIdentifyPerson400 = ApiSdkValidationError | EffectHttpApiSchemaError -export type SdkPersonAlreadyIdentifiedErrorTag = "SdkPersonAlreadyIdentifiedError" +export type ApiSdkPersonAlreadyIdentifiedErrorTag = "Api/SdkPersonAlreadyIdentifiedError" -export interface SdkPersonAlreadyIdentifiedError { - readonly "_tag": SdkPersonAlreadyIdentifiedErrorTag; +export interface ApiSdkPersonAlreadyIdentifiedError { + readonly "_tag": ApiSdkPersonAlreadyIdentifiedErrorTag; readonly "distinctId": string } -export type SdkIdentifyPerson500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError +export type SdkIdentifyPerson500 = ApiAuthenticationError | ApiSdkServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export type SdkSyncPersonAttributesParamsXIsBackgrounded = "false" @@ -493,9 +544,9 @@ export interface SdkSyncPersonAttributesBody { readonly "traits"?: Record | null | undefined } -export type SdkSyncPersonAttributes400 = SdkValidationError | EffectHttpApiSchemaError +export type SdkSyncPersonAttributes400 = ApiSdkValidationError | EffectHttpApiSchemaError -export type SdkSyncPersonAttributes500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError +export type SdkSyncPersonAttributes500 = ApiAuthenticationError | ApiSdkServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export type SdkSyncTransactionParamsXIsBackgrounded = "false" @@ -537,7 +588,7 @@ export type SdkSyncTransactionRequestQuantityEnum = "-Infinity" export interface SdkSyncTransactionRequest { readonly "platform": SdkSyncTransactionRequestPlatformEnum | SdkSyncTransactionRequestPlatformEnum; - readonly "productId": string; + readonly "productSlug": string; readonly "purchaseDate": number | SdkSyncTransactionRequestPurchaseDateEnum | SdkSyncTransactionRequestPurchaseDateEnum | SdkSyncTransactionRequestPurchaseDateEnum; readonly "purchaseToken"?: string | null | undefined; readonly "quantity": number | SdkSyncTransactionRequestQuantityEnum | SdkSyncTransactionRequestQuantityEnum | SdkSyncTransactionRequestQuantityEnum; @@ -549,9 +600,9 @@ export interface SdkSyncTransactionResponse { readonly "accepted": boolean } -export type SdkSyncTransaction400 = SdkValidationError | EffectHttpApiSchemaError +export type SdkSyncTransaction400 = ApiSdkValidationError | EffectHttpApiSchemaError -export type SdkSyncTransaction500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError +export type SdkSyncTransaction500 = ApiAuthenticationError | ApiSdkServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export type SdkEvaluateFeatureFlagsParamsXIsBackgrounded = "false" @@ -599,7 +650,7 @@ export interface SdkFeatureFlagsResponse { readonly "flags": ReadonlyArray } -export type SdkEvaluateFeatureFlags500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError +export type SdkEvaluateFeatureFlags500 = ApiAuthenticationError | ApiSdkServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export type SdkResolvePaywallParamsXIsBackgrounded = "false" @@ -671,9 +722,50 @@ export interface SdkResolvedPaywall { export type SdkResolvePaywall200 = SdkResolvedPaywall | null -export type SdkResolvePaywall400 = SdkValidationError | EffectHttpApiSchemaError +export type SdkResolvePaywall400 = ApiSdkValidationError | EffectHttpApiSchemaError + +export type SdkResolvePaywall500 = ApiAuthenticationError | ApiSdkServiceError | ApiAuthenticationError | ApiNotAuthenticatedError -export type SdkResolvePaywall500 = AuthenticationError | SdkServiceError | AuthenticationError | NotAuthenticatedError +export type SdkGetSchemaParamsXIsBackgrounded = "false" + +export type SdkGetSchemaParamsXIsDebugBuildEnum = "false" + +export type SdkGetSchemaParamsXObserverModeEnum = "false" + +export type SdkGetSchemaParamsXPlatformFlavorEnum = "browser" + +export type SdkGetSchemaParamsXSdkEnum = "web" + +export interface SdkGetSchemaParams { + readonly "x-distinct-id": string; + readonly "x-publishable-key": string; + readonly "x-client-bundle-id": string; + readonly "x-client-locale"?: string | null | undefined; + readonly "x-client-version"?: string | null | undefined; + readonly "x-is-backgrounded": SdkGetSchemaParamsXIsBackgrounded; + readonly "x-is-debug-build": SdkGetSchemaParamsXIsDebugBuildEnum | SdkGetSchemaParamsXIsDebugBuildEnum; + readonly "x-nonce"?: string | null | undefined; + readonly "x-observer-mode": SdkGetSchemaParamsXObserverModeEnum | SdkGetSchemaParamsXObserverModeEnum; + readonly "x-platform": string; + readonly "x-platform-brand"?: string | null | undefined; + readonly "x-platform-device"?: string | null | undefined; + readonly "x-platform-flavor": SdkGetSchemaParamsXPlatformFlavorEnum | SdkGetSchemaParamsXPlatformFlavorEnum; + readonly "x-platform-flavor-version"?: string | null | undefined; + readonly "x-platform-version"?: string | null | undefined; + readonly "x-preferred-locales"?: string | null | undefined; + readonly "x-sdk": SdkGetSchemaParamsXSdkEnum | SdkGetSchemaParamsXSdkEnum; + readonly "x-sdk-version": string; + readonly "x-storefront"?: string | null | undefined +} + +export interface SdkSchema { + readonly "locations": Record; + readonly "perks": Record; + readonly "products": Record; + readonly "version": string +} + +export type SdkGetSchema500 = ApiAuthenticationError | ApiSchemaServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export interface User { readonly "createdAt": string; @@ -698,14 +790,14 @@ export interface User { readonly "updatedAt": string } -export type UserServiceErrorTag = "UserServiceError" +export type ApiUserServiceErrorTag = "Api/UserServiceError" -export interface UserServiceError { - readonly "_tag": UserServiceErrorTag; +export interface ApiUserServiceError { + readonly "_tag": ApiUserServiceErrorTag; readonly "cause": string } -export type UsersGetUser500 = AuthenticationError | UserServiceError | AuthenticationError | NotAuthenticatedError +export type UsersGetUser500 = ApiAuthenticationError | ApiUserServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export interface PaymentProviderConfiguration { readonly "enabled": boolean; @@ -717,14 +809,14 @@ export interface PaymentProviderConfiguration { export type PaymentProviderConfigurationsListPaymentProviderConfigurations200 = ReadonlyArray -export type PaymentProviderConfigurationServiceErrorTag = "PaymentProviderConfigurationServiceError" +export type ApiPaymentProviderConfigurationServiceErrorTag = "Api/PaymentProviderConfigurationServiceError" -export interface PaymentProviderConfigurationServiceError { - readonly "_tag": PaymentProviderConfigurationServiceErrorTag; +export interface ApiPaymentProviderConfigurationServiceError { + readonly "_tag": ApiPaymentProviderConfigurationServiceErrorTag; readonly "cause": string } -export type PaymentProviderConfigurationsListPaymentProviderConfigurations500 = PaymentProviderConfigurationServiceError | AuthenticationError | NotAuthenticatedError +export type PaymentProviderConfigurationsListPaymentProviderConfigurations500 = ApiPaymentProviderConfigurationServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export interface PaymentProviderProduct { readonly "configuration": Record; @@ -736,132 +828,14 @@ export interface PaymentProviderProduct { export type PaymentProviderProductsListPaymentProviderProducts200 = ReadonlyArray -export type PaymentProviderProductServiceErrorTag = "PaymentProviderProductServiceError" +export type ApiPaymentProviderProductServiceErrorTag = "Api/PaymentProviderProductServiceError" -export interface PaymentProviderProductServiceError { - readonly "_tag": PaymentProviderProductServiceErrorTag; +export interface ApiPaymentProviderProductServiceError { + readonly "_tag": ApiPaymentProviderProductServiceErrorTag; readonly "cause": string } -export type PaymentProviderProductsListPaymentProviderProducts500 = PaymentProviderProductServiceError | AuthenticationError | NotAuthenticatedError - -export interface DeployChangesetBody { - readonly "changeset": { - readonly "changes": ReadonlyArray<{ - readonly "changeType": "create-paywall-location"; - readonly "key": string; - readonly "payload": { - readonly "description"?: string | null | null | undefined; - readonly "name": string; - readonly "slug": string -} -} | { - readonly "changeType": "update-paywall-location"; - readonly "key": string; - readonly "payload": { - readonly "description"?: string | null | null | undefined; - readonly "name": string; - readonly "slug": string -} -} | { - readonly "changeType": "archive-paywall-location"; - readonly "key": string; - readonly "payload": { - readonly "slug": string -} -} | { - readonly "changeType": "create-perk"; - readonly "key": string; - readonly "payload": { - readonly "name": string; - readonly "slug": string -} -} | { - readonly "changeType": "update-perk"; - readonly "key": string; - readonly "payload": { - readonly "name": string; - readonly "slug": string -} -} | { - readonly "changeType": "delete-perk"; - readonly "key": string; - readonly "payload": { - readonly "slug": string -} -} | { - readonly "changeType": "create-product"; - readonly "key": string; - readonly "payload": { - readonly "name": string; - readonly "slug": string -} -} | { - readonly "changeType": "update-product"; - readonly "key": string; - readonly "payload": { - readonly "name": string; - readonly "slug": string -} -} | { - readonly "changeType": "delete-product"; - readonly "key": string; - readonly "payload": { - readonly "slug": string -} -} | { - readonly "changeType": "create-product-perk"; - readonly "key": string; - readonly "payload": { - readonly "perkSlug": string; - readonly "productSlug": string -} -} | { - readonly "changeType": "delete-product-perk"; - readonly "key": string; - readonly "payload": { - readonly "perkSlug": string; - readonly "productSlug": string -} -} | { - readonly "changeType": "create-payment-provider-product"; - readonly "key": string; - readonly "payload": { - readonly "configuration": Record; - readonly "productSlug": string; - readonly "providerId": string -} -} | { - readonly "changeType": "update-payment-provider-product"; - readonly "key": string; - readonly "payload": { - readonly "configuration": Record; - readonly "productSlug": string; - readonly "providerId": string -} -} | { - readonly "changeType": "delete-payment-provider-product"; - readonly "key": string; - readonly "payload": { - readonly "productSlug": string; - readonly "providerId": string -} -}> -} -} - -export interface DeployChangesetResponse { - readonly "deploymentId": string -} - -export type ChangesetDeploymentServiceErrorTag = "ChangesetDeploymentServiceError" - -export interface ChangesetDeploymentServiceError { - readonly "_tag": ChangesetDeploymentServiceErrorTag; - readonly "cause": null -} - -export type ChangesetsDeployChangeset500 = AuthenticationError | ChangesetDeploymentServiceError | AuthenticationError | NotAuthenticatedError +export type PaymentProviderProductsListPaymentProviderProducts500 = ApiPaymentProviderProductServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export type WebhookEndpointConsecutiveFailuresEnum = "-Infinity" @@ -883,14 +857,14 @@ export interface WebhookEndpoint { export type WebhooksListWebhookEndpoints200 = ReadonlyArray -export type WebhookServiceErrorTag = "WebhookServiceError" +export type ApiWebhookServiceErrorTag = "Api/WebhookServiceError" -export interface WebhookServiceError { - readonly "_tag": WebhookServiceErrorTag; +export interface ApiWebhookServiceError { + readonly "_tag": ApiWebhookServiceErrorTag; readonly "cause": string } -export type WebhooksListWebhookEndpoints500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError +export type WebhooksListWebhookEndpoints500 = ApiWebhookServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export interface CreateWebhookEndpointBody { readonly "description"?: string | null | undefined; @@ -899,27 +873,27 @@ export interface CreateWebhookEndpointBody { readonly "url": string } -export type WebhookValidationErrorTag = "WebhookValidationError" +export type ApiWebhookValidationErrorTag = "Api/WebhookValidationError" -export interface WebhookValidationError { - readonly "_tag": WebhookValidationErrorTag; +export interface ApiWebhookValidationError { + readonly "_tag": ApiWebhookValidationErrorTag; readonly "message": string } -export type WebhooksCreateWebhookEndpoint400 = WebhookValidationError | EffectHttpApiSchemaError +export type WebhooksCreateWebhookEndpoint400 = ApiWebhookValidationError | EffectHttpApiSchemaError -export type WebhooksCreateWebhookEndpoint500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError +export type WebhooksCreateWebhookEndpoint500 = ApiWebhookServiceError | ApiAuthenticationError | ApiNotAuthenticatedError -export type WebhookEndpointNotFoundErrorTag = "WebhookEndpointNotFoundError" +export type ApiWebhookEndpointNotFoundErrorTag = "Api/WebhookEndpointNotFoundError" -export interface WebhookEndpointNotFoundError { - readonly "_tag": WebhookEndpointNotFoundErrorTag; +export interface ApiWebhookEndpointNotFoundError { + readonly "_tag": ApiWebhookEndpointNotFoundErrorTag; readonly "endpointId": string } -export type WebhooksGetWebhookEndpoint500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError +export type WebhooksGetWebhookEndpoint500 = ApiWebhookServiceError | ApiAuthenticationError | ApiNotAuthenticatedError -export type WebhooksDeleteWebhookEndpoint500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError +export type WebhooksDeleteWebhookEndpoint500 = ApiWebhookServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export type UpdateWebhookEndpointBodyStatusEnum = "disabled" @@ -931,11 +905,11 @@ export interface UpdateWebhookEndpointBody { readonly "url"?: string | null | undefined } -export type WebhooksUpdateWebhookEndpoint400 = WebhookValidationError | EffectHttpApiSchemaError +export type WebhooksUpdateWebhookEndpoint400 = ApiWebhookValidationError | EffectHttpApiSchemaError -export type WebhooksUpdateWebhookEndpoint500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError +export type WebhooksUpdateWebhookEndpoint500 = ApiWebhookServiceError | ApiAuthenticationError | ApiNotAuthenticatedError -export type WebhooksRotateWebhookSecret500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError +export type WebhooksRotateWebhookSecret500 = ApiWebhookServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export type WebhookDeliveryAttemptCountEnum = "-Infinity" @@ -958,11 +932,11 @@ export interface WebhookDelivery { readonly "webhookEndpointId": string } -export type WebhooksTestWebhookEndpoint500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError +export type WebhooksTestWebhookEndpoint500 = ApiWebhookServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export type WebhooksListWebhookDeliveries200 = ReadonlyArray -export type WebhooksListWebhookDeliveries500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError +export type WebhooksListWebhookDeliveries500 = ApiWebhookServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export type WebhookDeliveryWithAttemptsAttemptCountEnum = "-Infinity" @@ -1003,18 +977,18 @@ export interface WebhookDeliveryWithAttempts { readonly "webhookEndpointId": string } -export type WebhookDeliveryNotFoundErrorTag = "WebhookDeliveryNotFoundError" +export type ApiWebhookDeliveryNotFoundErrorTag = "Api/WebhookDeliveryNotFoundError" -export interface WebhookDeliveryNotFoundError { - readonly "_tag": WebhookDeliveryNotFoundErrorTag; +export interface ApiWebhookDeliveryNotFoundError { + readonly "_tag": ApiWebhookDeliveryNotFoundErrorTag; readonly "deliveryId": string } -export type WebhooksGetWebhookDelivery500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError +export type WebhooksGetWebhookDelivery500 = ApiWebhookServiceError | ApiAuthenticationError | ApiNotAuthenticatedError -export type WebhooksRetryWebhookDelivery400 = WebhookValidationError | EffectHttpApiSchemaError +export type WebhooksRetryWebhookDelivery400 = ApiWebhookValidationError | EffectHttpApiSchemaError -export type WebhooksRetryWebhookDelivery500 = WebhookServiceError | AuthenticationError | NotAuthenticatedError +export type WebhooksRetryWebhookDelivery500 = ApiWebhookServiceError | ApiAuthenticationError | ApiNotAuthenticatedError export const make = ( httpClient: HttpClient.HttpClient, @@ -1089,73 +1063,79 @@ export const make = ( return { httpClient, "authSession": () => HttpClientRequest.get(`/api/v1/auth/session`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"AuthSession500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","500":"AuthSession500"}) ), "apiKeysListApiKeys": () => HttpClientRequest.get(`/api/v1/api-keys`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"ApiKeysListApiKeys500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","500":"ApiKeysListApiKeys500"}) ), "apiKeysCreateSecretKey": (options) => HttpClientRequest.post(`/api/v1/api-keys`).pipe( HttpClientRequest.bodyJsonUnsafe(options), - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"ApiKeysCreateSecretKey500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","500":"ApiKeysCreateSecretKey500"}) ), "apiKeysGetApiKeyById": (apiKeyId) => HttpClientRequest.get(`/api/v1/api-keys/${apiKeyId}`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"ApiKeyNotFoundError","500":"ApiKeysGetApiKeyById500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","404":"ApiApiKeyNotFoundError","500":"ApiKeysGetApiKeyById500"}) ), "apiKeysDeleteApiKey": (apiKeyId) => HttpClientRequest.delete(`/api/v1/api-keys/${apiKeyId}`).pipe( - onRequest([], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"ApiKeyNotFoundError","500":"ApiKeysDeleteApiKey500"}) + onRequest([], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","404":"ApiApiKeyNotFoundError","500":"ApiKeysDeleteApiKey500"}) ), "apiKeysRotateSecretKey": (apiKeyId) => HttpClientRequest.post(`/api/v1/api-keys/${apiKeyId}/rotate`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"ApiKeyNotFoundError","500":"ApiKeysRotateSecretKey500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","404":"ApiApiKeyNotFoundError","500":"ApiKeysRotateSecretKey500"}) ), "personsListPersons": () => HttpClientRequest.get(`/api/v1/persons`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"PersonsListPersons500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","500":"PersonsListPersons500"}) ), "personsCreatePerson": (options) => HttpClientRequest.post(`/api/v1/persons`).pipe( HttpClientRequest.bodyJsonUnsafe(options), - onRequest(["2xx"], {"400":"PersonsCreatePerson400","403":"ActionForbiddenError","500":"PersonsCreatePerson500"}) + onRequest(["2xx"], {"400":"PersonsCreatePerson400","403":"ApiActionForbiddenError","500":"PersonsCreatePerson500"}) ), "personsGetPersonById": (personId) => HttpClientRequest.get(`/api/v1/persons/${personId}`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"PersonNotFoundError","500":"PersonsGetPersonById500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","404":"ApiPersonNotFoundError","500":"PersonsGetPersonById500"}) ), "personsGetPersonByDistinctId": (distinctId) => HttpClientRequest.get(`/api/v1/persons/by-distinct-id/${distinctId}`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"PersonNotFoundError","500":"PersonsGetPersonByDistinctId500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","404":"ApiPersonNotFoundError","500":"PersonsGetPersonByDistinctId500"}) ), "organizationsCreateOrganization": (options) => HttpClientRequest.post(`/api/v1/organizations`).pipe( HttpClientRequest.bodyJsonUnsafe(options), onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","500":"OrganizationsCreateOrganization500"}) ), "perksListPerks": () => HttpClientRequest.get(`/api/v1/perks`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"PerksListPerks500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","500":"PerksListPerks500"}) ), "paywallLocationsListPaywallLocations": () => HttpClientRequest.get(`/api/v1/paywall-locations`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"PaywallLocationsListPaywallLocations500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","500":"PaywallLocationsListPaywallLocations500"}) + ), + "schemaGetSchema": () => HttpClientRequest.get(`/api/v1/schema`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","500":"SchemaGetSchema500"}) + ), + "schemaGetSchemaVersion": () => HttpClientRequest.get(`/api/v1/schema/version`).pipe( + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","500":"SchemaGetSchemaVersion500"}) ), "projectsCreateProject": (options) => HttpClientRequest.post(`/api/v1/projects`).pipe( HttpClientRequest.bodyJsonUnsafe(options), - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"ProjectsCreateProject500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","500":"ProjectsCreateProject500"}) ), "projectsListProjects": (organizationId) => HttpClientRequest.get(`/api/v1/projects/${organizationId}`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"ProjectsListProjects500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","500":"ProjectsListProjects500"}) ), "productsListProducts": () => HttpClientRequest.get(`/api/v1/products`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"ProductsListProducts500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","500":"ProductsListProducts500"}) ), "productPerksListProductPerksByProductId": (productId) => HttpClientRequest.get(`/api/v1/product-perks/by-product-id/${productId}`).pipe( - onRequest(["2xx"], {"400":"ProductPerksListProductPerksByProductId400","403":"ActionForbiddenError","500":"ProductPerksListProductPerksByProductId500"}) + onRequest(["2xx"], {"400":"ProductPerksListProductPerksByProductId400","403":"ApiActionForbiddenError","500":"ProductPerksListProductPerksByProductId500"}) ), "sdkGetPerson": (options) => HttpClientRequest.get(`/api/v1/sdk/person`).pipe( HttpClientRequest.setHeaders({ "x-distinct-id": options?.["x-distinct-id"] ?? undefined, "x-publishable-key": options?.["x-publishable-key"] ?? undefined, "x-client-bundle-id": options?.["x-client-bundle-id"] ?? undefined, "x-client-locale": options?.["x-client-locale"] ?? undefined, "x-client-version": options?.["x-client-version"] ?? undefined, "x-is-backgrounded": options?.["x-is-backgrounded"] ?? undefined, "x-is-debug-build": options?.["x-is-debug-build"] ?? undefined, "x-nonce": options?.["x-nonce"] ?? undefined, "x-observer-mode": options?.["x-observer-mode"] ?? undefined, "x-platform": options?.["x-platform"] ?? undefined, "x-platform-brand": options?.["x-platform-brand"] ?? undefined, "x-platform-device": options?.["x-platform-device"] ?? undefined, "x-platform-flavor": options?.["x-platform-flavor"] ?? undefined, "x-platform-flavor-version": options?.["x-platform-flavor-version"] ?? undefined, "x-platform-version": options?.["x-platform-version"] ?? undefined, "x-preferred-locales": options?.["x-preferred-locales"] ?? undefined, "x-sdk": options?.["x-sdk"] ?? undefined, "x-sdk-version": options?.["x-sdk-version"] ?? undefined, "x-storefront": options?.["x-storefront"] ?? undefined }), - onRequest(["2xx"], {"400":"SdkGetPerson400","404":"SdkPersonNotFoundError","500":"SdkGetPerson500"}) + onRequest(["2xx"], {"400":"SdkGetPerson400","404":"ApiSdkPersonNotFoundError","500":"SdkGetPerson500"}) ), "sdkIdentifyPerson": (options) => HttpClientRequest.post(`/api/v1/sdk/identify`).pipe( HttpClientRequest.setHeaders({ "x-distinct-id": options.params?.["x-distinct-id"] ?? undefined, "x-publishable-key": options.params?.["x-publishable-key"] ?? undefined, "x-client-bundle-id": options.params?.["x-client-bundle-id"] ?? undefined, "x-client-locale": options.params?.["x-client-locale"] ?? undefined, "x-client-version": options.params?.["x-client-version"] ?? undefined, "x-is-backgrounded": options.params?.["x-is-backgrounded"] ?? undefined, "x-is-debug-build": options.params?.["x-is-debug-build"] ?? undefined, "x-nonce": options.params?.["x-nonce"] ?? undefined, "x-observer-mode": options.params?.["x-observer-mode"] ?? undefined, "x-platform": options.params?.["x-platform"] ?? undefined, "x-platform-brand": options.params?.["x-platform-brand"] ?? undefined, "x-platform-device": options.params?.["x-platform-device"] ?? undefined, "x-platform-flavor": options.params?.["x-platform-flavor"] ?? undefined, "x-platform-flavor-version": options.params?.["x-platform-flavor-version"] ?? undefined, "x-platform-version": options.params?.["x-platform-version"] ?? undefined, "x-preferred-locales": options.params?.["x-preferred-locales"] ?? undefined, "x-sdk": options.params?.["x-sdk"] ?? undefined, "x-sdk-version": options.params?.["x-sdk-version"] ?? undefined, "x-storefront": options.params?.["x-storefront"] ?? undefined }), HttpClientRequest.bodyJsonUnsafe(options.payload), - onRequest(["2xx"], {"400":"SdkIdentifyPerson400","409":"SdkPersonAlreadyIdentifiedError","500":"SdkIdentifyPerson500"}) + onRequest(["2xx"], {"400":"SdkIdentifyPerson400","404":"ApiSdkPersonNotFoundError","409":"ApiSdkPersonAlreadyIdentifiedError","500":"SdkIdentifyPerson500"}) ), "sdkSyncPersonAttributes": (options) => HttpClientRequest.post(`/api/v1/sdk/person/traits`).pipe( HttpClientRequest.setHeaders({ "x-distinct-id": options.params?.["x-distinct-id"] ?? undefined, "x-publishable-key": options.params?.["x-publishable-key"] ?? undefined, "x-client-bundle-id": options.params?.["x-client-bundle-id"] ?? undefined, "x-client-locale": options.params?.["x-client-locale"] ?? undefined, "x-client-version": options.params?.["x-client-version"] ?? undefined, "x-is-backgrounded": options.params?.["x-is-backgrounded"] ?? undefined, "x-is-debug-build": options.params?.["x-is-debug-build"] ?? undefined, "x-nonce": options.params?.["x-nonce"] ?? undefined, "x-observer-mode": options.params?.["x-observer-mode"] ?? undefined, "x-platform": options.params?.["x-platform"] ?? undefined, "x-platform-brand": options.params?.["x-platform-brand"] ?? undefined, "x-platform-device": options.params?.["x-platform-device"] ?? undefined, "x-platform-flavor": options.params?.["x-platform-flavor"] ?? undefined, "x-platform-flavor-version": options.params?.["x-platform-flavor-version"] ?? undefined, "x-platform-version": options.params?.["x-platform-version"] ?? undefined, "x-preferred-locales": options.params?.["x-preferred-locales"] ?? undefined, "x-sdk": options.params?.["x-sdk"] ?? undefined, "x-sdk-version": options.params?.["x-sdk-version"] ?? undefined, "x-storefront": options.params?.["x-storefront"] ?? undefined }), HttpClientRequest.bodyJsonUnsafe(options.payload), - onRequest(["2xx"], {"400":"SdkSyncPersonAttributes400","500":"SdkSyncPersonAttributes500"}) + onRequest(["2xx"], {"400":"SdkSyncPersonAttributes400","404":"ApiSdkPersonNotFoundError","500":"SdkSyncPersonAttributes500"}) ), "sdkSyncTransaction": (options) => HttpClientRequest.post(`/api/v1/sdk/sync-transaction`).pipe( HttpClientRequest.setHeaders({ "x-distinct-id": options.params?.["x-distinct-id"] ?? undefined, "x-publishable-key": options.params?.["x-publishable-key"] ?? undefined, "x-client-bundle-id": options.params?.["x-client-bundle-id"] ?? undefined, "x-client-locale": options.params?.["x-client-locale"] ?? undefined, "x-client-version": options.params?.["x-client-version"] ?? undefined, "x-is-backgrounded": options.params?.["x-is-backgrounded"] ?? undefined, "x-is-debug-build": options.params?.["x-is-debug-build"] ?? undefined, "x-nonce": options.params?.["x-nonce"] ?? undefined, "x-observer-mode": options.params?.["x-observer-mode"] ?? undefined, "x-platform": options.params?.["x-platform"] ?? undefined, "x-platform-brand": options.params?.["x-platform-brand"] ?? undefined, "x-platform-device": options.params?.["x-platform-device"] ?? undefined, "x-platform-flavor": options.params?.["x-platform-flavor"] ?? undefined, "x-platform-flavor-version": options.params?.["x-platform-flavor-version"] ?? undefined, "x-platform-version": options.params?.["x-platform-version"] ?? undefined, "x-preferred-locales": options.params?.["x-preferred-locales"] ?? undefined, "x-sdk": options.params?.["x-sdk"] ?? undefined, "x-sdk-version": options.params?.["x-sdk-version"] ?? undefined, "x-storefront": options.params?.["x-storefront"] ?? undefined }), @@ -1172,93 +1152,95 @@ export const make = ( HttpClientRequest.bodyJsonUnsafe(options.payload), onRequest(["2xx"], {"400":"SdkResolvePaywall400","500":"SdkResolvePaywall500"}) ), + "sdkGetSchema": (options) => HttpClientRequest.get(`/api/v1/sdk/schema`).pipe( + HttpClientRequest.setHeaders({ "x-distinct-id": options?.["x-distinct-id"] ?? undefined, "x-publishable-key": options?.["x-publishable-key"] ?? undefined, "x-client-bundle-id": options?.["x-client-bundle-id"] ?? undefined, "x-client-locale": options?.["x-client-locale"] ?? undefined, "x-client-version": options?.["x-client-version"] ?? undefined, "x-is-backgrounded": options?.["x-is-backgrounded"] ?? undefined, "x-is-debug-build": options?.["x-is-debug-build"] ?? undefined, "x-nonce": options?.["x-nonce"] ?? undefined, "x-observer-mode": options?.["x-observer-mode"] ?? undefined, "x-platform": options?.["x-platform"] ?? undefined, "x-platform-brand": options?.["x-platform-brand"] ?? undefined, "x-platform-device": options?.["x-platform-device"] ?? undefined, "x-platform-flavor": options?.["x-platform-flavor"] ?? undefined, "x-platform-flavor-version": options?.["x-platform-flavor-version"] ?? undefined, "x-platform-version": options?.["x-platform-version"] ?? undefined, "x-preferred-locales": options?.["x-preferred-locales"] ?? undefined, "x-sdk": options?.["x-sdk"] ?? undefined, "x-sdk-version": options?.["x-sdk-version"] ?? undefined, "x-storefront": options?.["x-storefront"] ?? undefined }), + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","500":"SdkGetSchema500"}) + ), "usersGetUser": () => HttpClientRequest.get(`/api/v1/users/current`).pipe( onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","500":"UsersGetUser500"}) ), "paymentProviderConfigurationsListPaymentProviderConfigurations": () => HttpClientRequest.get(`/api/v1/payment-provider-configurations`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"PaymentProviderConfigurationsListPaymentProviderConfigurations500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","500":"PaymentProviderConfigurationsListPaymentProviderConfigurations500"}) ), "paymentProviderProductsListPaymentProviderProducts": () => HttpClientRequest.get(`/api/v1/payment-provider-products`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"PaymentProviderProductsListPaymentProviderProducts500"}) - ), - "changesetsDeployChangeset": (options) => HttpClientRequest.post(`/api/v1/changesets/deploy`).pipe( - HttpClientRequest.bodyJsonUnsafe(options), - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"ChangesetsDeployChangeset500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","500":"PaymentProviderProductsListPaymentProviderProducts500"}) ), "webhooksListWebhookEndpoints": () => HttpClientRequest.get(`/api/v1/webhooks/endpoints`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"WebhooksListWebhookEndpoints500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","500":"WebhooksListWebhookEndpoints500"}) ), "webhooksCreateWebhookEndpoint": (options) => HttpClientRequest.post(`/api/v1/webhooks/endpoints`).pipe( HttpClientRequest.bodyJsonUnsafe(options), - onRequest(["2xx"], {"400":"WebhooksCreateWebhookEndpoint400","403":"ActionForbiddenError","500":"WebhooksCreateWebhookEndpoint500"}) + onRequest(["2xx"], {"400":"WebhooksCreateWebhookEndpoint400","403":"ApiActionForbiddenError","500":"WebhooksCreateWebhookEndpoint500"}) ), "webhooksGetWebhookEndpoint": (endpointId) => HttpClientRequest.get(`/api/v1/webhooks/endpoints/${endpointId}`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"WebhookEndpointNotFoundError","500":"WebhooksGetWebhookEndpoint500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","404":"ApiWebhookEndpointNotFoundError","500":"WebhooksGetWebhookEndpoint500"}) ), "webhooksDeleteWebhookEndpoint": (endpointId) => HttpClientRequest.delete(`/api/v1/webhooks/endpoints/${endpointId}`).pipe( - onRequest([], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"WebhookEndpointNotFoundError","500":"WebhooksDeleteWebhookEndpoint500"}) + onRequest([], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","404":"ApiWebhookEndpointNotFoundError","500":"WebhooksDeleteWebhookEndpoint500"}) ), "webhooksUpdateWebhookEndpoint": (endpointId, options) => HttpClientRequest.patch(`/api/v1/webhooks/endpoints/${endpointId}`).pipe( HttpClientRequest.bodyJsonUnsafe(options), - onRequest(["2xx"], {"400":"WebhooksUpdateWebhookEndpoint400","403":"ActionForbiddenError","404":"WebhookEndpointNotFoundError","500":"WebhooksUpdateWebhookEndpoint500"}) + onRequest(["2xx"], {"400":"WebhooksUpdateWebhookEndpoint400","403":"ApiActionForbiddenError","404":"ApiWebhookEndpointNotFoundError","500":"WebhooksUpdateWebhookEndpoint500"}) ), "webhooksRotateWebhookSecret": (endpointId) => HttpClientRequest.post(`/api/v1/webhooks/endpoints/${endpointId}/rotate-secret`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"WebhookEndpointNotFoundError","500":"WebhooksRotateWebhookSecret500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","404":"ApiWebhookEndpointNotFoundError","500":"WebhooksRotateWebhookSecret500"}) ), "webhooksTestWebhookEndpoint": (endpointId) => HttpClientRequest.post(`/api/v1/webhooks/endpoints/${endpointId}/test`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"WebhookEndpointNotFoundError","500":"WebhooksTestWebhookEndpoint500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","404":"ApiWebhookEndpointNotFoundError","500":"WebhooksTestWebhookEndpoint500"}) ), "webhooksListWebhookDeliveries": () => HttpClientRequest.get(`/api/v1/webhooks/deliveries`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","500":"WebhooksListWebhookDeliveries500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","500":"WebhooksListWebhookDeliveries500"}) ), "webhooksGetWebhookDelivery": (deliveryId) => HttpClientRequest.get(`/api/v1/webhooks/deliveries/${deliveryId}`).pipe( - onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ActionForbiddenError","404":"WebhookDeliveryNotFoundError","500":"WebhooksGetWebhookDelivery500"}) + onRequest(["2xx"], {"400":"EffectHttpApiSchemaError","403":"ApiActionForbiddenError","404":"ApiWebhookDeliveryNotFoundError","500":"WebhooksGetWebhookDelivery500"}) ), "webhooksRetryWebhookDelivery": (deliveryId) => HttpClientRequest.post(`/api/v1/webhooks/deliveries/${deliveryId}/retry`).pipe( - onRequest(["2xx"], {"400":"WebhooksRetryWebhookDelivery400","403":"ActionForbiddenError","404":"WebhookDeliveryNotFoundError","500":"WebhooksRetryWebhookDelivery500"}) + onRequest(["2xx"], {"400":"WebhooksRetryWebhookDelivery400","403":"ApiActionForbiddenError","404":"ApiWebhookDeliveryNotFoundError","500":"WebhooksRetryWebhookDelivery500"}) ) } } export interface VoidhashCoreClient { readonly httpClient: HttpClient.HttpClient - readonly "authSession": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"AuthSession500", AuthSession500>> - readonly "apiKeysListApiKeys": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ApiKeysListApiKeys500", ApiKeysListApiKeys500>> - readonly "apiKeysCreateSecretKey": (options: CreateSecretKeyBody) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ApiKeysCreateSecretKey500", ApiKeysCreateSecretKey500>> - readonly "apiKeysGetApiKeyById": (apiKeyId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ApiKeyNotFoundError", ApiKeyNotFoundError> | VoidhashCoreClientError<"ApiKeysGetApiKeyById500", ApiKeysGetApiKeyById500>> - readonly "apiKeysDeleteApiKey": (apiKeyId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ApiKeyNotFoundError", ApiKeyNotFoundError> | VoidhashCoreClientError<"ApiKeysDeleteApiKey500", ApiKeysDeleteApiKey500>> - readonly "apiKeysRotateSecretKey": (apiKeyId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ApiKeyNotFoundError", ApiKeyNotFoundError> | VoidhashCoreClientError<"ApiKeysRotateSecretKey500", ApiKeysRotateSecretKey500>> - readonly "personsListPersons": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PersonsListPersons500", PersonsListPersons500>> - readonly "personsCreatePerson": (options: CreatePersonBody) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PersonsCreatePerson500", PersonsCreatePerson500>> - readonly "personsGetPersonById": (personId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PersonNotFoundError", PersonNotFoundError> | VoidhashCoreClientError<"PersonsGetPersonById500", PersonsGetPersonById500>> - readonly "personsGetPersonByDistinctId": (distinctId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PersonNotFoundError", PersonNotFoundError> | VoidhashCoreClientError<"PersonsGetPersonByDistinctId500", PersonsGetPersonByDistinctId500>> + readonly "authSession": () => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"AuthSession500", AuthSession500>> + readonly "apiKeysListApiKeys": () => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ApiKeysListApiKeys500", ApiKeysListApiKeys500>> + readonly "apiKeysCreateSecretKey": (options: CreateSecretKeyBody) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ApiKeysCreateSecretKey500", ApiKeysCreateSecretKey500>> + readonly "apiKeysGetApiKeyById": (apiKeyId: string) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ApiApiKeyNotFoundError", ApiApiKeyNotFoundError> | VoidhashCoreClientError<"ApiKeysGetApiKeyById500", ApiKeysGetApiKeyById500>> + readonly "apiKeysDeleteApiKey": (apiKeyId: string) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ApiApiKeyNotFoundError", ApiApiKeyNotFoundError> | VoidhashCoreClientError<"ApiKeysDeleteApiKey500", ApiKeysDeleteApiKey500>> + readonly "apiKeysRotateSecretKey": (apiKeyId: string) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ApiApiKeyNotFoundError", ApiApiKeyNotFoundError> | VoidhashCoreClientError<"ApiKeysRotateSecretKey500", ApiKeysRotateSecretKey500>> + readonly "personsListPersons": () => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"PersonsListPersons500", PersonsListPersons500>> + readonly "personsCreatePerson": (options: CreatePersonBody) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"PersonsCreatePerson500", PersonsCreatePerson500>> + readonly "personsGetPersonById": (personId: string) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ApiPersonNotFoundError", ApiPersonNotFoundError> | VoidhashCoreClientError<"PersonsGetPersonById500", PersonsGetPersonById500>> + readonly "personsGetPersonByDistinctId": (distinctId: string) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ApiPersonNotFoundError", ApiPersonNotFoundError> | VoidhashCoreClientError<"PersonsGetPersonByDistinctId500", PersonsGetPersonByDistinctId500>> readonly "organizationsCreateOrganization": (options: CreateOrganizationBody) => Effect.Effect | VoidhashCoreClientError<"OrganizationsCreateOrganization500", OrganizationsCreateOrganization500>> - readonly "perksListPerks": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PerksListPerks500", PerksListPerks500>> - readonly "paywallLocationsListPaywallLocations": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PaywallLocationsListPaywallLocations500", PaywallLocationsListPaywallLocations500>> - readonly "projectsCreateProject": (options: CreateProjectBody) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ProjectsCreateProject500", ProjectsCreateProject500>> - readonly "projectsListProjects": (organizationId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ProjectsListProjects500", ProjectsListProjects500>> - readonly "productsListProducts": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ProductsListProducts500", ProductsListProducts500>> - readonly "productPerksListProductPerksByProductId": (productId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ProductPerksListProductPerksByProductId500", ProductPerksListProductPerksByProductId500>> - readonly "sdkGetPerson": (options: SdkGetPersonParams) => Effect.Effect | VoidhashCoreClientError<"SdkPersonNotFoundError", SdkPersonNotFoundError> | VoidhashCoreClientError<"SdkGetPerson500", SdkGetPerson500>> - readonly "sdkIdentifyPerson": (options: { readonly params: SdkIdentifyPersonParams; readonly payload: SdkIdentifyBody }) => Effect.Effect | VoidhashCoreClientError<"SdkPersonAlreadyIdentifiedError", SdkPersonAlreadyIdentifiedError> | VoidhashCoreClientError<"SdkIdentifyPerson500", SdkIdentifyPerson500>> - readonly "sdkSyncPersonAttributes": (options: { readonly params: SdkSyncPersonAttributesParams; readonly payload: SdkSyncPersonAttributesBody }) => Effect.Effect | VoidhashCoreClientError<"SdkSyncPersonAttributes500", SdkSyncPersonAttributes500>> + readonly "perksListPerks": () => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"PerksListPerks500", PerksListPerks500>> + readonly "paywallLocationsListPaywallLocations": () => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"PaywallLocationsListPaywallLocations500", PaywallLocationsListPaywallLocations500>> + readonly "schemaGetSchema": () => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"SchemaGetSchema500", SchemaGetSchema500>> + readonly "schemaGetSchemaVersion": () => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"SchemaGetSchemaVersion500", SchemaGetSchemaVersion500>> + readonly "projectsCreateProject": (options: CreateProjectBody) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ProjectsCreateProject500", ProjectsCreateProject500>> + readonly "projectsListProjects": (organizationId: string) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ProjectsListProjects500", ProjectsListProjects500>> + readonly "productsListProducts": () => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ProductsListProducts500", ProductsListProducts500>> + readonly "productPerksListProductPerksByProductId": (productId: string) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ProductPerksListProductPerksByProductId500", ProductPerksListProductPerksByProductId500>> + readonly "sdkGetPerson": (options: SdkGetPersonParams) => Effect.Effect | VoidhashCoreClientError<"ApiSdkPersonNotFoundError", ApiSdkPersonNotFoundError> | VoidhashCoreClientError<"SdkGetPerson500", SdkGetPerson500>> + readonly "sdkIdentifyPerson": (options: { readonly params: SdkIdentifyPersonParams; readonly payload: SdkIdentifyBody }) => Effect.Effect | VoidhashCoreClientError<"ApiSdkPersonNotFoundError", ApiSdkPersonNotFoundError> | VoidhashCoreClientError<"ApiSdkPersonAlreadyIdentifiedError", ApiSdkPersonAlreadyIdentifiedError> | VoidhashCoreClientError<"SdkIdentifyPerson500", SdkIdentifyPerson500>> + readonly "sdkSyncPersonAttributes": (options: { readonly params: SdkSyncPersonAttributesParams; readonly payload: SdkSyncPersonAttributesBody }) => Effect.Effect | VoidhashCoreClientError<"ApiSdkPersonNotFoundError", ApiSdkPersonNotFoundError> | VoidhashCoreClientError<"SdkSyncPersonAttributes500", SdkSyncPersonAttributes500>> readonly "sdkSyncTransaction": (options: { readonly params: SdkSyncTransactionParams; readonly payload: SdkSyncTransactionRequest }) => Effect.Effect | VoidhashCoreClientError<"SdkSyncTransaction500", SdkSyncTransaction500>> readonly "sdkEvaluateFeatureFlags": (options: { readonly params: SdkEvaluateFeatureFlagsParams; readonly payload: EvaluateFeatureFlagsBody }) => Effect.Effect | VoidhashCoreClientError<"SdkEvaluateFeatureFlags500", SdkEvaluateFeatureFlags500>> readonly "sdkResolvePaywall": (options: { readonly params: SdkResolvePaywallParams; readonly payload: SdkResolvePaywallBody }) => Effect.Effect | VoidhashCoreClientError<"SdkResolvePaywall500", SdkResolvePaywall500>> + readonly "sdkGetSchema": (options: SdkGetSchemaParams) => Effect.Effect | VoidhashCoreClientError<"SdkGetSchema500", SdkGetSchema500>> readonly "usersGetUser": () => Effect.Effect | VoidhashCoreClientError<"UsersGetUser500", UsersGetUser500>> - readonly "paymentProviderConfigurationsListPaymentProviderConfigurations": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PaymentProviderConfigurationsListPaymentProviderConfigurations500", PaymentProviderConfigurationsListPaymentProviderConfigurations500>> - readonly "paymentProviderProductsListPaymentProviderProducts": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"PaymentProviderProductsListPaymentProviderProducts500", PaymentProviderProductsListPaymentProviderProducts500>> - readonly "changesetsDeployChangeset": (options: DeployChangesetBody) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"ChangesetsDeployChangeset500", ChangesetsDeployChangeset500>> - readonly "webhooksListWebhookEndpoints": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhooksListWebhookEndpoints500", WebhooksListWebhookEndpoints500>> - readonly "webhooksCreateWebhookEndpoint": (options: CreateWebhookEndpointBody) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhooksCreateWebhookEndpoint500", WebhooksCreateWebhookEndpoint500>> - readonly "webhooksGetWebhookEndpoint": (endpointId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhookEndpointNotFoundError", WebhookEndpointNotFoundError> | VoidhashCoreClientError<"WebhooksGetWebhookEndpoint500", WebhooksGetWebhookEndpoint500>> - readonly "webhooksDeleteWebhookEndpoint": (endpointId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhookEndpointNotFoundError", WebhookEndpointNotFoundError> | VoidhashCoreClientError<"WebhooksDeleteWebhookEndpoint500", WebhooksDeleteWebhookEndpoint500>> - readonly "webhooksUpdateWebhookEndpoint": (endpointId: string, options: UpdateWebhookEndpointBody) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhookEndpointNotFoundError", WebhookEndpointNotFoundError> | VoidhashCoreClientError<"WebhooksUpdateWebhookEndpoint500", WebhooksUpdateWebhookEndpoint500>> - readonly "webhooksRotateWebhookSecret": (endpointId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhookEndpointNotFoundError", WebhookEndpointNotFoundError> | VoidhashCoreClientError<"WebhooksRotateWebhookSecret500", WebhooksRotateWebhookSecret500>> - readonly "webhooksTestWebhookEndpoint": (endpointId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhookEndpointNotFoundError", WebhookEndpointNotFoundError> | VoidhashCoreClientError<"WebhooksTestWebhookEndpoint500", WebhooksTestWebhookEndpoint500>> - readonly "webhooksListWebhookDeliveries": () => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhooksListWebhookDeliveries500", WebhooksListWebhookDeliveries500>> - readonly "webhooksGetWebhookDelivery": (deliveryId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhookDeliveryNotFoundError", WebhookDeliveryNotFoundError> | VoidhashCoreClientError<"WebhooksGetWebhookDelivery500", WebhooksGetWebhookDelivery500>> - readonly "webhooksRetryWebhookDelivery": (deliveryId: string) => Effect.Effect | VoidhashCoreClientError<"ActionForbiddenError", ActionForbiddenError> | VoidhashCoreClientError<"WebhookDeliveryNotFoundError", WebhookDeliveryNotFoundError> | VoidhashCoreClientError<"WebhooksRetryWebhookDelivery500", WebhooksRetryWebhookDelivery500>> + readonly "paymentProviderConfigurationsListPaymentProviderConfigurations": () => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"PaymentProviderConfigurationsListPaymentProviderConfigurations500", PaymentProviderConfigurationsListPaymentProviderConfigurations500>> + readonly "paymentProviderProductsListPaymentProviderProducts": () => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"PaymentProviderProductsListPaymentProviderProducts500", PaymentProviderProductsListPaymentProviderProducts500>> + readonly "webhooksListWebhookEndpoints": () => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"WebhooksListWebhookEndpoints500", WebhooksListWebhookEndpoints500>> + readonly "webhooksCreateWebhookEndpoint": (options: CreateWebhookEndpointBody) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"WebhooksCreateWebhookEndpoint500", WebhooksCreateWebhookEndpoint500>> + readonly "webhooksGetWebhookEndpoint": (endpointId: string) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ApiWebhookEndpointNotFoundError", ApiWebhookEndpointNotFoundError> | VoidhashCoreClientError<"WebhooksGetWebhookEndpoint500", WebhooksGetWebhookEndpoint500>> + readonly "webhooksDeleteWebhookEndpoint": (endpointId: string) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ApiWebhookEndpointNotFoundError", ApiWebhookEndpointNotFoundError> | VoidhashCoreClientError<"WebhooksDeleteWebhookEndpoint500", WebhooksDeleteWebhookEndpoint500>> + readonly "webhooksUpdateWebhookEndpoint": (endpointId: string, options: UpdateWebhookEndpointBody) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ApiWebhookEndpointNotFoundError", ApiWebhookEndpointNotFoundError> | VoidhashCoreClientError<"WebhooksUpdateWebhookEndpoint500", WebhooksUpdateWebhookEndpoint500>> + readonly "webhooksRotateWebhookSecret": (endpointId: string) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ApiWebhookEndpointNotFoundError", ApiWebhookEndpointNotFoundError> | VoidhashCoreClientError<"WebhooksRotateWebhookSecret500", WebhooksRotateWebhookSecret500>> + readonly "webhooksTestWebhookEndpoint": (endpointId: string) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ApiWebhookEndpointNotFoundError", ApiWebhookEndpointNotFoundError> | VoidhashCoreClientError<"WebhooksTestWebhookEndpoint500", WebhooksTestWebhookEndpoint500>> + readonly "webhooksListWebhookDeliveries": () => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"WebhooksListWebhookDeliveries500", WebhooksListWebhookDeliveries500>> + readonly "webhooksGetWebhookDelivery": (deliveryId: string) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ApiWebhookDeliveryNotFoundError", ApiWebhookDeliveryNotFoundError> | VoidhashCoreClientError<"WebhooksGetWebhookDelivery500", WebhooksGetWebhookDelivery500>> + readonly "webhooksRetryWebhookDelivery": (deliveryId: string) => Effect.Effect | VoidhashCoreClientError<"ApiActionForbiddenError", ApiActionForbiddenError> | VoidhashCoreClientError<"ApiWebhookDeliveryNotFoundError", ApiWebhookDeliveryNotFoundError> | VoidhashCoreClientError<"WebhooksRetryWebhookDelivery500", WebhooksRetryWebhookDelivery500>> } export interface VoidhashCoreClientError extends Error { From 8d7a7acdbb4bd14b5916fe66db18238acce70652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Mon, 11 May 2026 19:04:20 +0200 Subject: [PATCH 015/129] feat: move to vitest --- libraries/react-native/package.json | 32 +- .../src/__mocks__/api/handlers.ts | 89 -- .../react-native/src/__mocks__/api/node.ts | 3 - libraries/react-native/src/client-effect.ts | 1115 +++-------------- libraries/react-native/src/client.tsx | 18 +- .../src/core/analytics/service.ts | 469 ++++++- .../react-native/src/core/analytics/types.ts | 34 +- .../feature-flags/feature-flag-service.ts | 68 + .../src/core/lifecycle/lifecycle-adapter.ts | 24 + .../src/core/lifecycle/lifecycle-service.ts | 40 + .../react-native-lifecycle-adapter.ts | 54 + .../src/core/paywalls/paywall-service.ts | 37 + .../src/core/products/product-service.ts | 95 ++ .../core/transactions/transaction-service.ts | 223 ++++ .../{src/__tests__ => tests}/client.test.ts | 54 +- .../core/cache-manager.test.ts | 25 +- .../core/client-effect.test.ts | 268 ++-- .../core/customer-info-manager.test.ts | 25 +- .../core/identity-manager.test.ts | 37 +- .../helpers/effect-test-harness.ts | 89 +- .../tests/helpers/effect-vitest.ts | 9 + .../helpers/test-schema.ts | 2 +- .../internal/paywall-bridge-parser.test.ts | 3 +- .../internal/webview-utils.test.ts | 3 +- .../internal/whitelist.test.ts | 3 +- .../react/use-paywall-by-location.test.tsx | 66 +- libraries/react-native/tsconfig.json | 2 +- libraries/react-native/vitest.setup.ts | 53 + libraries/react-native/vitest.unit.mts | 16 + pnpm-lock.yaml | 775 ++++++++++-- 30 files changed, 2313 insertions(+), 1418 deletions(-) delete mode 100644 libraries/react-native/src/__mocks__/api/handlers.ts delete mode 100644 libraries/react-native/src/__mocks__/api/node.ts create mode 100644 libraries/react-native/src/core/feature-flags/feature-flag-service.ts create mode 100644 libraries/react-native/src/core/lifecycle/lifecycle-adapter.ts create mode 100644 libraries/react-native/src/core/lifecycle/lifecycle-service.ts create mode 100644 libraries/react-native/src/core/lifecycle/react-native-lifecycle-adapter.ts create mode 100644 libraries/react-native/src/core/paywalls/paywall-service.ts create mode 100644 libraries/react-native/src/core/products/product-service.ts create mode 100644 libraries/react-native/src/core/transactions/transaction-service.ts rename libraries/react-native/{src/__tests__ => tests}/client.test.ts (78%) rename libraries/react-native/{src/__tests__ => tests}/core/cache-manager.test.ts (79%) rename libraries/react-native/{src/__tests__ => tests}/core/client-effect.test.ts (79%) rename libraries/react-native/{src/__tests__ => tests}/core/customer-info-manager.test.ts (83%) rename libraries/react-native/{src/__tests__ => tests}/core/identity-manager.test.ts (78%) rename libraries/react-native/{src/__tests__ => tests}/helpers/effect-test-harness.ts (68%) create mode 100644 libraries/react-native/tests/helpers/effect-vitest.ts rename libraries/react-native/{src/__tests__ => tests}/helpers/test-schema.ts (94%) rename libraries/react-native/{src/__tests__ => tests}/internal/paywall-bridge-parser.test.ts (90%) rename libraries/react-native/{src/__tests__ => tests}/internal/webview-utils.test.ts (86%) rename libraries/react-native/{src/__tests__ => tests}/internal/whitelist.test.ts (81%) rename libraries/react-native/{src/__tests__ => tests}/react/use-paywall-by-location.test.tsx (86%) create mode 100644 libraries/react-native/vitest.setup.ts create mode 100644 libraries/react-native/vitest.unit.mts diff --git a/libraries/react-native/package.json b/libraries/react-native/package.json index 84b2b6692..22236c511 100644 --- a/libraries/react-native/package.json +++ b/libraries/react-native/package.json @@ -61,7 +61,7 @@ "postinstall": "tsc || exit 0;", "typecheck": "tsgo --noEmit", "build": "expo-module build", - "test": "expo-module test", + "test": "vitest --config vitest.unit.mts run", "clean": "rm -rf android/build node_modules/**/android/build lib", "lint": "biome check .", "lint-ci": "biome check .", @@ -73,23 +73,23 @@ "@voidhash/generated-clients": "workspace:*" }, "devDependencies": { - "@types/jest": "^29.5.14", + "@effect/vitest": "4.0.0-beta.23", + "@srsholmes/vitest-react-native": "^0.1.5", "@types/react": "~19.1.10", - "@vitejs/plugin-react": "^4.6.0", + "@vitejs/plugin-react": "catalog:", "@voidhash/shared": "workspace:*", "expo": "54.0.33", "expo-constants": "~18.0.13", "expo-linking": "~8.0.9", "expo-module-scripts": "^5.0.8", - "jest": "~29.7.0", - "jest-expo": "~54.0.17", - "jest-fixed-jsdom": "^0.0.9", "nitro-codegen": "0.26.4", "react": "19.1.0", "react-native": "0.81.5", "react-native-nitro-modules": "0.26.4", "typescript": "5.9.3", - "ultracite": "5.0.39" + "ultracite": "5.0.39", + "vite-tsconfig-paths": "catalog:", + "vitest": "^4.1.6" }, "peerDependencies": { "@react-native-async-storage/async-storage": "^1.24.0 || ^2.0.0", @@ -107,23 +107,5 @@ "optional": true } }, - "jest": { - "moduleNameMapper": { - "^msgpackr$": "/../../node_modules/msgpackr/dist/node.cjs" - }, - "modulePathIgnorePatterns": [ - "/lib/", - "/build/" - ], - "preset": "expo-module-scripts", - "projects": [], - "setupFiles": [ - "/jest.setup.ts" - ], - "testPathIgnorePatterns": [ - "/src/__tests__/helpers/" - ], - "testEnvironment": "jest-fixed-jsdom" - }, "packageManager": "pnpm@10.19.0" } diff --git a/libraries/react-native/src/__mocks__/api/handlers.ts b/libraries/react-native/src/__mocks__/api/handlers.ts deleted file mode 100644 index 100cba191..000000000 --- a/libraries/react-native/src/__mocks__/api/handlers.ts +++ /dev/null @@ -1,89 +0,0 @@ -// import { HttpResponse, http } from '../../../../node_modules/msw'; - -// export const handlers = [ -// http.post('http://localhost:3000/v1/sdk/get-configuration', () => { -// return HttpResponse.json({ -// paywalls: [ -// { -// paywallId: 'paywall_1', -// paywallProducts: [ -// { -// paywallProductId: 'pw_prod_1', -// productId: 'prod_1', -// displayName: 'Premium Monthly', -// nativePaymentProviderConfigurationProductId: 'native_ppc_prod_1', -// defaultWebCheckoutPaymentProviderConfigurationProductId: -// 'web_ppc_prod_1', -// paymentProviderConfigurationProducts: [ -// // App Store -// { -// paymentProviderConfigurationProductId: 'ppc_prod_1_1', -// paymentProviderConfigurationId: 'ppc_1', -// configuration: {} -// }, -// // Google Play -// { -// paymentProviderConfigurationProductId: 'ppc_prod_1_2', -// paymentProviderConfigurationId: 'ppc_2', -// configuration: {} -// } -// ] -// }, -// { -// paywallProductId: 'pw_prod_2', -// productId: 'prod_2', -// displayName: 'Premium Yearly', -// nativePaymentProviderConfigurationProductId: null, -// defaultWebCheckoutPaymentProviderConfigurationProductId: -// 'web_ppc_prod_2', -// paymentProviderConfigurationProducts: [ -// // App Store -// { -// paymentProviderConfigurationProductId: 'ppc_prod_2_1', -// paymentProviderConfigurationId: 'ppc_1', -// configuration: {} -// }, -// // Google Play -// { -// paymentProviderConfigurationProductId: 'ppc_prod_2_2', -// paymentProviderConfigurationId: 'ppc_2', -// configuration: {} -// } -// ] -// } -// ] -// } -// ], -// paywallLocations: [ -// { -// paywallLocationId: 'location_1', -// slug: 'home' -// }, -// { -// paywallLocationId: 'location_2', -// slug: 'settings' -// } -// ], -// placements: [ -// { -// paywallId: 'paywall_1', -// paywallLocationId: 'location_1' -// }, -// { -// paywallId: 'paywall_1', -// paywallLocationId: 'location_2' -// } -// ], -// paymentProviderConfigurations: [ -// { -// paymentProviderConfigurationId: 'ppc_1', -// providerId: 'app-store' -// }, -// { -// paymentProviderConfigurationId: 'ppc_2', -// providerId: 'google-play' -// } -// ] -// }); -// }) -// ]; diff --git a/libraries/react-native/src/__mocks__/api/node.ts b/libraries/react-native/src/__mocks__/api/node.ts deleted file mode 100644 index 03d997792..000000000 --- a/libraries/react-native/src/__mocks__/api/node.ts +++ /dev/null @@ -1,3 +0,0 @@ -// import { handlers } from './handlers'; - -// export const server = setupServer(...handlers); diff --git a/libraries/react-native/src/client-effect.ts b/libraries/react-native/src/client-effect.ts index 38b1f1efb..b3b3b75d5 100644 --- a/libraries/react-native/src/client-effect.ts +++ b/libraries/react-native/src/client-effect.ts @@ -1,126 +1,29 @@ -import type { - CaptureAcceptedResponse, - CaptureErrorResponse, -} from "@voidhash/generated-clients/event-capture"; -import { Cause, Effect } from "effect"; +import { Effect } from "effect"; -import { SDK_VERSION } from "./core/constants"; -import { CacheManager } from "./core/caching/cache-manager"; -import type { Product, SubscriptionProduct } from "./core/entities/product"; -import type { Transaction } from "./core/entities/transaction"; -import { EventBusProvider } from "./core/event-bus"; +import { AnalyticsService } from "./core/analytics/service"; +import type { AnalyticsIngestEvent } from "./core/analytics/types"; import { CustomerAttributeManager } from "./core/identity/customer-attribute-manager"; import { CustomerInfoManager } from "./core/identity/customer-info-manager"; import { IdentityManager } from "./core/identity/identity-manager"; +import type { SubscriptionProduct } from "./core/entities/product"; +import type { Transaction } from "./core/entities/transaction"; +import { FeatureFlagService } from "./core/feature-flags/feature-flag-service"; +import { LifecycleService } from "./core/lifecycle/lifecycle-service"; import { ApiClient } from "./core/networking/api-client"; import { PaymentAdapter } from "./core/payment-adapters/payment-adapter"; -import type { LocationSlug, ProductSlug } from "./core/schema/registry"; +import { PaywallService } from "./core/paywalls/paywall-service"; +import { ProductService, type ProductsBySlug } from "./core/products/product-service"; +import type { LocationSlug } from "./core/schema/registry"; import { - type RuntimeProductDefinition, type RuntimeSchema, createEmptyRuntimeSchema, } from "./core/schema/runtime"; -import { SdkConfiguration } from "./core/sdk-configuration"; +import { TransactionService } from "./core/transactions/transaction-service"; import { getCommonSdkHeaders } from "./core/utils/get-common-sdk-headers"; import { UnsupportedPlatformError } from "./errors"; -import { - AnalyticsIngestEvent, - AnalyticsSendFailure, - QueuedAnalyticsEvent, -} from "./core/analytics/types"; -import { - createQueuedAnalyticsEvent, - getAnalyticsStandardizedProperties, - mapQueuedAnalyticsEventToIngestEvent, -} from "./core/analytics/utils"; -import { getNonce } from "./core/utils/crypto"; - -const PROCESSED_TRANSACTION_TTL_MS = 1000 * 60 * 30; -const ANALYTICS_BATCH_SIZE = 20; -const ANALYTICS_FLUSH_INTERVAL_MS = 5000; -const MAX_ANALYTICS_RETRY_DELAY_MS = 30_000; -const RETRYABLE_ANALYTICS_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]); - -/** - * The shape of `useProducts()` etc. — keyed by the registered product slugs. - * Values are `null` when the underlying store SDK doesn't know about that - * product (e.g. the product isn't configured on this platform). - */ -export type ProductsBySlug = Record; - -interface AppReleaseInfo { - readonly appBuild: string | null; - readonly appVersion: string | null; -} - -type AppLifecycleState = string; -interface AppLifecycleSubscription { - readonly remove: () => void; -} - -interface ReactNativeAppState { - readonly currentState?: AppLifecycleState; - addEventListener: ( - eventType: "change", - listener: (nextState: AppLifecycleState) => void - ) => AppLifecycleSubscription; -} - -const ANALYTICS_LAST_SEEN_APP_RELEASE_STORAGE_KEY = - "voidhash:analytics:last-seen-app-release"; - -const getReactNativeAppState = (): ReactNativeAppState | null => { - try { - const reactNative = require("react-native") as { - readonly AppState?: ReactNativeAppState; - }; - return reactNative.AppState ?? null; - } catch { - return null; - } -}; - -const toNullableString = (value: unknown): string | null => - value !== null && value !== undefined ? String(value) : null; - -const toAppReleaseInfo = ( - value: AppReleaseInfo | undefined | null -): AppReleaseInfo | null => { - if (!value) return null; - return { - appBuild: value.appBuild, - appVersion: value.appVersion, - }; -}; - -const getAnalyticsRetryDelayMs = (attempts: number) => - Math.min(1000 * 2 ** Math.max(attempts - 1, 0), MAX_ANALYTICS_RETRY_DELAY_MS); - -const parseRetryAfterMs = (value: string | null): number | undefined => { - if (!value) { - return undefined; - } - - const retryAfterSeconds = Number(value); - if (!Number.isNaN(retryAfterSeconds) && retryAfterSeconds >= 0) { - return Math.ceil(retryAfterSeconds * 1000); - } - - const retryAt = Date.parse(value); - if (Number.isNaN(retryAt)) { - return undefined; - } - - return Math.max(retryAt - Date.now(), 0); -}; - -const getRetryAfterMsFromResponseBody = ( - data: CaptureAcceptedResponse | CaptureErrorResponse | undefined -): number | undefined => - data && "retry_after_ms" in data && typeof data.retry_after_ms === "number" - ? data.retry_after_ms - : undefined; +export type { ProductsBySlug }; +export type { AnalyticsIngestEvent }; interface InitOptions { readonly distinctId?: string; @@ -133,6 +36,11 @@ interface InitOptions { readonly internalSchema?: RuntimeSchema; } +/** + * Build the initial state of the SDK before `init()` has run. Returns an + * object whose `init` method performs identity establishment, schema fetching, + * and then yields the fully-initialized client facade. + */ const makeUnitializedClient = () => ({ init: (initOptions: InitOptions = {}) => Effect.gen(function* init() { @@ -189,841 +97,204 @@ const makeUnitializedClient = () => ({ } } - return makeInitializedClient({ schema: runtimeSchema }); + return yield* makeInitializedClient({ schema: runtimeSchema }); }), }); -const makeInitializedClient = (options: { schema: RuntimeSchema }) => { - const inFlightTransactionKeys = new Set(); - - // Analytics state - const analyticsQueue: QueuedAnalyticsEvent[] = []; - const analyticsSessionId = getNonce(); - let analyticsFlushTimer: ReturnType | null = null; - let triggerFlushCallback: (() => void) | null = null; - const getStandardizedProperties = getAnalyticsStandardizedProperties(); - - const clearFlushTimer = () => { - if (analyticsFlushTimer) { - clearTimeout(analyticsFlushTimer); - analyticsFlushTimer = null; - } - }; - - const getNextAnalyticsFlushDelayMs = () => { - if (analyticsQueue.length === 0) { - return null; - } - - const now = Date.now(); - const hasDueEvents = analyticsQueue.some( - (event) => event.availableAt <= now - ); - if (hasDueEvents) { - return ANALYTICS_FLUSH_INTERVAL_MS; - } - - const nextAvailableAt = Math.min( - ...analyticsQueue.map((event) => event.availableAt) - ); - return Math.max(nextAvailableAt - now, 0); - }; - - const scheduleFlushTimer = () => { - if (analyticsFlushTimer || analyticsQueue.length === 0) return; - const delayMs = getNextAnalyticsFlushDelayMs(); - if (delayMs === null) { - return; - } - - analyticsFlushTimer = setTimeout(() => { - analyticsFlushTimer = null; - triggerFlushCallback?.(); - }, delayMs); - }; - - const sendAnalyticsEventsImpl = ( - events: ReadonlyArray - ) => - Effect.gen(function* () { - if (events.length === 0) return; - - const identityManager = yield* IdentityManager; - const sdkConfiguration = yield* SdkConfiguration; - const distinctId = yield* identityManager.getDistinctId(); - const ingestEventsUrl = resolveIngestEventsUrl({ - baseUrl: sdkConfiguration.baseUrl, - ingestUrl: sdkConfiguration.ingestUrl, - }); - - const response = yield* Effect.tryPromise({ - try: () => - fetch(ingestEventsUrl, { - body: JSON.stringify({ - events: events.map((event) => ({ - context: event.context, - distinct_id: distinctId, - event: event.event_name, - properties: event.properties, - request: { - sdk_name: "react-native", - sdk_version: SDK_VERSION, - }, - session_id: event.session_id, - timestamp: event.event_ts, - uuid: event.event_id, - })), - sent_at: new Date().toISOString(), - token: sdkConfiguration.publishableKey, - }), - headers: { - "content-type": "application/json", - }, - method: "POST", - }), - catch: (cause) => - new AnalyticsSendFailure({ - cause, - message: "Analytics request failed", - retryable: true, - }), - }); - - const data = (yield* Effect.tryPromise({ - try: () => - response.json() as Promise< - CaptureAcceptedResponse | CaptureErrorResponse - >, - catch: (cause) => cause, - }).pipe(Effect.orElseSucceed(() => undefined))) as - | CaptureAcceptedResponse - | CaptureErrorResponse - | undefined; - - if (response.status === 202) { - return; - } - - if (response.status === 413) { - return yield* Effect.fail( - new AnalyticsSendFailure({ - message: `Analytics ingest request failed: ${response.status} ${response.statusText}`, - retryable: false, - status: response.status, - }) - ); - } - - if (RETRYABLE_ANALYTICS_STATUS_CODES.has(response.status)) { - return yield* Effect.fail( - new AnalyticsSendFailure({ - message: `Analytics ingest request failed: ${response.status} ${response.statusText}`, - retryAfterMs: - parseRetryAfterMs(response.headers.get("retry-after")) ?? - getRetryAfterMsFromResponseBody(data), - retryable: true, - status: response.status, - }) - ); - } - - if (!response.ok) { - return yield* Effect.fail( - new AnalyticsSendFailure({ - message: `Analytics ingest request failed: ${response.status} ${response.statusText}`, - retryable: false, - status: response.status, - }) - ); - } - }); - - const buildQueuedAnalyticsBatchIds = ( - events: ReadonlyArray - ) => new Set(events.map((event) => event.id)); - - const dropQueuedAnalyticsBatch = ( - events: ReadonlyArray - ) => { - const ids = buildQueuedAnalyticsBatchIds(events); - for (let index = analyticsQueue.length - 1; index >= 0; index -= 1) { - if (ids.has(analyticsQueue[index]!.id)) { - analyticsQueue.splice(index, 1); - } - } - }; - - const postponeQueuedAnalyticsBatch = ( - events: ReadonlyArray, - nextAvailableAt: number - ) => { - const ids = buildQueuedAnalyticsBatchIds(events); - for (let index = 0; index < analyticsQueue.length; index += 1) { - const queuedEvent = analyticsQueue[index]!; - if (!ids.has(queuedEvent.id)) { - continue; - } - - analyticsQueue[index] = { - ...queuedEvent, - attempts: queuedEvent.attempts + 1, - availableAt: nextAvailableAt, - }; - } - }; - - const getDueQueuedAnalyticsBatch = () => { - const now = Date.now(); - const queuedBatch: QueuedAnalyticsEvent[] = []; - - for (const event of analyticsQueue) { - if (event.availableAt > now) { - break; - } - - queuedBatch.push(event); - if (queuedBatch.length >= ANALYTICS_BATCH_SIZE) { - break; - } - } - - return queuedBatch; - }; - - const processQueuedAnalyticsBatch = ( - queuedBatch: ReadonlyArray, - standardizedProperties: Record - ): Effect.Effect< - void, - AnalyticsSendFailure, - IdentityManager | SdkConfiguration - > => - Effect.gen(function* () { - const ingestBatch = queuedBatch.map((event) => - mapQueuedAnalyticsEventToIngestEvent( - event, - standardizedProperties, - analyticsSessionId - ) - ); - - const sendResult = yield* Effect.exit(sendAnalyticsEventsImpl(ingestBatch)); - if (sendResult._tag === "Success") { - dropQueuedAnalyticsBatch(queuedBatch); - return; - } - - const failure = Cause.squash(sendResult.cause); - if (!(failure instanceof AnalyticsSendFailure)) { - return yield* Effect.fail( - new AnalyticsSendFailure({ - cause: failure, - message: failure instanceof Error ? failure.message : String(failure), - retryable: false, - }) - ); - } - - if (failure.status === 413 && queuedBatch.length > 1) { - const midpoint = Math.ceil(queuedBatch.length / 2); - yield* processQueuedAnalyticsBatch( - queuedBatch.slice(0, midpoint), - standardizedProperties - ); - yield* processQueuedAnalyticsBatch( - queuedBatch.slice(midpoint), - standardizedProperties - ); - return; - } - - if (failure.status === 413 && queuedBatch.length === 1) { - dropQueuedAnalyticsBatch(queuedBatch); - yield* Effect.logWarning("Dropping analytics event after 413 response", { - eventId: queuedBatch[0]?.id, - }); - return; - } - - if (!failure.retryable) { - dropQueuedAnalyticsBatch(queuedBatch); - yield* Effect.logWarning( - "Dropping analytics batch after non-retryable response", - { - eventIds: queuedBatch.map((event) => event.id), - status: failure.status, - } - ); - return; - } - - return yield* Effect.fail(failure); - }); - - const processObservedTransaction = (transaction: Transaction) => - Effect.gen(function* processObservedTransaction() { - const transactionProcessingKey = buildTransactionProcessingKey(transaction); - if (inFlightTransactionKeys.has(transactionProcessingKey)) { - return; - } - - const cacheManager = yield* CacheManager; - const processedTransactionCacheKey = - getProcessedTransactionCacheKey(transactionProcessingKey); - const cachedTransaction = yield* cacheManager.get( - processedTransactionCacheKey - ); - if ( - cachedTransaction && - !cachedTransaction.isExpired && - cachedTransaction.value - ) { - return; - } - - inFlightTransactionKeys.add(transactionProcessingKey); - try { - const apiClient = yield* ApiClient; - const identityManager = yield* IdentityManager; - const paymentAdapter = yield* PaymentAdapter; - const sdkConfiguration = yield* SdkConfiguration; +/** + * Build the initialized SDK facade. Yields long-lived services from the + * runtime (most notably `AnalyticsService`, so its mutable queue/timer can be + * exposed via the synchronous accessors below) and returns an object that + * delegates every method to the appropriate service. + */ +const makeInitializedClient = (options: { schema: RuntimeSchema }) => + Effect.gen(function* () { + const analyticsService = yield* AnalyticsService; - const commonHeaders = yield* getCommonSdkHeaders(); - const distinctId = yield* identityManager.getDistinctId(); - if (transaction.platform === "android" && !transaction.purchaseToken) { - yield* Effect.logWarning( - "Skipping observed Android transaction without purchase token", - { - transactionId: transaction.transactionId, - } + return { + end: () => + Effect.gen(function* end() { + const transactionService = yield* TransactionService; + return yield* transactionService.endConnection(); + }), + + getFeatureFlags: (flagKeys?: string[]) => + Effect.gen(function* getFeatureFlags() { + const featureFlagService = yield* FeatureFlagService; + return yield* featureFlagService.getFeatureFlags(flagKeys); + }), + + getPaywallForLocation: (locationSlug: LocationSlug) => + Effect.gen(function* getPaywallForLocation() { + const paywallService = yield* PaywallService; + return yield* paywallService.getPaywallForLocation(locationSlug); + }), + + getCurrentCustomer: (forceFetch = false) => + Effect.gen(function* getCurrentCustomer() { + const identityManager = yield* IdentityManager; + const customerInfoManager = yield* CustomerInfoManager; + const distinctId = yield* identityManager.getDistinctId(); + return yield* customerInfoManager.getCustomer( + distinctId, + forceFetch ? "fetch" : "fetch-while-stale" ); - return; + }), + + getDistinctId: () => + Effect.gen(function* getDistinctId() { + const identityManager = yield* IdentityManager; + return yield* identityManager.getDistinctId(); + }), + + getProducts: () => + Effect.gen(function* getProducts() { + const productService = yield* ProductService; + return yield* productService.getProducts(options.schema); + }), + + /** Read access to the schema fetched at init time. */ + getSchema: () => options.schema, + + identify: ( + distinctId: string, + identifyOptions: { + email?: string; + name?: string; } + ) => + Effect.gen(function* identify() { + const identityManager = yield* IdentityManager; + return yield* identityManager.identify(distinctId, identifyOptions); + }), + + iosPresentCodeRedemptionSheet: () => + Effect.gen(function* iosPresentCodeRedemptionSheet() { + const paymentAdapter = yield* PaymentAdapter; + const { presentCodeRedemptionSheet } = paymentAdapter; + if (!presentCodeRedemptionSheet) { + return yield* Effect.fail( + new UnsupportedPlatformError( + "Present code redemption sheet is not supported on this platform" + ) + ); + } + return yield* presentCodeRedemptionSheet(); + }), + + iosShowManageSubscriptions: () => + Effect.gen(function* iosShowManageSubscriptions() { + const paymentAdapter = yield* PaymentAdapter; + const { showManageSubscriptions } = paymentAdapter; + if (!showManageSubscriptions) { + return yield* Effect.fail( + new UnsupportedPlatformError( + "Show manage subscriptions is not supported on this platform" + ) + ); + } + return yield* showManageSubscriptions(); + }), - yield* apiClient.sdk.syncTransaction({ - headers: { - ...commonHeaders, - "x-distinct-id": distinctId, - }, - payload: mapTransactionToSyncPayload( + processObservedTransaction: (transaction: Transaction) => + Effect.gen(function* processObservedTransaction() { + const transactionService = yield* TransactionService; + return yield* transactionService.processObservedTransaction( transaction, - options.schema.products - ), - }); - - yield* cacheManager.set(processedTransactionCacheKey, true, { - ttl: PROCESSED_TRANSACTION_TTL_MS, - }); - - if (!sdkConfiguration.readOnly) { - yield* paymentAdapter.acknowledgePurchase(transaction); - } - } finally { - inFlightTransactionKeys.delete(transactionProcessingKey); - } - }); - - const reconcileObservedTransactions = () => - Effect.gen(function* reconcileObservedTransactions() { - const paymentAdapter = yield* PaymentAdapter; - const [pendingTransactions, purchasedTransactions] = yield* Effect.all([ - paymentAdapter.getPendingTransactions(), - paymentAdapter.getPurchaseHistory(true), - ]); - - const observedTransactionsByKey = new Map(); - for (const transaction of [ - ...pendingTransactions, - ...purchasedTransactions, - ]) { - observedTransactionsByKey.set( - buildTransactionProcessingKey(transaction), - transaction - ); - } - - for (const transaction of observedTransactionsByKey.values()) { - yield* processObservedTransaction(transaction).pipe( - Effect.catch((error) => - Effect.logWarning("Failed to process observed transaction", { - error, - transactionId: transaction.transactionId, - }) - ) - ); - } - }); - - return { - end: () => - Effect.gen(function* end() { - const paymentAdapter = yield* PaymentAdapter; - return yield* paymentAdapter.endConnection(); - }), - - getFeatureFlags: (flagKeys?: string[]) => - Effect.gen(function* getFeatureFlags() { - const cacheManager = yield* CacheManager; - const apiClient = yield* ApiClient; - const eventBus = yield* EventBusProvider; - const identityManager = yield* IdentityManager; - - const cacheKey = `feature-flags:${flagKeys?.sort().join(",") ?? "all"}`; - const cached = yield* cacheManager.get<{ - readonly flags: ReadonlyArray<{ - readonly enabled: boolean; - readonly key: string; - readonly payload: unknown | null; - readonly variantKey: string | null; - }>; - }>(cacheKey); - - if (cached && !cached.isExpired && !cached.isStale) { - return cached.value; - } - - const commonHeaders = yield* getCommonSdkHeaders(); - const distinctId = yield* identityManager.getDistinctId(); - const result = yield* apiClient.sdk.evaluateFeatureFlags({ - headers: { - ...commonHeaders, - "x-distinct-id": distinctId, - }, - payload: { flagKeys }, - }); - - yield* cacheManager.set(cacheKey, result, { - ttl: 1000 * 60 * 5, // 5 minutes - }); - - eventBus.emit("feature-flags-fetched", result); - - return result; - }), - - getPaywallForLocation: (locationSlug: LocationSlug) => - Effect.gen(function* getPaywallForLocation() { - const apiClient = yield* ApiClient; - const identityManager = yield* IdentityManager; - - const commonHeaders = yield* getCommonSdkHeaders(); - const distinctId = yield* identityManager.getDistinctId(); - - return yield* apiClient.sdk.resolvePaywall({ - headers: { - ...commonHeaders, - "x-distinct-id": distinctId, - }, - payload: { locationSlug: String(locationSlug) }, - }); - }), - - getCurrentCustomer: (forceFetch = false) => - Effect.gen(function* getCurrentCustomer() { - const identityManager = yield* IdentityManager; - const customerInfoManager = yield* CustomerInfoManager; - - const distinctId = yield* identityManager.getDistinctId(); - const customer = yield* customerInfoManager.getCustomer( - distinctId, - forceFetch ? "fetch" : "fetch-while-stale" - ); - - return customer; - }), - - getDistinctId: () => - Effect.gen(function* getDistinctId() { - const identityManager = yield* IdentityManager; - return yield* identityManager.getDistinctId(); - }), - - getProducts: () => - Effect.gen(function* getProducts() { - const productDefinitions = options.schema.products; - const nativeProducts = yield* loadProductsCached(productDefinitions); - return mapNativeProductsToProductMap(productDefinitions, nativeProducts); - }), - - /** Read access to the schema fetched at init time. */ - getSchema: () => options.schema, - - identify: ( - distinctId: string, - options: { - email?: string; - name?: string; - } - ) => - Effect.gen(function* identify() { - const identityManager = yield* IdentityManager; - return yield* identityManager.identify(distinctId, options); - }), - - iosPresentCodeRedemptionSheet: () => - Effect.gen(function* iosPresentCodeRedemptionSheet() { - const paymentAdapter = yield* PaymentAdapter; - const { presentCodeRedemptionSheet } = paymentAdapter; - if (!presentCodeRedemptionSheet) { - return yield* Effect.fail( - new UnsupportedPlatformError( - "Present code redemption sheet is not supported on this platform" - ) - ); - } - return yield* presentCodeRedemptionSheet(); - }), - - iosShowManageSubscriptions: () => - Effect.gen(function* iosShowManageSubscriptions() { - const paymentAdapter = yield* PaymentAdapter; - const { showManageSubscriptions } = paymentAdapter; - if (!showManageSubscriptions) { - return yield* Effect.fail( - new UnsupportedPlatformError( - "Show manage subscriptions is not supported on this platform" - ) + options.schema ); - } - return yield* showManageSubscriptions(); - }), + }), - processObservedTransaction, - - purchase: ( - product: SubscriptionProduct, - _options: { - method?: "native"; - } - ) => - Effect.gen(function* purchase() { - const paymentAdapter = yield* PaymentAdapter; - const transaction = yield* paymentAdapter.buyProduct(product); - yield* processObservedTransaction(transaction); - }), - - restorePurchases: () => - Effect.gen(function* restorePurchases() { - const customerInfoManager = yield* CustomerInfoManager; - const identityManager = yield* IdentityManager; - - yield* reconcileObservedTransactions(); - - const distinctId = yield* identityManager.getDistinctId(); - yield* customerInfoManager.getCustomer(distinctId, "fetch"); - }), - - reconcileObservedTransactions: () => reconcileObservedTransactions(), - - getAnalyticsStandardizedProperties: () => getStandardizedProperties(), - - capture: (eventName: string, properties: Record = {}) => - Effect.sync(() => { - const normalized = eventName.trim(); - if (!normalized) return; - analyticsQueue.push(createQueuedAnalyticsEvent(normalized, properties)); - if (analyticsQueue.length >= ANALYTICS_BATCH_SIZE) { - clearFlushTimer(); - triggerFlushCallback?.(); - return; + purchase: ( + product: SubscriptionProduct, + _options: { + method?: "native"; } - scheduleFlushTimer(); - }), - - flush: () => - Effect.gen(function* () { - clearFlushTimer(); - if (analyticsQueue.length === 0) return; - - const standardizedProperties = yield* getStandardizedProperties(); - - while (analyticsQueue.length > 0) { - const queuedBatch = getDueQueuedAnalyticsBatch(); - if (queuedBatch.length === 0) { - scheduleFlushTimer(); - return; - } - - const sendResult = yield* Effect.exit( - processQueuedAnalyticsBatch(queuedBatch, standardizedProperties) + ) => + Effect.gen(function* purchase() { + const transactionService = yield* TransactionService; + yield* transactionService.purchase(product, options.schema); + }), + + restorePurchases: () => + Effect.gen(function* restorePurchases() { + const transactionService = yield* TransactionService; + yield* transactionService.restorePurchases(options.schema); + }), + + reconcileObservedTransactions: () => + Effect.gen(function* reconcileObservedTransactions() { + const transactionService = yield* TransactionService; + return yield* transactionService.reconcileObservedTransactions( + options.schema ); - if (sendResult._tag === "Failure") { - const failure = Cause.squash(sendResult.cause); - if (failure instanceof AnalyticsSendFailure && failure.retryable) { - postponeQueuedAnalyticsBatch( - queuedBatch, - Date.now() + - (failure.retryAfterMs ?? - getAnalyticsRetryDelayMs( - (queuedBatch[0]?.attempts ?? 0) + 1 - )) - ); - scheduleFlushTimer(); - return; - } + }), - yield* Effect.failCause(sendResult.cause); - } - } - }), + // --- Analytics: Effect methods delegate to AnalyticsService --- - getAnalyticsQueueLength: () => analyticsQueue.length, + getAnalyticsStandardizedProperties: () => + analyticsService.getStandardizedProperties(), - setAnalyticsFlushCallback: (callback: () => void) => { - triggerFlushCallback = callback; - }, + capture: (eventName: string, properties: Record = {}) => + analyticsService.capture(eventName, properties), - stopAnalyticsFlushTimer: () => - Effect.sync(() => { - clearFlushTimer(); - }), + flush: () => analyticsService.flush(), - transferAnalyticsEvents: ( - events: ReadonlyArray<{ - eventName: string; - properties: Record; - }> - ) => - Effect.sync(() => { - for (const event of events) { - const normalized = event.eventName.trim(); - if (!normalized) continue; - analyticsQueue.push( - createQueuedAnalyticsEvent(normalized, event.properties) - ); - } - }), + transferAnalyticsEvents: ( + events: ReadonlyArray<{ + eventName: string; + properties: Record; + }> + ) => analyticsService.transferEvents(events), - captureAutomaticStartupEvents: () => - Effect.gen(function* () { - try { - const standardizedProps = yield* getStandardizedProperties(); - const currentAppRelease: AppReleaseInfo = { - appBuild: toNullableString(standardizedProps.$app_build), - appVersion: toNullableString(standardizedProps.$app_version), - }; + captureAutomaticStartupEvents: () => + analyticsService.captureAutomaticStartupEvents(), - const cacheManager = yield* CacheManager; - const cachedRelease = yield* cacheManager.get( - ANALYTICS_LAST_SEEN_APP_RELEASE_STORAGE_KEY - ); - const previousAppRelease = toAppReleaseInfo(cachedRelease?.value); + sendAnalyticsEvents: (events: ReadonlyArray) => + analyticsService.sendAnalyticsEvents(events), - if (!previousAppRelease) { - analyticsQueue.push(createQueuedAnalyticsEvent("app_installed", {})); - } else if ( - previousAppRelease.appBuild !== currentAppRelease.appBuild || - previousAppRelease.appVersion !== currentAppRelease.appVersion - ) { - analyticsQueue.push(createQueuedAnalyticsEvent("app_updated", {})); - } - - analyticsQueue.push(createQueuedAnalyticsEvent("app_opened", {})); - - yield* cacheManager.set( - ANALYTICS_LAST_SEEN_APP_RELEASE_STORAGE_KEY, - currentAppRelease + setupAutomaticLifecycleEvents: ( + captureEvent: (eventName: string) => void + ) => + Effect.gen(function* setupAutomaticLifecycleEvents() { + const lifecycleService = yield* LifecycleService; + return yield* lifecycleService.setupAutomaticLifecycleEvents( + captureEvent ); - } catch { - analyticsQueue.push(createQueuedAnalyticsEvent("app_opened", {})); - } - }), - - setupAutomaticLifecycleEvents: (captureEvent: (eventName: string) => void) => - Effect.sync(() => { - const appState = getReactNativeAppState(); - if (!appState || typeof appState.addEventListener !== "function") { - return null; - } - - let lifecycleState: AppLifecycleState | null = - appState.currentState ?? null; - - const subscription = appState.addEventListener("change", (nextAppState) => { - const previousAppState = lifecycleState; - lifecycleState = nextAppState; - - if (nextAppState === "background" && previousAppState !== "background") { - captureEvent("app_backgrounded"); - return; - } - - if ( - nextAppState === "active" && - previousAppState !== null && - previousAppState !== "active" - ) { - captureEvent("app_became_active"); - } - }); - - return subscription; - }), - - sendAnalyticsEvents: (events: ReadonlyArray) => - sendAnalyticsEventsImpl(events), - - reset: () => - Effect.gen(function* reset() { - const identityManager = yield* IdentityManager; - return yield* identityManager.reset(); - }), - - signOut: () => - Effect.gen(function* signOut() { - const identityManager = yield* IdentityManager; - return yield* identityManager.reset(); - }), - - startTransactionObserver: ( - onPurchase?: (transaction: Transaction) => void - ) => - Effect.gen(function* startTransactionObserver() { - const paymentAdapter = yield* PaymentAdapter; - return yield* paymentAdapter.initConnection(onPurchase); - }), - }; -}; - -const loadProductsCached = ( - productDefinitions: Readonly> -) => - Effect.gen(function* loadProductsCached() { - const cacheManager = yield* CacheManager; - const paymentAdapter = yield* PaymentAdapter; - - const cacheKey = generateCacheKeyFromProductDefinitions(productDefinitions); - - const cachedProducts = yield* cacheManager.get(cacheKey); - - if ( - cachedProducts && - !(cachedProducts.isStale || cachedProducts.isExpired) - ) { - yield* Effect.logDebug("Products fetched from cache", { - products: cachedProducts.value, - }); - return cachedProducts.value; - } - - const nativeProducts = yield* paymentAdapter.getProducts(productDefinitions); - - yield* Effect.logDebug("Products fetched from native adapter", { - products: nativeProducts, - }); - - // Store products in cache - yield* cacheManager.set(cacheKey, nativeProducts, { - ttl: 1000 * 60 * 60 * 24, // 24 hours - }); - - return nativeProducts; + }), + + // --- Analytics: sync accessors read directly from the service --- + + getAnalyticsQueueLength: () => analyticsService.getQueueLength(), + + setAnalyticsFlushCallback: (callback: () => void) => { + analyticsService.setFlushCallback(callback); + }, + + // --- Identity --- + + reset: () => + Effect.gen(function* reset() { + const identityManager = yield* IdentityManager; + return yield* identityManager.reset(); + }), + + signOut: () => + Effect.gen(function* signOut() { + const identityManager = yield* IdentityManager; + return yield* identityManager.reset(); + }), + + startTransactionObserver: ( + onPurchase?: (transaction: Transaction) => void + ) => + Effect.gen(function* startTransactionObserver() { + const transactionService = yield* TransactionService; + return yield* transactionService.startTransactionObserver(onPurchase); + }), + } as const; }); -const mapNativeProductsToProductMap = ( - productDefinitions: Readonly>, - nativeProducts: Product[] -): ProductsBySlug => { - const productMap = {} as Record; - - for (const slug of Object.keys(productDefinitions)) { - const nativeProduct = nativeProducts.find( - (nativeProduct) => nativeProduct.slug === slug - ); - - if (nativeProduct) { - productMap[slug] = nativeProduct as SubscriptionProduct; - continue; - } - - productMap[slug] = null; - } - - return productMap as ProductsBySlug; -}; - -const buildTransactionProcessingKey = (transaction: Transaction) => - `${transaction.platform}:${transaction.transactionId}:${transaction.purchaseDate}`; - -const getProcessedTransactionCacheKey = (transactionProcessingKey: string) => - `processed-transaction:${transactionProcessingKey}`; - -const resolveTransactionProductSlug = ( - transaction: Transaction, - productDefinitions: Readonly> -) => { - const matchedProduct = Object.values(productDefinitions).find( - (productDefinition) => { - if (productDefinition.slug === transaction.productId) { - return true; - } - - const provider = - transaction.platform === "ios" - ? productDefinition.configuration.providers.appleAppStore - : productDefinition.configuration.providers.googlePlay; - - return provider?.productId === transaction.productId; - } - ); - - return matchedProduct?.slug ?? transaction.productId; -}; - -const mapTransactionToSyncPayload = ( - transaction: Transaction, - productDefinitions: Readonly> -) => { - const productSlug = resolveTransactionProductSlug( - transaction, - productDefinitions - ); - - if (transaction.platform === "ios") { - return { - platform: "ios" as const, - productSlug, - purchaseDate: transaction.purchaseDate, - quantity: transaction.quantity, - receipt: transaction.receipt, - transactionId: transaction.transactionId, - }; - } - - return { - platform: "android" as const, - productSlug, - purchaseDate: transaction.purchaseDate, - purchaseToken: transaction.purchaseToken ?? "", - quantity: transaction.quantity, - receipt: transaction.receipt, - transactionId: transaction.transactionId, - }; -}; - -const generateCacheKeyFromProductDefinitions = ( - productDefinitions: Readonly> -) => `native-products:${JSON.stringify(productDefinitions)}`; - -const resolveIngestEventsUrl = (options: { - baseUrl: string; - ingestUrl: string | undefined; -}) => { - const baseUrl = options.ingestUrl - ? new URL(options.ingestUrl) - : buildDefaultIngestBaseUrl(options.baseUrl); - return new URL("/batch", baseUrl).toString(); -}; - -const buildDefaultIngestBaseUrl = (apiBaseUrl: string) => { - const parsedApiUrl = new URL(apiBaseUrl); - parsedApiUrl.hostname = `i.${parsedApiUrl.hostname}`; - parsedApiUrl.hash = ""; - parsedApiUrl.pathname = "/"; - parsedApiUrl.search = ""; - return parsedApiUrl; -}; - export const VoidhashEffectClient = { makeInitializedClient, makeUnitializedClient, diff --git a/libraries/react-native/src/client.tsx b/libraries/react-native/src/client.tsx index c6c5259d0..97c3c0ada 100644 --- a/libraries/react-native/src/client.tsx +++ b/libraries/react-native/src/client.tsx @@ -11,8 +11,14 @@ import { IdentityManager } from "./core/identity/identity-manager"; import { ApiClient } from "./core/networking/api-client"; import { AppStoreAdapter } from "./core/payment-adapters/app-store-adapter"; import { GooglePlayAdapter } from "./core/payment-adapters/google-play-adapter"; +import { FeatureFlagService } from "./core/feature-flags/feature-flag-service"; +import { LifecycleService } from "./core/lifecycle/lifecycle-service"; +import { ReactNativeLifecycleAdapter } from "./core/lifecycle/react-native-lifecycle-adapter"; +import { PaywallService } from "./core/paywalls/paywall-service"; import { type PlatformInfo } from "./core/platform/platform-provider"; import { ReactNativePlatformProvider } from "./core/platform/react-native-platform-provider"; +import { ProductService } from "./core/products/product-service"; +import { TransactionService } from "./core/transactions/transaction-service"; import type { LocationSlug, ProductSlug } from "./core/schema/registry"; import type { RuntimeSchema } from "./core/schema/runtime"; import { SdkConfiguration } from "./core/sdk-configuration"; @@ -48,7 +54,13 @@ const CreateEffectRuntime = ( ManagedRuntime.make( pipe( CustomerAttributeManager.Default, + Layer.provideMerge(ProductService.layer), + Layer.provideMerge(FeatureFlagService.layer), + Layer.provideMerge(PaywallService.layer), + Layer.provideMerge(TransactionService.layer), Layer.provideMerge(AnalyticsService.layer), + Layer.provideMerge(LifecycleService.layer), + Layer.provideMerge(ReactNativeLifecycleAdapter), Layer.provideMerge(CustomerInfoManager.Default), Layer.provideMerge(IdentityManager.Default), Layer.provideMerge(CacheManager.Default), @@ -85,8 +97,10 @@ type UninitializedEffectClient = ReturnType< typeof VoidhashEffectClient.makeUnitializedClient >; -type InitializedEffectClient = ReturnType< - typeof VoidhashEffectClient.makeInitializedClient +// `makeInitializedClient` now returns `Effect` — unwrap to the +// facade type by extracting the Effect's Success channel. +type InitializedEffectClient = Effect.Success< + ReturnType >; export class VoidhashClient { diff --git a/libraries/react-native/src/core/analytics/service.ts b/libraries/react-native/src/core/analytics/service.ts index 8e164ef84..7bf9ecf4a 100644 --- a/libraries/react-native/src/core/analytics/service.ts +++ b/libraries/react-native/src/core/analytics/service.ts @@ -1,13 +1,464 @@ -import { Effect, Layer, ServiceMap } from "effect"; +import { + Duration, + Effect, + Latch, + Layer, + Ref, + Schedule, + ServiceMap, +} from "effect"; +import { + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; +import { CacheManager } from "../caching/cache-manager"; +import { SDK_VERSION } from "../constants"; +import { IdentityManager } from "../identity/identity-manager"; +import { SdkConfiguration } from "../sdk-configuration"; +import { getNonce } from "../utils/crypto"; +import { + AnalyticsIngestEvent, + AnalyticsSendFailure, + QueuedAnalyticsEvent, +} from "./types"; +import { + createQueuedAnalyticsEvent, + getAnalyticsStandardizedProperties, + mapQueuedAnalyticsEventToIngestEvent, +} from "./utils"; -export class AnalyticsService extends ServiceMap.Service()("voidhash-react-native/AnalyticsService", { - make: Effect.gen(function* () { +const ANALYTICS_BATCH_SIZE = 20; +const ANALYTICS_FLUSH_INTERVAL_MS = 5000; +const MAX_ANALYTICS_RETRY_DELAY_MS = 30_000; +const RETRYABLE_ANALYTICS_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]); +const ANALYTICS_LAST_SEEN_APP_RELEASE_STORAGE_KEY = + "voidhash:analytics:last-seen-app-release"; - // const config = yield* Config; - // return { log: (msg: string) => Effect.log(`[${config.prefix}] ${msg}`) }; - }), -}) { - // Build the layer yourself from the make effect +interface AppReleaseInfo { + readonly appBuild: string | null; + readonly appVersion: string | null; +} + +const toNullableString = (value: unknown): string | null => + value !== null && value !== undefined ? String(value) : null; + +const toAppReleaseInfo = ( + value: AppReleaseInfo | undefined | null +): AppReleaseInfo | null => { + if (!value) return null; + return { + appBuild: value.appBuild, + appVersion: value.appVersion, + }; +}; + +const getAnalyticsRetryDelayMs = (attempts: number) => + Math.min(1000 * 2 ** Math.max(attempts - 1, 0), MAX_ANALYTICS_RETRY_DELAY_MS); + +const parseRetryAfterMs = ( + value: string | null | undefined +): number | undefined => { + if (!value) { + return undefined; + } + + const retryAfterSeconds = Number(value); + if (!Number.isNaN(retryAfterSeconds) && retryAfterSeconds >= 0) { + return Math.ceil(retryAfterSeconds * 1000); + } + + const retryAt = Date.parse(value); + if (Number.isNaN(retryAt)) { + return undefined; + } + + return Math.max(retryAt - Date.now(), 0); +}; + +const getRetryAfterMsFromResponseBody = ( + data: unknown +): number | undefined => { + if ( + data !== null && + typeof data === "object" && + "retry_after_ms" in data && + typeof (data as { retry_after_ms?: unknown }).retry_after_ms === "number" + ) { + return (data as { retry_after_ms: number }).retry_after_ms; + } + return undefined; +}; + +const resolveIngestEventsUrl = (options: { + baseUrl: string; + ingestUrl: string | undefined; +}) => { + const baseUrl = options.ingestUrl + ? new URL(options.ingestUrl) + : buildDefaultIngestBaseUrl(options.baseUrl); + return new URL("/batch", baseUrl).toString(); +}; + +const buildDefaultIngestBaseUrl = (apiBaseUrl: string) => { + const parsedApiUrl = new URL(apiBaseUrl); + parsedApiUrl.hostname = `i.${parsedApiUrl.hostname}`; + parsedApiUrl.hash = ""; + parsedApiUrl.pathname = "/"; + parsedApiUrl.search = ""; + return parsedApiUrl; +}; + +/** + * Inline retry schedule used inside `flush()`: exponential backoff capped at 3 + * total attempts. Retry-After-bearing failures are excluded via the `while` + * predicate so they're postponed in the queue instead — preserving the + * cool-down behavior expected by the rate-limit tests. + */ +const inlineRetrySchedule = Schedule.exponential( + Duration.seconds(1), + 2 +).pipe(Schedule.both(Schedule.recurs(2))); + +/** + * Owns the analytics pipeline: an in-memory event queue with batching, a + * declarative retry schedule, `Retry-After` honouring, automatic startup + * events (`app_installed` / `app_updated` / `app_opened`), and a periodic + * flush daemon forked into the service scope. Disposing the runtime closes + * the scope, which interrupts the daemon — no manual timer cleanup required. + * + * The synchronous `getQueueLength` and `setFlushCallback` methods exist so the + * outer `VoidhashClient` wrapper can hook a background flush callback at init + * time (which routes the daemon's flush through the wrapper's + * `analyticsFlushInFlight` Promise guard) and tests can assert on queue length + * without driving an Effect. + */ +export class AnalyticsService extends ServiceMap.Service()( + "rn-voidhash/AnalyticsService", + { + make: Effect.gen(function* () { + const identityManager = yield* IdentityManager; + const cacheManager = yield* CacheManager; + const sdkConfiguration = yield* SdkConfiguration; + const httpClient = yield* HttpClient.HttpClient; + + const queueRef = yield* Ref.make>([]); + const latch = yield* Latch.make(false); + const sessionId = getNonce(); + const getStandardizedProperties = getAnalyticsStandardizedProperties(); + let flushCallback: (() => void) | null = null; + + const ingestEventsUrl = resolveIngestEventsUrl({ + baseUrl: sdkConfiguration.baseUrl, + ingestUrl: sdkConfiguration.ingestUrl, + }); + + const buildRetryableFailure = (response: HttpClientResponse.HttpClientResponse) => + Effect.gen(function* () { + const body = yield* response.json.pipe( + Effect.orElseSucceed(() => undefined as unknown) + ); + return yield* Effect.fail( + new AnalyticsSendFailure({ + message: `Analytics ingest request failed: ${response.status}`, + retryAfterMs: + parseRetryAfterMs(response.headers["retry-after"]) ?? + getRetryAfterMsFromResponseBody(body), + retryable: true, + status: response.status, + }) + ); + }); + + const sendAnalyticsEvents = ( + events: ReadonlyArray + ): Effect.Effect => + Effect.gen(function* () { + if (events.length === 0) return; + + const distinctId = yield* identityManager.getDistinctId(); + const request = HttpClientRequest.post(ingestEventsUrl).pipe( + HttpClientRequest.bodyJsonUnsafe({ + events: events.map((event) => ({ + context: event.context, + distinct_id: distinctId, + event: event.event_name, + properties: event.properties, + request: { + sdk_name: "react-native", + sdk_version: SDK_VERSION, + }, + session_id: event.session_id, + timestamp: event.event_ts, + uuid: event.event_id, + })), + sent_at: new Date().toISOString(), + token: sdkConfiguration.publishableKey, + }) + ); + + const response = yield* httpClient.execute(request).pipe( + Effect.catchTag("HttpClientError", (cause) => + Effect.fail( + new AnalyticsSendFailure({ + cause, + message: "Analytics request failed", + retryable: true, + }) + ) + ) + ); + + return yield* HttpClientResponse.matchStatus(response, { + "2xx": () => Effect.void, + 413: () => + Effect.fail( + new AnalyticsSendFailure({ + message: `Analytics ingest request failed: ${response.status}`, + retryable: false, + status: response.status, + }) + ), + orElse: (res) => + RETRYABLE_ANALYTICS_STATUS_CODES.has(res.status) + ? buildRetryableFailure(res) + : Effect.fail( + new AnalyticsSendFailure({ + message: `Analytics ingest request failed: ${res.status}`, + retryable: false, + status: res.status, + }) + ), + }); + }); + + // Inline retry wrapper used by the queue-draining `flush()` path. Public + // `sendAnalyticsEvents` stays single-shot so callers can implement their + // own retry strategy. + const sendWithInlineRetry = ( + events: ReadonlyArray + ) => + sendAnalyticsEvents(events).pipe( + Effect.retry({ + schedule: inlineRetrySchedule, + while: (failure: AnalyticsSendFailure) => + failure.retryable && failure.retryAfterMs === undefined, + }) + ); + + // Re-inserts a failed batch at the head of the queue with bumped + // `availableAt` so the next due-check skips it until cool-down has + // elapsed. Used only on retryable failures — successful sends and + // non-retryable drops simply leave the events out of the queue, since + // `takeDueBatch` already removed them. + const postponeQueuedBatch = ( + events: ReadonlyArray, + nextAvailableAt: number + ) => + Ref.update(queueRef, (queue) => { + const postponed = events.map((event) => ({ + ...event, + attempts: event.attempts + 1, + availableAt: nextAvailableAt, + })); + return [...postponed, ...queue]; + }); + + // Pops the next `due` batch (events whose `availableAt <= now`) from the + // head of the queue. Treating taken events as "in flight" simplifies + // failure handling: success means no further action, retryable failure + // re-inserts via `postponeQueuedBatch`, and non-retryable failure leaves + // them dropped. + const takeDueBatch = () => + Ref.modify(queueRef, (queue) => { + const now = Date.now(); + const batch: QueuedAnalyticsEvent[] = []; + let cutoff = 0; + + for (const event of queue) { + if (event.availableAt > now) break; + batch.push(event); + cutoff += 1; + if (batch.length >= ANALYTICS_BATCH_SIZE) break; + } + + const remaining = batch.length === 0 ? queue : queue.slice(cutoff); + return [batch as ReadonlyArray, remaining]; + }); + + const processQueuedBatch = ( + queuedBatch: ReadonlyArray, + standardizedProperties: Record + ): Effect.Effect => { + const ingestBatch = queuedBatch.map((event) => + mapQueuedAnalyticsEventToIngestEvent( + event, + standardizedProperties, + sessionId + ) + ); + + return sendWithInlineRetry(ingestBatch).pipe( + Effect.catchTag("AnalyticsSendFailure", (failure) => { + if (failure.status === 413 && queuedBatch.length > 1) { + const midpoint = Math.ceil(queuedBatch.length / 2); + return Effect.gen(function* () { + yield* processQueuedBatch( + queuedBatch.slice(0, midpoint), + standardizedProperties + ); + yield* processQueuedBatch( + queuedBatch.slice(midpoint), + standardizedProperties + ); + }); + } + + if (failure.status === 413) { + return Effect.logWarning( + "Dropping analytics event after 413 response", + { eventId: queuedBatch[0]?.id } + ); + } + + if (failure.retryable) { + const delayMs = + failure.retryAfterMs ?? + getAnalyticsRetryDelayMs( + (queuedBatch[0]?.attempts ?? 0) + 1 + ); + return postponeQueuedBatch(queuedBatch, Date.now() + delayMs); + } + + return Effect.logWarning( + "Dropping analytics batch after non-retryable response", + { + eventIds: queuedBatch.map((event) => event.id), + status: failure.status, + } + ); + }) + ); + }; + + const capture = ( + eventName: string, + properties: Record = {} + ) => + Effect.sync(() => { + const normalized = eventName.trim(); + if (!normalized) return; + const queued = createQueuedAnalyticsEvent(normalized, properties); + // Direct mutation inside `Effect.sync` is safe: the Effect runtime + // guarantees no other fiber crosses this sync boundary. + const next = [...queueRef.ref.current, queued]; + queueRef.ref.current = next; + if (next.length >= ANALYTICS_BATCH_SIZE) { + // Wake the flush daemon immediately rather than waiting for the tick. + latch.openUnsafe(); + flushCallback?.(); + } + }); + + const flush = () => + Effect.gen(function* () { + const standardizedProperties = yield* getStandardizedProperties(); + + let batch = yield* takeDueBatch(); + while (batch.length > 0) { + yield* processQueuedBatch(batch, standardizedProperties); + batch = yield* takeDueBatch(); + } + }); + + const transferEvents = ( + events: ReadonlyArray<{ + eventName: string; + properties: Record; + }> + ) => + Effect.sync(() => { + const additions: QueuedAnalyticsEvent[] = []; + for (const event of events) { + const normalized = event.eventName.trim(); + if (!normalized) continue; + additions.push( + createQueuedAnalyticsEvent(normalized, event.properties) + ); + } + if (additions.length === 0) return; + queueRef.ref.current = [...queueRef.ref.current, ...additions]; + }); + + const captureAutomaticStartupEvents = () => + Effect.gen(function* () { + const standardizedProps = yield* getStandardizedProperties(); + const currentAppRelease: AppReleaseInfo = { + appBuild: toNullableString(standardizedProps.$app_build), + appVersion: toNullableString(standardizedProps.$app_version), + }; + + // If reading the cached release fails, fall back to recording the + // session as a fresh `app_opened`. Captures still flow through the + // same queue so the failure mode is "lose the install/update event," + // not "drop the session start." + const cachedRelease = yield* cacheManager + .get(ANALYTICS_LAST_SEEN_APP_RELEASE_STORAGE_KEY) + .pipe(Effect.orElseSucceed(() => null)); + const previousAppRelease = toAppReleaseInfo(cachedRelease?.value); + + const additions: QueuedAnalyticsEvent[] = []; + if (!previousAppRelease) { + additions.push(createQueuedAnalyticsEvent("app_installed", {})); + } else if ( + previousAppRelease.appBuild !== currentAppRelease.appBuild || + previousAppRelease.appVersion !== currentAppRelease.appVersion + ) { + additions.push(createQueuedAnalyticsEvent("app_updated", {})); + } + additions.push(createQueuedAnalyticsEvent("app_opened", {})); + + queueRef.ref.current = [...queueRef.ref.current, ...additions]; + + yield* cacheManager + .set( + ANALYTICS_LAST_SEEN_APP_RELEASE_STORAGE_KEY, + currentAppRelease + ) + .pipe(Effect.orElseSucceed(() => undefined)); + }); + + // Background flush daemon: wakes on either the 5s tick or a threshold + // signal from `capture`, then fires the registered callback so the outer + // wrapper can route the flush through its single-flight guard. Forked + // into the service scope — interrupted automatically on runtime dispose. + const daemon = Effect.forever( + Effect.gen(function* () { + yield* Effect.race( + Effect.sleep(Duration.millis(ANALYTICS_FLUSH_INTERVAL_MS)), + latch.await + ); + yield* latch.close; + yield* Effect.sync(() => flushCallback?.()); + }) + ); + yield* Effect.forkScoped(daemon); + + return { + capture, + captureAutomaticStartupEvents, + flush, + getQueueLength: () => queueRef.ref.current.length, + getStandardizedProperties: () => getStandardizedProperties(), + sendAnalyticsEvents, + setFlushCallback: (cb: () => void) => { + flushCallback = cb; + }, + transferEvents, + } as const; + }), + } +) { static readonly layer = Layer.effect(this, this.make); -} \ No newline at end of file +} diff --git a/libraries/react-native/src/core/analytics/types.ts b/libraries/react-native/src/core/analytics/types.ts index d49f313f5..9f7b1ce08 100644 --- a/libraries/react-native/src/core/analytics/types.ts +++ b/libraries/react-native/src/core/analytics/types.ts @@ -1,3 +1,5 @@ +import { Data } from "effect"; + export interface QueuedAnalyticsEvent { readonly attempts: number; readonly availableAt: number; @@ -22,22 +24,18 @@ export interface AnalyticsIngestEvent { readonly session_id: string; } -export class AnalyticsSendFailure extends Error { - readonly retryAfterMs?: number; +/** + * Tagged error raised when the analytics ingest endpoint rejects a batch. + * Tagged so callers can use `Effect.catchTag("AnalyticsSendFailure", ...)`. + * `retryable` indicates whether the failure is worth re-attempting; `retryAfterMs` + * communicates a server-suggested backoff (from `Retry-After` header or body). + */ +export class AnalyticsSendFailure extends Data.TaggedError( + "AnalyticsSendFailure" +)<{ + readonly message: string; readonly retryable: boolean; - readonly status?: number; - - constructor(input: { - readonly message: string; - readonly retryable: boolean; - readonly retryAfterMs?: number; - readonly status?: number; - readonly cause?: unknown; - }) { - super(input.message, input.cause ? { cause: input.cause } : undefined); - this.name = "AnalyticsSendFailure"; - this.retryAfterMs = input.retryAfterMs; - this.retryable = input.retryable; - this.status = input.status; - } -} + readonly retryAfterMs?: number | undefined; + readonly status?: number | undefined; + readonly cause?: unknown; +}> {} diff --git a/libraries/react-native/src/core/feature-flags/feature-flag-service.ts b/libraries/react-native/src/core/feature-flags/feature-flag-service.ts new file mode 100644 index 000000000..8fbcd782b --- /dev/null +++ b/libraries/react-native/src/core/feature-flags/feature-flag-service.ts @@ -0,0 +1,68 @@ +import { Effect, Layer, ServiceMap } from "effect"; + +import { CacheManager } from "../caching/cache-manager"; +import { EventBusProvider } from "../event-bus"; +import { IdentityManager } from "../identity/identity-manager"; +import { ApiClient } from "../networking/api-client"; +import { getCommonSdkHeaders } from "../utils/get-common-sdk-headers"; + +export interface FeatureFlagsResult { + readonly flags: ReadonlyArray<{ + readonly enabled: boolean; + readonly key: string; + readonly payload: unknown | null; + readonly variantKey: string | null; + }>; +} + +const FEATURE_FLAGS_CACHE_TTL_MS = 1000 * 60 * 5; + +const generateCacheKey = (flagKeys: string[] | undefined) => + `feature-flags:${flagKeys?.sort().join(",") ?? "all"}`; + +/** + * Evaluates feature flags via the SDK API with a 5-minute cache. Emits a + * `feature-flags-fetched` event on the event bus whenever a fresh result is + * received from the server (cache hits don't re-emit). + */ +export class FeatureFlagService extends ServiceMap.Service()( + "rn-voidhash/FeatureFlagService", + { + make: Effect.gen(function* () { + const cacheManager = yield* CacheManager; + const apiClient = yield* ApiClient; + const eventBus = yield* EventBusProvider; + const identityManager = yield* IdentityManager; + + const getFeatureFlags = (flagKeys?: string[]) => + Effect.gen(function* () { + const cacheKey = generateCacheKey(flagKeys); + const cached = yield* cacheManager.get(cacheKey); + if (cached && !cached.isExpired && !cached.isStale) { + return cached.value; + } + + const commonHeaders = yield* getCommonSdkHeaders(); + const distinctId = yield* identityManager.getDistinctId(); + const result = yield* apiClient.sdk.evaluateFeatureFlags({ + headers: { + ...commonHeaders, + "x-distinct-id": distinctId, + }, + payload: { flagKeys }, + }); + + yield* cacheManager.set(cacheKey, result, { + ttl: FEATURE_FLAGS_CACHE_TTL_MS, + }); + + eventBus.emit("feature-flags-fetched", result); + return result; + }); + + return { getFeatureFlags } as const; + }), + } +) { + static readonly layer = Layer.effect(this, this.make); +} diff --git a/libraries/react-native/src/core/lifecycle/lifecycle-adapter.ts b/libraries/react-native/src/core/lifecycle/lifecycle-adapter.ts new file mode 100644 index 000000000..61efce21a --- /dev/null +++ b/libraries/react-native/src/core/lifecycle/lifecycle-adapter.ts @@ -0,0 +1,24 @@ +import { type Effect, ServiceMap } from "effect"; + +export interface LifecycleSubscription { + readonly remove: () => void; +} + +/** + * Bridge to the host platform's app-lifecycle events. Implementations subscribe + * to native state-change events (foreground/background/inactive) and forward + * the raw transitions to the listener. The listener receives both the next + * state and the previously observed state so it can compute meaningful + * transitions (e.g. "became active" only fires when coming from non-active). + * + * Implementations return `null` when the platform doesn't expose lifecycle + * events (e.g. unit-test environments without React Native installed). + */ +export class LifecycleAdapter extends ServiceMap.Service< + LifecycleAdapter, + { + readonly subscribe: ( + listener: (nextState: string, previousState: string | null) => void + ) => Effect.Effect; + } +>()("rn-voidhash/LifecycleAdapter") {} diff --git a/libraries/react-native/src/core/lifecycle/lifecycle-service.ts b/libraries/react-native/src/core/lifecycle/lifecycle-service.ts new file mode 100644 index 000000000..2cac8f71c --- /dev/null +++ b/libraries/react-native/src/core/lifecycle/lifecycle-service.ts @@ -0,0 +1,40 @@ +import { Effect, Layer, ServiceMap } from "effect"; + +import { LifecycleAdapter, type LifecycleSubscription } from "./lifecycle-adapter"; + +/** + * Wires raw platform lifecycle transitions to high-level analytics-style + * events. Translates background/active transitions delivered by + * `LifecycleAdapter` into `app_backgrounded` and `app_became_active` callbacks + * which the caller is expected to forward to the analytics pipeline. + */ +export class LifecycleService extends ServiceMap.Service()( + "rn-voidhash/LifecycleService", + { + make: Effect.gen(function* () { + const adapter = yield* LifecycleAdapter; + + const setupAutomaticLifecycleEvents = ( + captureEvent: (eventName: string) => void + ): Effect.Effect => + adapter.subscribe((nextState, previousState) => { + if (nextState === "background" && previousState !== "background") { + captureEvent("app_backgrounded"); + return; + } + + if ( + nextState === "active" && + previousState !== null && + previousState !== "active" + ) { + captureEvent("app_became_active"); + } + }); + + return { setupAutomaticLifecycleEvents } as const; + }), + } +) { + static readonly layer = Layer.effect(this, this.make); +} diff --git a/libraries/react-native/src/core/lifecycle/react-native-lifecycle-adapter.ts b/libraries/react-native/src/core/lifecycle/react-native-lifecycle-adapter.ts new file mode 100644 index 000000000..66eda9e19 --- /dev/null +++ b/libraries/react-native/src/core/lifecycle/react-native-lifecycle-adapter.ts @@ -0,0 +1,54 @@ +import { Effect, Layer } from "effect"; + +import { LifecycleAdapter, type LifecycleSubscription } from "./lifecycle-adapter"; + +type AppLifecycleState = string; + +interface ReactNativeAppState { + readonly currentState?: AppLifecycleState; + addEventListener: ( + eventType: "change", + listener: (nextState: AppLifecycleState) => void + ) => LifecycleSubscription; +} + +/** + * Dynamically resolve `react-native`'s `AppState` so that the SDK degrades + * gracefully in environments where React Native isn't installed (e.g. Jest + * tests, Node-only consumers). + */ +const getReactNativeAppState = (): ReactNativeAppState | null => { + try { + const reactNative = require("react-native") as { + readonly AppState?: ReactNativeAppState; + }; + return reactNative.AppState ?? null; + } catch { + return null; + } +}; + +/** + * Default `LifecycleAdapter` implementation that bridges to React Native's + * `AppState`. Owns the dynamic `require("react-native")` so the rest of the + * SDK can stay React-Native-independent. + */ +export const ReactNativeLifecycleAdapter = Layer.succeed(LifecycleAdapter, { + subscribe: (listener) => + Effect.sync(() => { + const appState = getReactNativeAppState(); + if (!appState || typeof appState.addEventListener !== "function") { + return null; + } + + let previousState: AppLifecycleState | null = appState.currentState ?? null; + + const subscription = appState.addEventListener("change", (nextState) => { + const prior = previousState; + previousState = nextState; + listener(nextState, prior); + }); + + return subscription; + }), +}); diff --git a/libraries/react-native/src/core/paywalls/paywall-service.ts b/libraries/react-native/src/core/paywalls/paywall-service.ts new file mode 100644 index 000000000..9b45ed42d --- /dev/null +++ b/libraries/react-native/src/core/paywalls/paywall-service.ts @@ -0,0 +1,37 @@ +import { Effect, Layer, ServiceMap } from "effect"; + +import { IdentityManager } from "../identity/identity-manager"; +import { ApiClient } from "../networking/api-client"; +import type { LocationSlug } from "../schema/registry"; +import { getCommonSdkHeaders } from "../utils/get-common-sdk-headers"; + +/** + * Resolves the currently assigned paywall for a location slug. Stateless; + * delegates to the SDK API with the standard SDK headers. + */ +export class PaywallService extends ServiceMap.Service()( + "rn-voidhash/PaywallService", + { + make: Effect.gen(function* () { + const apiClient = yield* ApiClient; + const identityManager = yield* IdentityManager; + + const getPaywallForLocation = (locationSlug: LocationSlug) => + Effect.gen(function* () { + const commonHeaders = yield* getCommonSdkHeaders(); + const distinctId = yield* identityManager.getDistinctId(); + return yield* apiClient.sdk.resolvePaywall({ + headers: { + ...commonHeaders, + "x-distinct-id": distinctId, + }, + payload: { locationSlug: String(locationSlug) }, + }); + }); + + return { getPaywallForLocation } as const; + }), + } +) { + static readonly layer = Layer.effect(this, this.make); +} diff --git a/libraries/react-native/src/core/products/product-service.ts b/libraries/react-native/src/core/products/product-service.ts new file mode 100644 index 000000000..eca43bbac --- /dev/null +++ b/libraries/react-native/src/core/products/product-service.ts @@ -0,0 +1,95 @@ +import { Effect, Layer, ServiceMap } from "effect"; + +import { CacheManager } from "../caching/cache-manager"; +import type { Product, SubscriptionProduct } from "../entities/product"; +import { PaymentAdapter } from "../payment-adapters/payment-adapter"; +import type { ProductSlug } from "../schema/registry"; +import type { + RuntimeProductDefinition, + RuntimeSchema, +} from "../schema/runtime"; + +/** + * Map of product slugs to the resolved native subscription product (or `null` + * when the underlying store SDK doesn't know about that product on this + * platform). + */ +export type ProductsBySlug = Record; + +const NATIVE_PRODUCTS_CACHE_TTL_MS = 1000 * 60 * 60 * 24; + +const generateCacheKeyFromProductDefinitions = ( + productDefinitions: Readonly> +) => `native-products:${JSON.stringify(productDefinitions)}`; + +const mapNativeProductsToProductMap = ( + productDefinitions: Readonly>, + nativeProducts: Product[] +): ProductsBySlug => { + const productMap = {} as Record; + + for (const slug of Object.keys(productDefinitions)) { + const nativeProduct = nativeProducts.find( + (candidate) => candidate.slug === slug + ); + productMap[slug] = (nativeProduct as SubscriptionProduct | undefined) ?? null; + } + + return productMap as ProductsBySlug; +}; + +/** + * Fetches the products declared in the given schema from the native store + * (with a 24h cache) and maps them back to the schema slugs. Missing products + * (not configured for the current platform) are returned as `null`. + */ +export class ProductService extends ServiceMap.Service()( + "rn-voidhash/ProductService", + { + make: Effect.gen(function* () { + const cacheManager = yield* CacheManager; + const paymentAdapter = yield* PaymentAdapter; + + const loadProductsCached = ( + productDefinitions: Readonly> + ) => + Effect.gen(function* () { + const cacheKey = + generateCacheKeyFromProductDefinitions(productDefinitions); + const cached = yield* cacheManager.get(cacheKey); + if (cached && !(cached.isStale || cached.isExpired)) { + yield* Effect.logDebug("Products fetched from cache", { + products: cached.value, + }); + return cached.value; + } + + const nativeProducts = + yield* paymentAdapter.getProducts(productDefinitions); + yield* Effect.logDebug("Products fetched from native adapter", { + products: nativeProducts, + }); + + yield* cacheManager.set(cacheKey, nativeProducts, { + ttl: NATIVE_PRODUCTS_CACHE_TTL_MS, + }); + + return nativeProducts; + }); + + const getProducts = (schema: RuntimeSchema) => + Effect.gen(function* () { + const productDefinitions = schema.products; + const nativeProducts = yield* loadProductsCached(productDefinitions); + return mapNativeProductsToProductMap( + productDefinitions, + nativeProducts + ); + }); + + return { getProducts } as const; + }), + } +) { + static readonly layer = Layer.effect(this, this.make); +} diff --git a/libraries/react-native/src/core/transactions/transaction-service.ts b/libraries/react-native/src/core/transactions/transaction-service.ts new file mode 100644 index 000000000..00441f094 --- /dev/null +++ b/libraries/react-native/src/core/transactions/transaction-service.ts @@ -0,0 +1,223 @@ +import { Effect, Layer, ServiceMap } from "effect"; + +import { CacheManager } from "../caching/cache-manager"; +import type { SubscriptionProduct } from "../entities/product"; +import type { Transaction } from "../entities/transaction"; +import { CustomerInfoManager } from "../identity/customer-info-manager"; +import { IdentityManager } from "../identity/identity-manager"; +import { ApiClient } from "../networking/api-client"; +import { PaymentAdapter } from "../payment-adapters/payment-adapter"; +import type { + RuntimeProductDefinition, + RuntimeSchema, +} from "../schema/runtime"; +import { SdkConfiguration } from "../sdk-configuration"; +import { getCommonSdkHeaders } from "../utils/get-common-sdk-headers"; + +const PROCESSED_TRANSACTION_TTL_MS = 1000 * 60 * 30; + +const buildTransactionProcessingKey = (transaction: Transaction) => + `${transaction.platform}:${transaction.transactionId}:${transaction.purchaseDate}`; + +const getProcessedTransactionCacheKey = (transactionProcessingKey: string) => + `processed-transaction:${transactionProcessingKey}`; + +const resolveTransactionProductSlug = ( + transaction: Transaction, + productDefinitions: Readonly> +) => { + const matchedProduct = Object.values(productDefinitions).find( + (productDefinition) => { + if (productDefinition.slug === transaction.productId) { + return true; + } + + const provider = + transaction.platform === "ios" + ? productDefinition.configuration.providers.appleAppStore + : productDefinition.configuration.providers.googlePlay; + + return provider?.productId === transaction.productId; + } + ); + + return matchedProduct?.slug ?? transaction.productId; +}; + +const mapTransactionToSyncPayload = ( + transaction: Transaction, + productDefinitions: Readonly> +) => { + const productSlug = resolveTransactionProductSlug( + transaction, + productDefinitions + ); + + if (transaction.platform === "ios") { + return { + platform: "ios" as const, + productSlug, + purchaseDate: transaction.purchaseDate, + quantity: transaction.quantity, + receipt: transaction.receipt, + transactionId: transaction.transactionId, + }; + } + + return { + platform: "android" as const, + productSlug, + purchaseDate: transaction.purchaseDate, + purchaseToken: transaction.purchaseToken ?? "", + quantity: transaction.quantity, + receipt: transaction.receipt, + transactionId: transaction.transactionId, + }; +}; + +/** + * Owns the transaction lifecycle: deduplicated server-side sync, observation + * reconciliation, purchase orchestration, restore-purchases, and the native + * transaction observer. Holds an in-memory `inFlightKeys` set per runtime to + * coalesce concurrent sync attempts for the same transaction (the cache TTL + * catches duplicate attempts across runtime restarts). + */ +export class TransactionService extends ServiceMap.Service()( + "rn-voidhash/TransactionService", + { + make: Effect.gen(function* () { + const apiClient = yield* ApiClient; + const cacheManager = yield* CacheManager; + const customerInfoManager = yield* CustomerInfoManager; + const identityManager = yield* IdentityManager; + const paymentAdapter = yield* PaymentAdapter; + const sdkConfiguration = yield* SdkConfiguration; + + const inFlightKeys = new Set(); + + const processObservedTransaction = ( + transaction: Transaction, + schema: RuntimeSchema + ) => + Effect.gen(function* () { + const transactionProcessingKey = + buildTransactionProcessingKey(transaction); + if (inFlightKeys.has(transactionProcessingKey)) { + return; + } + + const processedCacheKey = getProcessedTransactionCacheKey( + transactionProcessingKey + ); + const cachedTransaction = + yield* cacheManager.get(processedCacheKey); + if ( + cachedTransaction && + !cachedTransaction.isExpired && + cachedTransaction.value + ) { + return; + } + + if (transaction.platform === "android" && !transaction.purchaseToken) { + yield* Effect.logWarning( + "Skipping observed Android transaction without purchase token", + { + transactionId: transaction.transactionId, + } + ); + return; + } + + inFlightKeys.add(transactionProcessingKey); + try { + const commonHeaders = yield* getCommonSdkHeaders(); + const distinctId = yield* identityManager.getDistinctId(); + + yield* apiClient.sdk.syncTransaction({ + headers: { + ...commonHeaders, + "x-distinct-id": distinctId, + }, + payload: mapTransactionToSyncPayload( + transaction, + schema.products + ), + }); + + yield* cacheManager.set(processedCacheKey, true, { + ttl: PROCESSED_TRANSACTION_TTL_MS, + }); + + if (!sdkConfiguration.readOnly) { + yield* paymentAdapter.acknowledgePurchase(transaction); + } + } finally { + inFlightKeys.delete(transactionProcessingKey); + } + }); + + const reconcileObservedTransactions = (schema: RuntimeSchema) => + Effect.gen(function* () { + const [pendingTransactions, purchasedTransactions] = yield* Effect.all( + [ + paymentAdapter.getPendingTransactions(), + paymentAdapter.getPurchaseHistory(true), + ] + ); + + const observedTransactionsByKey = new Map(); + for (const transaction of [ + ...pendingTransactions, + ...purchasedTransactions, + ]) { + observedTransactionsByKey.set( + buildTransactionProcessingKey(transaction), + transaction + ); + } + + for (const transaction of observedTransactionsByKey.values()) { + yield* processObservedTransaction(transaction, schema).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to process observed transaction", { + error, + transactionId: transaction.transactionId, + }) + ) + ); + } + }); + + const purchase = (product: SubscriptionProduct, schema: RuntimeSchema) => + Effect.gen(function* () { + const transaction = yield* paymentAdapter.buyProduct(product); + yield* processObservedTransaction(transaction, schema); + }); + + const restorePurchases = (schema: RuntimeSchema) => + Effect.gen(function* () { + yield* reconcileObservedTransactions(schema); + const distinctId = yield* identityManager.getDistinctId(); + yield* customerInfoManager.getCustomer(distinctId, "fetch"); + }); + + const startTransactionObserver = ( + onPurchase?: (transaction: Transaction) => void + ) => paymentAdapter.initConnection(onPurchase); + + const endConnection = () => paymentAdapter.endConnection(); + + return { + endConnection, + processObservedTransaction, + purchase, + reconcileObservedTransactions, + restorePurchases, + startTransactionObserver, + } as const; + }), + } +) { + static readonly layer = Layer.effect(this, this.make); +} diff --git a/libraries/react-native/src/__tests__/client.test.ts b/libraries/react-native/tests/client.test.ts similarity index 78% rename from libraries/react-native/src/__tests__/client.test.ts rename to libraries/react-native/tests/client.test.ts index e7a88568a..810f00c12 100644 --- a/libraries/react-native/src/__tests__/client.test.ts +++ b/libraries/react-native/tests/client.test.ts @@ -1,25 +1,29 @@ import { Exit } from "effect"; +import { vi } from "vitest"; +import { describe, expect, it } from "./helpers/effect-vitest"; -jest.mock("react-native", () => ({ AppState: null }), { virtual: true }); +vi.mock("react-native", () => ({ AppState: null })); -jest.mock("../core/payment-adapters/app-store-adapter", () => { - const { Layer } = jest.requireActual("effect"); +vi.mock("../src/core/payment-adapters/app-store-adapter", async () => { + const { Layer } = await vi.importActual("effect"); return { AppStoreAdapter: Layer.empty, }; }); -jest.mock("../core/payment-adapters/google-play-adapter", () => { - const { Layer } = jest.requireActual("effect"); +vi.mock("../src/core/payment-adapters/google-play-adapter", async () => { + const { Layer } = await vi.importActual("effect"); return { GooglePlayAdapter: Layer.empty, }; }); -jest.mock("../core/platform/react-native-platform-provider", () => { - const { Layer } = jest.requireActual("effect"); - const { PlatformProvider } = jest.requireActual( - "../core/platform/platform-provider" +vi.mock("../src/core/platform/react-native-platform-provider", async () => { + const { Layer } = await vi.importActual("effect"); + const { PlatformProvider } = await vi.importActual< + typeof import("../src/core/platform/platform-provider") + >( + "../src/core/platform/platform-provider" ); return { ReactNativePlatformProvider: Layer.succeed(PlatformProvider, { @@ -37,12 +41,12 @@ jest.mock("../core/platform/react-native-platform-provider", () => { }; }); -import { VoidhashClient } from "../client"; -import { EventBus } from "../core/event-bus"; +import { VoidhashClient } from "../src/client"; +import { EventBus } from "../src/core/event-bus"; import { ReadOnlyModePurchaseNotAllowedError, VoidhashError, -} from "../errors"; +} from "../src/errors"; import { createTestSchema } from "./helpers/test-schema"; function createClient(readOnly = false, unstableSwallowErrors = false) { @@ -64,7 +68,7 @@ function createClient(readOnly = false, unstableSwallowErrors = false) { describe("VoidhashClient", () => { describe("unstable_swallowErrors", () => { it("swallows flush errors when unstable_swallowErrors is enabled", async () => { - const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { return; }); const client = createClient(false, true); @@ -73,7 +77,7 @@ describe("VoidhashClient", () => { flush: () => "flush-effect", }; (client as unknown as Record).effectRuntime = { - runPromiseExit: jest.fn().mockResolvedValue(Exit.fail("boom")), + runPromiseExit: vi.fn().mockResolvedValue(Exit.fail("boom")), }; await expect(client.flush()).resolves.toBeUndefined(); @@ -86,7 +90,7 @@ describe("VoidhashClient", () => { }); it("swallows identify errors when unstable_swallowErrors is enabled", async () => { - const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { return; }); const client = createClient(false, true); @@ -95,7 +99,7 @@ describe("VoidhashClient", () => { identify: () => "identify-effect", }; (client as unknown as Record).effectRuntime = { - runPromiseExit: jest.fn().mockResolvedValue(Exit.fail("boom")), + runPromiseExit: vi.fn().mockResolvedValue(Exit.fail("boom")), }; await expect( @@ -109,7 +113,7 @@ describe("VoidhashClient", () => { }); it("swallows restorePurchases errors when unstable_swallowErrors is enabled", async () => { - const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { return; }); const client = createClient(false, true); @@ -118,7 +122,7 @@ describe("VoidhashClient", () => { restorePurchases: () => "restore-purchases-effect", }; (client as unknown as Record).effectRuntime = { - runPromiseExit: jest.fn().mockResolvedValue(Exit.fail("boom")), + runPromiseExit: vi.fn().mockResolvedValue(Exit.fail("boom")), }; await expect(client.restorePurchases()).resolves.toBeUndefined(); @@ -130,7 +134,7 @@ describe("VoidhashClient", () => { }); it("swallows init errors and keeps client uninitialized when unstable_swallowErrors is enabled", async () => { - const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { return; }); const client = createClient(false, true); @@ -139,7 +143,7 @@ describe("VoidhashClient", () => { init: () => "init-effect", }; (client as unknown as Record).effectRuntime = { - runPromiseExit: jest.fn().mockResolvedValue(Exit.fail("boom")), + runPromiseExit: vi.fn().mockResolvedValue(Exit.fail("boom")), }; await expect(client.init()).resolves.toBeUndefined(); @@ -152,7 +156,7 @@ describe("VoidhashClient", () => { }); it("swallows ensureInitialized errors in side-effect methods when unstable_swallowErrors is enabled", async () => { - const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { return; }); const client = createClient(false, true); @@ -174,7 +178,7 @@ describe("VoidhashClient", () => { getProducts: () => "get-products-effect", }; (client as unknown as Record).effectRuntime = { - runPromiseExit: jest.fn().mockResolvedValue(Exit.fail("boom")), + runPromiseExit: vi.fn().mockResolvedValue(Exit.fail("boom")), }; await expect(client.getProducts()).rejects.toEqual( @@ -191,7 +195,7 @@ describe("VoidhashClient", () => { purchase: () => "purchase-effect", }; (client as unknown as Record).effectRuntime = { - runPromiseExit: jest.fn().mockResolvedValue(Exit.fail("boom")), + runPromiseExit: vi.fn().mockResolvedValue(Exit.fail("boom")), }; await expect( @@ -217,7 +221,7 @@ describe("VoidhashClient", () => { purchase: () => "purchase-effect", }; (client as unknown as Record).effectRuntime = { - runPromiseExit: jest.fn().mockResolvedValue(Exit.succeed(undefined)), + runPromiseExit: vi.fn().mockResolvedValue(Exit.succeed(undefined)), }; await expect( @@ -237,7 +241,7 @@ describe("VoidhashClient", () => { purchase: () => "purchase-effect", }; (client as unknown as Record).effectRuntime = { - runPromiseExit: jest.fn().mockResolvedValue(Exit.succeed(undefined)), + runPromiseExit: vi.fn().mockResolvedValue(Exit.succeed(undefined)), }; await expect( diff --git a/libraries/react-native/src/__tests__/core/cache-manager.test.ts b/libraries/react-native/tests/core/cache-manager.test.ts similarity index 79% rename from libraries/react-native/src/__tests__/core/cache-manager.test.ts rename to libraries/react-native/tests/core/cache-manager.test.ts index e081acf14..fdba80abc 100644 --- a/libraries/react-native/src/__tests__/core/cache-manager.test.ts +++ b/libraries/react-native/tests/core/cache-manager.test.ts @@ -1,8 +1,9 @@ import { Effect, Layer, ManagedRuntime, pipe } from "effect"; -import { CacheAdapter } from "../../core/caching/cache-adapter"; -import { CacheManager } from "../../core/caching/cache-manager"; +import { CacheAdapter } from "../../src/core/caching/cache-adapter"; +import { CacheManager } from "../../src/core/caching/cache-manager"; import { createInMemoryCacheAdapter } from "../helpers/effect-test-harness"; +import { describe, expect, it } from "../helpers/effect-vitest"; const wait = (ms: number) => new Promise((resolve) => { @@ -21,13 +22,13 @@ describe("CacheManager", () => { try { await runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => + Effect.flatMap(CacheManager.asEffect(), (manager) => manager.set("customer:1", { id: "1" }, { staleTime: 1000, ttl: 1000 }) ) ); const result = await runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => + Effect.flatMap(CacheManager.asEffect(), (manager) => manager.get<{ id: string }>("customer:1") ) ); @@ -53,14 +54,14 @@ describe("CacheManager", () => { try { await runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => + Effect.flatMap(CacheManager.asEffect(), (manager) => manager.set("customer:expired", { id: "expired" }, { ttl: 1 }) ) ); await wait(5); const result = await runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => + Effect.flatMap(CacheManager.asEffect(), (manager) => manager.get<{ id: string }>("customer:expired") ) ); @@ -83,14 +84,14 @@ describe("CacheManager", () => { try { await runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => + Effect.flatMap(CacheManager.asEffect(), (manager) => manager.set("customer:stale", { id: "stale" }, { staleTime: 1, ttl: 1000 }) ) ); await wait(5); const result = await runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => + Effect.flatMap(CacheManager.asEffect(), (manager) => manager.get<{ id: string }>("customer:stale") ) ); @@ -115,7 +116,7 @@ describe("CacheManager", () => { try { await runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => + Effect.flatMap(CacheManager.asEffect(), (manager) => Effect.all([ manager.set("k1", "v1"), manager.set("k2", "v2"), @@ -125,13 +126,13 @@ describe("CacheManager", () => { ); const keys = await runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => manager.getCacheKeys()) + Effect.flatMap(CacheManager.asEffect(), (manager) => manager.getCacheKeys()) ); const k1 = await runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => manager.get("k1")) + Effect.flatMap(CacheManager.asEffect(), (manager) => manager.get("k1")) ); const k2 = await runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => manager.get("k2")) + Effect.flatMap(CacheManager.asEffect(), (manager) => manager.get("k2")) ); expect(keys).toEqual([]); diff --git a/libraries/react-native/src/__tests__/core/client-effect.test.ts b/libraries/react-native/tests/core/client-effect.test.ts similarity index 79% rename from libraries/react-native/src/__tests__/core/client-effect.test.ts rename to libraries/react-native/tests/core/client-effect.test.ts index a255e4ce7..afc8a9d3d 100644 --- a/libraries/react-native/src/__tests__/core/client-effect.test.ts +++ b/libraries/react-native/tests/core/client-effect.test.ts @@ -1,21 +1,23 @@ import { Effect } from "effect"; +import { vi } from "vitest"; import { type AnalyticsIngestEvent, VoidhashEffectClient, -} from "../../client-effect"; -import { ANONYMOUS_DISTINCT_ID_PREFIX } from "../../constants"; -import { CacheManager } from "../../core/caching/cache-manager"; -import { Product, SubscriptionProduct } from "../../core/entities/product"; -import { Transaction } from "../../core/entities/transaction"; -import { CustomerAttributeManager } from "../../core/identity/customer-attribute-manager"; -import { SDK_VERSION } from "../../core/constants"; +} from "../../src/client-effect"; +import { ANONYMOUS_DISTINCT_ID_PREFIX } from "../../src/constants"; +import { CacheManager } from "../../src/core/caching/cache-manager"; +import { Product, SubscriptionProduct } from "../../src/core/entities/product"; +import { Transaction } from "../../src/core/entities/transaction"; +import { CustomerAttributeManager } from "../../src/core/identity/customer-attribute-manager"; +import { SDK_VERSION } from "../../src/core/constants"; import { createApiClientDouble, createEffectTestHarness, createInMemoryCacheAdapter, createPaymentAdapterDouble, } from "../helpers/effect-test-harness"; +import { describe, expect, it } from "../helpers/effect-vitest"; import { createTestSchema } from "../helpers/test-schema"; describe("VoidhashEffectClient", () => { @@ -32,10 +34,10 @@ describe("VoidhashEffectClient", () => { try { await harness.runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => manager.set("distinctId", "cached-before-init")) + Effect.flatMap(CacheManager.asEffect(), (manager) => manager.set("distinctId", "cached-before-init")) ); await harness.runtime.runPromise( - Effect.flatMap(CustomerAttributeManager, (manager) => + Effect.flatMap(CustomerAttributeManager.asEffect(), (manager) => manager.setCustomerAttributes("cached-before-init", { email: "before@voidhash.test", name: "Before", @@ -121,7 +123,9 @@ describe("VoidhashEffectClient", () => { cacheAdapter: cache.adapter, paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); try { const first = await harness.runtime.runPromise(initializedClient.getProducts()); @@ -157,7 +161,9 @@ describe("VoidhashEffectClient", () => { cacheAdapter: cache.adapter, paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); const events: string[] = []; const remove = harness.eventBus.on("feature-flags-fetched", () => { events.push("feature-flags-fetched"); @@ -165,7 +171,7 @@ describe("VoidhashEffectClient", () => { try { await harness.runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => manager.set("distinctId", "feature-user")) + Effect.flatMap(CacheManager.asEffect(), (manager) => manager.set("distinctId", "feature-user")) ); const first = await harness.runtime.runPromise( @@ -195,7 +201,9 @@ describe("VoidhashEffectClient", () => { cacheAdapter: cache.adapter, paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); try { await expect( @@ -220,7 +228,9 @@ describe("VoidhashEffectClient", () => { cacheAdapter: cache.adapter, paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); const monthlyProduct = new SubscriptionProduct( "monthly-id", "monthly_sub", @@ -261,7 +271,9 @@ describe("VoidhashEffectClient", () => { cacheAdapter: cache.adapter, paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); try { await harness.runtime.runPromise( @@ -285,7 +297,9 @@ describe("VoidhashEffectClient", () => { paymentAdapter: paymentDouble.paymentAdapter, readOnly: true, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); const transaction = new Transaction( "tx-id", "tx-id", @@ -321,7 +335,9 @@ describe("VoidhashEffectClient", () => { cacheAdapter: cache.adapter, paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); const transaction = new Transaction( "tx-id", "tx-id", @@ -368,7 +384,9 @@ describe("VoidhashEffectClient", () => { cacheAdapter: cache.adapter, paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); try { await harness.runtime.runPromise( @@ -393,7 +411,9 @@ describe("VoidhashEffectClient", () => { cacheAdapter: cache.adapter, paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); const transaction = new Transaction( "tx-id", "tx-id", @@ -417,27 +437,35 @@ describe("VoidhashEffectClient", () => { } }); - describe("sendAnalyticsEvents", () => { - const analyticsEvents: ReadonlyArray = [ - { - context: {}, - event_id: "evt_1", - event_name: "cta-button-clicked", - event_ts: "2026-01-01T00:00:00.000Z", - properties: { - button_name: "Get Started", - }, - session_id: "sess_1", + const analyticsEvents: ReadonlyArray = [ + { + context: {}, + event_id: "evt_1", + event_name: "cta-button-clicked", + event_ts: "2026-01-01T00:00:00.000Z", + properties: { + button_name: "Get Started", }, - ]; + session_id: "sess_1", + }, + ]; + + const acceptedAnalyticsResponse = () => + new Response(null, { + status: 202, + statusText: "Accepted", + }); + + const decodeRequestBody = (body: RequestInit["body"]) => { + if (typeof body === "string") return body; + if (body instanceof Uint8Array) return new TextDecoder().decode(body); + return String(body); + }; + describe("sendAnalyticsEvents", () => { it("sends analytics to derived i. subdomain by default", async () => { const originalFetch = global.fetch; - const fetchMock = jest.fn().mockResolvedValue({ - ok: true, - status: 202, - statusText: "Accepted", - }); + const fetchMock = vi.fn().mockResolvedValue(acceptedAnalyticsResponse()); global.fetch = fetchMock as unknown as typeof global.fetch; const schema = createTestSchema(); @@ -448,14 +476,17 @@ describe("VoidhashEffectClient", () => { apiClient: apiDouble.apiClient, baseUrl: "https://api.voidhash.test", cacheAdapter: cache.adapter, + fetch: fetchMock as unknown as typeof globalThis.fetch, paymentAdapter: paymentDouble.paymentAdapter, publishableKey: "pk_analytics", }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); try { await harness.runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => + Effect.flatMap(CacheManager.asEffect(), (manager) => manager.set("distinctId", "analytics-user") ) ); @@ -464,16 +495,16 @@ describe("VoidhashEffectClient", () => { ); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock.mock.calls[0]?.[0]).toBe( + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( "https://i.api.voidhash.test/batch" ); const request = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined; expect(request?.method).toBe("POST"); - expect(request?.headers).toEqual({ + expect(request?.headers).toEqual(expect.objectContaining({ "content-type": "application/json", - }); - expect(JSON.parse(String(request?.body))).toMatchObject({ + })); + expect(JSON.parse(decodeRequestBody(request?.body))).toMatchObject({ events: [ { distinct_id: "analytics-user", @@ -500,11 +531,7 @@ describe("VoidhashEffectClient", () => { it("uses ingestUrl override when provided", async () => { const originalFetch = global.fetch; - const fetchMock = jest.fn().mockResolvedValue({ - ok: true, - status: 202, - statusText: "Accepted", - }); + const fetchMock = vi.fn().mockResolvedValue(acceptedAnalyticsResponse()); global.fetch = fetchMock as unknown as typeof global.fetch; const schema = createTestSchema(); @@ -514,10 +541,13 @@ describe("VoidhashEffectClient", () => { const harness = createEffectTestHarness({ apiClient: apiDouble.apiClient, cacheAdapter: cache.adapter, + fetch: fetchMock as unknown as typeof globalThis.fetch, ingestUrl: "http://localhost:8083", paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); try { await harness.runtime.runPromise( @@ -525,7 +555,9 @@ describe("VoidhashEffectClient", () => { ); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:8083/batch"); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "http://localhost:8083/batch" + ); } finally { global.fetch = originalFetch; await harness.runtime.dispose(); @@ -534,15 +566,12 @@ describe("VoidhashEffectClient", () => { it("does not inline retry failed analytics delivery", async () => { const originalFetch = global.fetch; - const fetchMock = jest - .fn() - .mockResolvedValueOnce({ - headers: new Headers(), - json: async () => ({ error: "try again" }), - ok: false, + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ error: "try again" }), { + headers: { "content-type": "application/json" }, status: 503, statusText: "Service Unavailable", - }); + })); global.fetch = fetchMock as unknown as typeof global.fetch; const schema = createTestSchema(); @@ -552,15 +581,18 @@ describe("VoidhashEffectClient", () => { const harness = createEffectTestHarness({ apiClient: apiDouble.apiClient, cacheAdapter: cache.adapter, + fetch: fetchMock as unknown as typeof globalThis.fetch, ingestUrl: "http://localhost:8083", paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); try { await expect(harness.runtime.runPromise( initializedClient.sendAnalyticsEvents(analyticsEvents) - )).rejects.toThrow("Analytics ingest request failed: 503 Service Unavailable"); + )).rejects.toThrow("Analytics ingest request failed: 503"); expect(fetchMock).toHaveBeenCalledTimes(1); } finally { global.fetch = originalFetch; @@ -580,7 +612,9 @@ describe("VoidhashEffectClient", () => { cacheAdapter: cache.adapter, paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); try { harness.runtime.runSync( @@ -595,11 +629,7 @@ describe("VoidhashEffectClient", () => { it("flushes immediately when queue reaches 20 events", async () => { const originalFetch = global.fetch; - const fetchMock = jest.fn().mockResolvedValue({ - ok: true, - status: 202, - statusText: "Accepted", - }); + const fetchMock = vi.fn().mockResolvedValue(acceptedAnalyticsResponse()); global.fetch = fetchMock as unknown as typeof global.fetch; const schema = createTestSchema(); @@ -609,10 +639,13 @@ describe("VoidhashEffectClient", () => { const harness = createEffectTestHarness({ apiClient: apiDouble.apiClient, cacheAdapter: cache.adapter, + fetch: fetchMock as unknown as typeof globalThis.fetch, ingestUrl: "http://localhost:8083", paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); let flushTriggered = false; initializedClient.setAnalyticsFlushCallback(() => { flushTriggered = true; @@ -636,14 +669,10 @@ describe("VoidhashEffectClient", () => { }); it("flushes queued events when timer fires after 5 seconds", async () => { - jest.useFakeTimers(); + vi.useFakeTimers(); const originalFetch = global.fetch; - const fetchMock = jest.fn().mockResolvedValue({ - ok: true, - status: 202, - statusText: "Accepted", - }); + const fetchMock = vi.fn().mockResolvedValue(acceptedAnalyticsResponse()); global.fetch = fetchMock as unknown as typeof global.fetch; const schema = createTestSchema(); @@ -653,10 +682,13 @@ describe("VoidhashEffectClient", () => { const harness = createEffectTestHarness({ apiClient: apiDouble.apiClient, cacheAdapter: cache.adapter, + fetch: fetchMock as unknown as typeof globalThis.fetch, ingestUrl: "http://localhost:8083", paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); let flushTriggered = false; initializedClient.setAnalyticsFlushCallback(() => { flushTriggered = true; @@ -666,10 +698,10 @@ describe("VoidhashEffectClient", () => { harness.runtime.runSync(initializedClient.capture("screen-view")); expect(flushTriggered).toBe(false); - jest.advanceTimersByTime(5000); + vi.advanceTimersByTime(5000); expect(flushTriggered).toBe(true); } finally { - jest.useRealTimers(); + vi.useRealTimers(); global.fetch = originalFetch; await harness.runtime.dispose(); } @@ -677,19 +709,21 @@ describe("VoidhashEffectClient", () => { it("keeps batch in queue when flush is retryably rate limited", async () => { const originalFetch = global.fetch; - const fetchMock = jest.fn().mockResolvedValueOnce({ - headers: new Headers({ - "retry-after": "2", - }), - json: async () => ({ + const fetchMock = vi.fn().mockResolvedValueOnce(new Response( + JSON.stringify({ code: "rate_limited", error: "request rate limit exceeded", retry_after_ms: 2000, }), - ok: false, - status: 429, - statusText: "Too Many Requests", - }); + { + headers: { + "content-type": "application/json", + "retry-after": "2", + }, + status: 429, + statusText: "Too Many Requests", + } + )); global.fetch = fetchMock as unknown as typeof global.fetch; const schema = createTestSchema(); @@ -699,10 +733,13 @@ describe("VoidhashEffectClient", () => { const harness = createEffectTestHarness({ apiClient: apiDouble.apiClient, cacheAdapter: cache.adapter, + fetch: fetchMock as unknown as typeof globalThis.fetch, ingestUrl: "http://localhost:8083", paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); try { harness.runtime.runSync(initializedClient.capture("event-1")); @@ -718,35 +755,37 @@ describe("VoidhashEffectClient", () => { }); it("retries a rate-limited batch after Retry-After elapses", async () => { - jest.useFakeTimers(); + vi.useFakeTimers(); const originalFetch = global.fetch; - const fetchMock = jest - .fn() - .mockResolvedValueOnce({ - headers: new Headers({ - "retry-after": "2", - }), - json: async () => ({ + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response( + JSON.stringify({ code: "rate_limited", error: "request rate limit exceeded", retry_after_ms: 2000, }), - ok: false, - status: 429, - statusText: "Too Many Requests", - }) - .mockResolvedValueOnce({ - headers: new Headers(), - json: async () => ({ + { + headers: { + "content-type": "application/json", + "retry-after": "2", + }, + status: 429, + statusText: "Too Many Requests", + } + )) + .mockResolvedValueOnce(new Response( + JSON.stringify({ accepted: 1, rejected: 0, request_id: "req_after_backoff", }), - ok: true, - status: 202, - statusText: "Accepted", - }); + { + headers: { "content-type": "application/json" }, + status: 202, + statusText: "Accepted", + } + )); global.fetch = fetchMock as unknown as typeof global.fetch; const schema = createTestSchema(); @@ -756,10 +795,13 @@ describe("VoidhashEffectClient", () => { const harness = createEffectTestHarness({ apiClient: apiDouble.apiClient, cacheAdapter: cache.adapter, + fetch: fetchMock as unknown as typeof globalThis.fetch, ingestUrl: "http://localhost:8083", paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); try { harness.runtime.runSync(initializedClient.capture("event-1")); @@ -770,13 +812,13 @@ describe("VoidhashEffectClient", () => { await harness.runtime.runPromise(initializedClient.flush()); expect(fetchMock).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(2000); + vi.advanceTimersByTime(2000); await harness.runtime.runPromise(initializedClient.flush()); expect(fetchMock).toHaveBeenCalledTimes(2); expect(initializedClient.getAnalyticsQueueLength()).toBe(0); } finally { - jest.useRealTimers(); + vi.useRealTimers(); global.fetch = originalFetch; await harness.runtime.dispose(); } @@ -794,7 +836,9 @@ describe("VoidhashEffectClient", () => { cacheAdapter: cache.adapter, paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); try { await harness.runtime.runPromise( @@ -818,12 +862,14 @@ describe("VoidhashEffectClient", () => { cacheAdapter: cache.adapter, paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); try { // Store a different app release in cache await harness.runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => + Effect.flatMap(CacheManager.asEffect(), (manager) => manager.set("voidhash:analytics:last-seen-app-release", { appBuild: "0", appVersion: "0.0.1", @@ -854,7 +900,9 @@ describe("VoidhashEffectClient", () => { cacheAdapter: cache.adapter, paymentAdapter: paymentDouble.paymentAdapter, }); - const initializedClient = VoidhashEffectClient.makeInitializedClient({ schema }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); const capturedEvents: string[] = []; try { diff --git a/libraries/react-native/src/__tests__/core/customer-info-manager.test.ts b/libraries/react-native/tests/core/customer-info-manager.test.ts similarity index 83% rename from libraries/react-native/src/__tests__/core/customer-info-manager.test.ts rename to libraries/react-native/tests/core/customer-info-manager.test.ts index db0ea3713..fdaea8b58 100644 --- a/libraries/react-native/src/__tests__/core/customer-info-manager.test.ts +++ b/libraries/react-native/tests/core/customer-info-manager.test.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; -import { CacheManager } from "../../core/caching/cache-manager"; -import { CustomerInfoManager } from "../../core/identity/customer-info-manager"; +import { CacheManager } from "../../src/core/caching/cache-manager"; +import { CustomerInfoManager } from "../../src/core/identity/customer-info-manager"; import { createApiClientDouble, createEffectTestHarness, @@ -9,6 +9,7 @@ import { createPaymentAdapterDouble, createSdkCustomer, } from "../helpers/effect-test-harness"; +import { describe, expect, it } from "../helpers/effect-vitest"; const wait = (ms: number) => new Promise((resolve) => { @@ -29,13 +30,13 @@ describe("CustomerInfoManager", () => { try { await harness.runtime.runPromise( - Effect.flatMap(CustomerInfoManager, (manager) => + Effect.flatMap(CustomerInfoManager.asEffect(), (manager) => manager.cache("cached-user", customer) ) ); const result = await harness.runtime.runPromise( - Effect.flatMap(CustomerInfoManager, (manager) => + Effect.flatMap(CustomerInfoManager.asEffect(), (manager) => manager.getCustomer("cached-user", "cache") ) ); @@ -66,16 +67,20 @@ describe("CustomerInfoManager", () => { try { const result = await harness.runtime.runPromise( - Effect.flatMap(CustomerInfoManager, (manager) => + Effect.flatMap(CustomerInfoManager.asEffect(), (manager) => manager.getCustomer("fetched-user", "fetch") ) ); const cached = await harness.runtime.runPromise( - Effect.flatMap(CustomerInfoManager, (manager) => + Effect.flatMap(CustomerInfoManager.asEffect(), (manager) => manager.getCustomerFromCache("fetched-user") ) ); + if (result === null) { + throw new Error("Expected fetched customer"); + } + expect(result.distinctId).toBe("fetched-user"); expect(apiDouble.state.getCustomerCalls).toHaveLength(1); expect(cached?.value.distinctId).toBe("fetched-user"); @@ -99,11 +104,11 @@ describe("CustomerInfoManager", () => { try { await harness.runtime.runPromise( - Effect.flatMap(CustomerInfoManager, (manager) => manager.cache("fresh-user", customer)) + Effect.flatMap(CustomerInfoManager.asEffect(), (manager) => manager.cache("fresh-user", customer)) ); const result = await harness.runtime.runPromise( - Effect.flatMap(CustomerInfoManager, (manager) => + Effect.flatMap(CustomerInfoManager.asEffect(), (manager) => manager.getCustomer("fresh-user", "fetch-while-stale") ) ); @@ -131,7 +136,7 @@ describe("CustomerInfoManager", () => { try { await harness.runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => + Effect.flatMap(CacheManager.asEffect(), (manager) => manager.set("customer:stale-user", staleCustomer, { staleTime: 1, ttl: 1000, @@ -141,7 +146,7 @@ describe("CustomerInfoManager", () => { await wait(5); const result = await harness.runtime.runPromise( - Effect.flatMap(CustomerInfoManager, (manager) => + Effect.flatMap(CustomerInfoManager.asEffect(), (manager) => manager.getCustomer("stale-user", "fetch-while-stale") ) ); diff --git a/libraries/react-native/src/__tests__/core/identity-manager.test.ts b/libraries/react-native/tests/core/identity-manager.test.ts similarity index 78% rename from libraries/react-native/src/__tests__/core/identity-manager.test.ts rename to libraries/react-native/tests/core/identity-manager.test.ts index 5d16ff174..dae9f8462 100644 --- a/libraries/react-native/src/__tests__/core/identity-manager.test.ts +++ b/libraries/react-native/tests/core/identity-manager.test.ts @@ -1,16 +1,17 @@ import { Effect } from "effect"; -import { ANONYMOUS_DISTINCT_ID_PREFIX } from "../../constants"; -import { CacheManager } from "../../core/caching/cache-manager"; -import { CustomerAttributeManager } from "../../core/identity/customer-attribute-manager"; -import { CustomerInfoManager } from "../../core/identity/customer-info-manager"; -import { IdentityManager } from "../../core/identity/identity-manager"; +import { ANONYMOUS_DISTINCT_ID_PREFIX } from "../../src/constants"; +import { CacheManager } from "../../src/core/caching/cache-manager"; +import { CustomerAttributeManager } from "../../src/core/identity/customer-attribute-manager"; +import { CustomerInfoManager } from "../../src/core/identity/customer-info-manager"; +import { IdentityManager } from "../../src/core/identity/identity-manager"; import { createApiClientDouble, createEffectTestHarness, createInMemoryCacheAdapter, createPaymentAdapterDouble, } from "../helpers/effect-test-harness"; +import { describe, expect, it } from "../helpers/effect-vitest"; describe("IdentityManager", () => { it("uses the cached distinct id when present", async () => { @@ -25,11 +26,11 @@ describe("IdentityManager", () => { try { await harness.runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => manager.set("distinctId", "cached-user")) + Effect.flatMap(CacheManager.asEffect(), (manager) => manager.set("distinctId", "cached-user")) ); const distinctId = await harness.runtime.runPromise( - Effect.flatMap(IdentityManager, (manager) => manager.getDistinctId()) + Effect.flatMap(IdentityManager.asEffect(), (manager) => manager.getDistinctId()) ); expect(distinctId).toBe("cached-user"); @@ -50,10 +51,10 @@ describe("IdentityManager", () => { try { const distinctId = await harness.runtime.runPromise( - Effect.flatMap(IdentityManager, (manager) => manager.getDistinctId()) + Effect.flatMap(IdentityManager.asEffect(), (manager) => manager.getDistinctId()) ); const cached = await harness.runtime.runPromise( - Effect.flatMap(IdentityManager, (manager) => manager.getDistinctIdFromCache()) + Effect.flatMap(IdentityManager.asEffect(), (manager) => manager.getDistinctIdFromCache()) ); expect(distinctId.startsWith(ANONYMOUS_DISTINCT_ID_PREFIX)).toBe(true); @@ -85,7 +86,7 @@ describe("IdentityManager", () => { try { await harness.runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => + Effect.flatMap(CacheManager.asEffect(), (manager) => Effect.all([ manager.set("distinctId", "old-user"), manager.set("customer-attributes:old-user", { @@ -97,7 +98,7 @@ describe("IdentityManager", () => { ); await harness.runtime.runPromise( - Effect.flatMap(IdentityManager, (manager) => + Effect.flatMap(IdentityManager.asEffect(), (manager) => manager.identify("new-user", { email: "new@voidhash.test", name: "New User", @@ -106,10 +107,10 @@ describe("IdentityManager", () => { ); const cachedDistinctId = await harness.runtime.runPromise( - Effect.flatMap(IdentityManager, (manager) => manager.getDistinctIdFromCache()) + Effect.flatMap(IdentityManager.asEffect(), (manager) => manager.getDistinctIdFromCache()) ); const cachedCustomer = await harness.runtime.runPromise( - Effect.flatMap(CustomerInfoManager, (manager) => + Effect.flatMap(CustomerInfoManager.asEffect(), (manager) => manager.getCustomerFromCache("new-user") ) ); @@ -155,7 +156,7 @@ describe("IdentityManager", () => { try { await harness.runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => + Effect.flatMap(CacheManager.asEffect(), (manager) => Effect.all([ manager.set("distinctId", "signed-in-user"), manager.set("customer:some-user", { id: "some-user" }), @@ -163,7 +164,7 @@ describe("IdentityManager", () => { ) ); await harness.runtime.runPromise( - Effect.flatMap(CustomerAttributeManager, (manager) => + Effect.flatMap(CustomerAttributeManager.asEffect(), (manager) => manager.setCustomerAttributes("signed-in-user", { email: "signed@voidhash.test", name: "Signed User", @@ -172,14 +173,14 @@ describe("IdentityManager", () => { ); await harness.runtime.runPromise( - Effect.flatMap(IdentityManager, (manager) => manager.reset()) + Effect.flatMap(IdentityManager.asEffect(), (manager) => manager.reset()) ); const distinctIdFromCache = await harness.runtime.runPromise( - Effect.flatMap(IdentityManager, (manager) => manager.getDistinctIdFromCache()) + Effect.flatMap(IdentityManager.asEffect(), (manager) => manager.getDistinctIdFromCache()) ); const cacheKeys = await harness.runtime.runPromise( - Effect.flatMap(CacheManager, (manager) => manager.getCacheKeys()) + Effect.flatMap(CacheManager.asEffect(), (manager) => manager.getCacheKeys()) ); expect(apiDouble.state.syncCustomerAttributesCalls).toHaveLength(1); diff --git a/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts b/libraries/react-native/tests/helpers/effect-test-harness.ts similarity index 68% rename from libraries/react-native/src/__tests__/helpers/effect-test-harness.ts rename to libraries/react-native/tests/helpers/effect-test-harness.ts index 02ce3fc59..e955cd6dc 100644 --- a/libraries/react-native/src/__tests__/helpers/effect-test-harness.ts +++ b/libraries/react-native/tests/helpers/effect-test-harness.ts @@ -1,19 +1,28 @@ import type { SdkPerson as SdkCustomer } from "@voidhash/generated-clients"; import { Effect, Layer, ManagedRuntime, pipe } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; -import { CacheAdapter } from "../../core/caching/cache-adapter"; -import { CacheManager } from "../../core/caching/cache-manager"; -import { Product, type SubscriptionProduct } from "../../core/entities/product"; -import { Transaction } from "../../core/entities/transaction"; -import { EventBus, EventBusProvider } from "../../core/event-bus"; -import { CustomerAttributeManager } from "../../core/identity/customer-attribute-manager"; -import { CustomerInfoManager } from "../../core/identity/customer-info-manager"; -import { IdentityManager } from "../../core/identity/identity-manager"; -import { ApiClient } from "../../core/networking/api-client"; -import { PaymentAdapter } from "../../core/payment-adapters/payment-adapter"; -import type { PlatformInfo } from "../../core/platform/platform-provider"; -import { PlatformProvider } from "../../core/platform/platform-provider"; -import { SdkConfiguration } from "../../core/sdk-configuration"; +import { CacheAdapter } from "../../src/core/caching/cache-adapter"; +import { CacheManager } from "../../src/core/caching/cache-manager"; +import { Product, type SubscriptionProduct } from "../../src/core/entities/product"; +import { Transaction } from "../../src/core/entities/transaction"; +import { EventBus, EventBusProvider } from "../../src/core/event-bus"; +import { CustomerAttributeManager } from "../../src/core/identity/customer-attribute-manager"; +import { CustomerInfoManager } from "../../src/core/identity/customer-info-manager"; +import { IdentityManager } from "../../src/core/identity/identity-manager"; +import { AnalyticsService } from "../../src/core/analytics/service"; +import { FeatureFlagService } from "../../src/core/feature-flags/feature-flag-service"; +import { LifecycleAdapter } from "../../src/core/lifecycle/lifecycle-adapter"; +import { LifecycleService } from "../../src/core/lifecycle/lifecycle-service"; +import { ApiClient } from "../../src/core/networking/api-client"; +import { PaywallService } from "../../src/core/paywalls/paywall-service"; +import { PaymentAdapter } from "../../src/core/payment-adapters/payment-adapter"; +import type { PlatformInfo } from "../../src/core/platform/platform-provider"; +import { PlatformProvider } from "../../src/core/platform/platform-provider"; +import { ProductService } from "../../src/core/products/product-service"; +import { SdkConfiguration } from "../../src/core/sdk-configuration"; +import { TransactionService } from "../../src/core/transactions/transaction-service"; +import { createTestSchema } from "./test-schema"; type FeatureFlagsResult = { readonly flags: ReadonlyArray<{ @@ -64,6 +73,7 @@ export function createApiClientDouble(options: ApiClientDoubleOptions = {}) { const apiClient = { sdk: { + getSchema: () => Effect.succeed(createTestSchema()), evaluateFeatureFlags: (request: ApiSdkCall) => { state.evaluateFeatureFlagsCalls.push(request); return Effect.succeed( @@ -93,6 +103,7 @@ export function createApiClientDouble(options: ApiClientDoubleOptions = {}) { options.identifyResult ?? createSdkCustomer(distinctId) ); }, + resolvePaywall: () => Effect.succeed(null), syncCustomerAttributes: (request: ApiSdkCall) => { state.syncCustomerAttributesCalls.push(request); return Effect.void; @@ -108,7 +119,7 @@ export function createApiClientDouble(options: ApiClientDoubleOptions = {}) { }; return { - apiClient: apiClient as unknown as ApiClient, + apiClient: apiClient as unknown, state, }; } @@ -184,7 +195,7 @@ export function createPaymentAdapterDouble( }; return { - paymentAdapter: paymentAdapter as unknown as PaymentAdapter, + paymentAdapter: paymentAdapter as unknown, state, }; } @@ -208,14 +219,29 @@ export function createInMemoryCacheAdapter() { }; } +/** + * No-op `LifecycleAdapter` double for tests. Returns `null` so callers know + * lifecycle wiring is unavailable, mirroring the production fallback when + * `react-native` isn't installed. + */ +export function createLifecycleAdapterDouble() { + return { + adapter: { + subscribe: () => Effect.succeed(null), + }, + }; +} + export interface EffectTestHarnessOptions { - apiClient: ApiClient; + apiClient: unknown; baseUrl?: string; cacheAdapter: ReturnType["adapter"]; debug?: boolean; eventBus?: EventBus; + fetch?: typeof globalThis.fetch; ingestUrl?: string; - paymentAdapter: PaymentAdapter; + lifecycleAdapter?: ReturnType; + paymentAdapter: unknown; platform?: Partial; publishableKey?: string; readOnly?: boolean; @@ -237,14 +263,31 @@ const defaultPlatformInfo: PlatformInfo = { export function createEffectTestHarness(options: EffectTestHarnessOptions) { const eventBus = options.eventBus ?? new EventBus(); - const layer = pipe( + const lifecycle = options.lifecycleAdapter ?? createLifecycleAdapterDouble(); + + const baseLayer = pipe( CustomerAttributeManager.Default, + Layer.provideMerge(ProductService.layer), + Layer.provideMerge(FeatureFlagService.layer), + Layer.provideMerge(PaywallService.layer), + Layer.provideMerge(TransactionService.layer), + Layer.provideMerge(AnalyticsService.layer), + Layer.provideMerge(LifecycleService.layer), + Layer.provideMerge(Layer.succeed(LifecycleAdapter, lifecycle.adapter)), Layer.provideMerge(CustomerInfoManager.Default), Layer.provideMerge(IdentityManager.Default), Layer.provideMerge(CacheManager.Default), Layer.provideMerge(Layer.succeed(CacheAdapter, options.cacheAdapter)), - Layer.provideMerge(Layer.succeed(ApiClient, options.apiClient)), - Layer.provideMerge(Layer.succeed(PaymentAdapter, options.paymentAdapter)), + Layer.provideMerge( + Layer.succeed(ApiClient, options.apiClient as typeof ApiClient.Service) + ), + Layer.provideMerge(FetchHttpClient.layer), + Layer.provideMerge( + Layer.succeed( + PaymentAdapter, + options.paymentAdapter as typeof PaymentAdapter.Service + ) + ), Layer.provideMerge(Layer.succeed(EventBusProvider, eventBus)), Layer.provideMerge( Layer.succeed(PlatformProvider, { @@ -262,6 +305,12 @@ export function createEffectTestHarness(options: EffectTestHarnessOptions) { }) ) ); + const layer = options.fetch + ? pipe( + baseLayer, + Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, options.fetch)) + ) + : baseLayer; return { eventBus, diff --git a/libraries/react-native/tests/helpers/effect-vitest.ts b/libraries/react-native/tests/helpers/effect-vitest.ts new file mode 100644 index 000000000..2aeeb1562 --- /dev/null +++ b/libraries/react-native/tests/helpers/effect-vitest.ts @@ -0,0 +1,9 @@ +import { makeMethods } from "@effect/vitest"; +import { it as vitestIt } from "vitest"; + +export { beforeEach, describe, expect } from "vitest"; +const makeVitestMethods = makeMethods as unknown as ( + it: typeof vitestIt +) => typeof vitestIt; + +export const it = makeVitestMethods(vitestIt); diff --git a/libraries/react-native/src/__tests__/helpers/test-schema.ts b/libraries/react-native/tests/helpers/test-schema.ts similarity index 94% rename from libraries/react-native/src/__tests__/helpers/test-schema.ts rename to libraries/react-native/tests/helpers/test-schema.ts index 0f2b7aa7f..b441e7da7 100644 --- a/libraries/react-native/src/__tests__/helpers/test-schema.ts +++ b/libraries/react-native/tests/helpers/test-schema.ts @@ -1,4 +1,4 @@ -import type { RuntimeSchema } from "../../core/schema/runtime"; +import type { RuntimeSchema } from "../../src/core/schema/runtime"; /** * Build a deterministic in-memory schema for tests. Mirrors the shape the diff --git a/libraries/react-native/src/__tests__/internal/paywall-bridge-parser.test.ts b/libraries/react-native/tests/internal/paywall-bridge-parser.test.ts similarity index 90% rename from libraries/react-native/src/__tests__/internal/paywall-bridge-parser.test.ts rename to libraries/react-native/tests/internal/paywall-bridge-parser.test.ts index 77fceb5d4..23794fa63 100644 --- a/libraries/react-native/src/__tests__/internal/paywall-bridge-parser.test.ts +++ b/libraries/react-native/tests/internal/paywall-bridge-parser.test.ts @@ -1,7 +1,8 @@ +import { describe, expect, it } from "../helpers/effect-vitest"; import { PaywallBridgeParseError, parsePaywallBridgeEnvelope, -} from "../../internal/paywall-bridge/parser"; +} from "../../src/internal/paywall-bridge/parser"; describe("parsePaywallBridgeEnvelope", () => { it("parses a valid purchase payload", () => { diff --git a/libraries/react-native/src/__tests__/internal/webview-utils.test.ts b/libraries/react-native/tests/internal/webview-utils.test.ts similarity index 86% rename from libraries/react-native/src/__tests__/internal/webview-utils.test.ts rename to libraries/react-native/tests/internal/webview-utils.test.ts index c04c91e77..0bdefcd06 100644 --- a/libraries/react-native/src/__tests__/internal/webview-utils.test.ts +++ b/libraries/react-native/tests/internal/webview-utils.test.ts @@ -1,7 +1,8 @@ +import { describe, expect, it } from "../helpers/effect-vitest"; import { normalizeSource, wrapNitroCallback, -} from "../../internal/webview/utils"; +} from "../../src/internal/webview/utils"; describe("webview utils", () => { it("wraps callbacks in Nitro callback object", () => { diff --git a/libraries/react-native/src/__tests__/internal/whitelist.test.ts b/libraries/react-native/tests/internal/whitelist.test.ts similarity index 81% rename from libraries/react-native/src/__tests__/internal/whitelist.test.ts rename to libraries/react-native/tests/internal/whitelist.test.ts index f20cf07ea..67c766fb7 100644 --- a/libraries/react-native/src/__tests__/internal/whitelist.test.ts +++ b/libraries/react-native/tests/internal/whitelist.test.ts @@ -1,4 +1,5 @@ -import { compileWhitelist, passesWhitelist } from "../../internal/webview/whitelist"; +import { compileWhitelist, passesWhitelist } from "../../src/internal/webview/whitelist"; +import { describe, expect, it } from "../helpers/effect-vitest"; describe("webview whitelist", () => { it("allows whitelisted https origin", () => { diff --git a/libraries/react-native/src/__tests__/react/use-paywall-by-location.test.tsx b/libraries/react-native/tests/react/use-paywall-by-location.test.tsx similarity index 86% rename from libraries/react-native/src/__tests__/react/use-paywall-by-location.test.tsx rename to libraries/react-native/tests/react/use-paywall-by-location.test.tsx index bd0aca4b3..73de0d020 100644 --- a/libraries/react-native/src/__tests__/react/use-paywall-by-location.test.tsx +++ b/libraries/react-native/tests/react/use-paywall-by-location.test.tsx @@ -1,47 +1,49 @@ -import type { VoidhashClient } from "../../client"; +import { type Mocked, vi } from "vitest"; +import type { VoidhashClient } from "../../src/client"; import { __internal_handlePaywallBridgeEventForTests, __internal_resetPaywallByLocationCachesForTests, -} from "../../react/hooks/use-paywall-by-location"; +} from "../../src/react/hooks/use-paywall-by-location"; +import { beforeEach, describe, expect, it } from "../helpers/effect-vitest"; -jest.mock("react-native", () => ({ +vi.mock("react-native", () => ({ AppState: { - addEventListener: jest.fn(), + addEventListener: vi.fn(), }, Linking: { - openURL: jest.fn().mockResolvedValue(undefined), + openURL: vi.fn().mockResolvedValue(undefined), }, })); -jest.mock("../../nitro", () => ({ +vi.mock("../../src/nitro", () => ({ PaywallPresenter: undefined, })); function createClientMock() { return { - getProducts: jest.fn(), - purchase: jest.fn(), - restorePurchases: jest.fn(), - } as unknown as jest.Mocked; + getProducts: vi.fn(), + purchase: vi.fn(), + restorePurchases: vi.fn(), + } as unknown as Mocked; } function createPresenterMock() { return { - dismiss: jest.fn().mockResolvedValue(undefined), - postMessage: jest.fn(), + dismiss: vi.fn().mockResolvedValue(undefined), + postMessage: vi.fn(), }; } describe("usePaywallByLocation bridge coordinator", () => { beforeEach(() => { __internal_resetPaywallByLocationCachesForTests(); - jest.clearAllMocks(); + vi.clearAllMocks(); }); it("handles purchase bridge action and dismisses on success", async () => { const client = createClientMock(); const presenter = createPresenterMock(); - const openExternalUrl = jest.fn().mockResolvedValue(undefined); + const openExternalUrl = vi.fn().mockResolvedValue(undefined); client.getProducts.mockResolvedValue({ monthly: { @@ -78,8 +80,8 @@ describe("usePaywallByLocation bridge coordinator", () => { it("invokes onPurchase callback on purchase success", async () => { const client = createClientMock(); const presenter = createPresenterMock(); - const onPurchase = jest.fn(); - const onError = jest.fn(); + const onPurchase = vi.fn(); + const onError = vi.fn(); client.getProducts.mockResolvedValue({ monthly: { @@ -92,7 +94,7 @@ describe("usePaywallByLocation bridge coordinator", () => { await __internal_handlePaywallBridgeEventForTests({ client, locationKey: "home", - openExternalUrl: jest.fn().mockResolvedValue(undefined), + openExternalUrl: vi.fn().mockResolvedValue(undefined), paywallOptions: { onError, onPurchase, @@ -130,7 +132,7 @@ describe("usePaywallByLocation bridge coordinator", () => { await __internal_handlePaywallBridgeEventForTests({ client, locationKey: "home", - openExternalUrl: jest.fn().mockResolvedValue(undefined), + openExternalUrl: vi.fn().mockResolvedValue(undefined), presenter, rawBridgeEvent: JSON.stringify({ payload: { @@ -150,7 +152,7 @@ describe("usePaywallByLocation bridge coordinator", () => { it("invokes onError callback when purchase product cannot be resolved", async () => { const client = createClientMock(); const presenter = createPresenterMock(); - const onError = jest.fn(); + const onError = vi.fn(); client.getProducts.mockResolvedValue({ monthly: { @@ -162,7 +164,7 @@ describe("usePaywallByLocation bridge coordinator", () => { await __internal_handlePaywallBridgeEventForTests({ client, locationKey: "home", - openExternalUrl: jest.fn().mockResolvedValue(undefined), + openExternalUrl: vi.fn().mockResolvedValue(undefined), paywallOptions: { onError, }, @@ -195,7 +197,7 @@ describe("usePaywallByLocation bridge coordinator", () => { await __internal_handlePaywallBridgeEventForTests({ client, locationKey: "home", - openExternalUrl: jest.fn().mockResolvedValue(undefined), + openExternalUrl: vi.fn().mockResolvedValue(undefined), presenter, rawBridgeEvent: JSON.stringify({ requestId: "req_restore", @@ -212,15 +214,15 @@ describe("usePaywallByLocation bridge coordinator", () => { it("invokes onRestore callback on restore success", async () => { const client = createClientMock(); const presenter = createPresenterMock(); - const onRestore = jest.fn(); - const onError = jest.fn(); + const onRestore = vi.fn(); + const onError = vi.fn(); client.restorePurchases.mockResolvedValue(undefined as never); await __internal_handlePaywallBridgeEventForTests({ client, locationKey: "home", - openExternalUrl: jest.fn().mockResolvedValue(undefined), + openExternalUrl: vi.fn().mockResolvedValue(undefined), paywallOptions: { onError, onRestore, @@ -242,7 +244,7 @@ describe("usePaywallByLocation bridge coordinator", () => { it("invokes onError callback when purchase fails", async () => { const client = createClientMock(); const presenter = createPresenterMock(); - const onError = jest.fn(); + const onError = vi.fn(); client.getProducts.mockResolvedValue({ monthly: { @@ -255,7 +257,7 @@ describe("usePaywallByLocation bridge coordinator", () => { await __internal_handlePaywallBridgeEventForTests({ client, locationKey: "home", - openExternalUrl: jest.fn().mockResolvedValue(undefined), + openExternalUrl: vi.fn().mockResolvedValue(undefined), paywallOptions: { onError, }, @@ -280,14 +282,14 @@ describe("usePaywallByLocation bridge coordinator", () => { it("invokes onError callback when restore fails", async () => { const client = createClientMock(); const presenter = createPresenterMock(); - const onError = jest.fn(); + const onError = vi.fn(); client.restorePurchases.mockRejectedValue(new Error("restore failed") as never); await __internal_handlePaywallBridgeEventForTests({ client, locationKey: "home", - openExternalUrl: jest.fn().mockResolvedValue(undefined), + openExternalUrl: vi.fn().mockResolvedValue(undefined), paywallOptions: { onError, }, @@ -309,7 +311,7 @@ describe("usePaywallByLocation bridge coordinator", () => { it("handles openExternal and close actions", async () => { const client = createClientMock(); const presenter = createPresenterMock(); - const openExternalUrl = jest.fn().mockResolvedValue(undefined); + const openExternalUrl = vi.fn().mockResolvedValue(undefined); await __internal_handlePaywallBridgeEventForTests({ client, @@ -343,7 +345,7 @@ describe("usePaywallByLocation bridge coordinator", () => { it("returns deterministic busy error while another action is in-flight", async () => { const client = createClientMock(); const presenter = createPresenterMock(); - const onError = jest.fn(); + const onError = vi.fn(); client.getProducts.mockResolvedValue({ monthly: { @@ -363,7 +365,7 @@ describe("usePaywallByLocation bridge coordinator", () => { const firstRequest = __internal_handlePaywallBridgeEventForTests({ client, locationKey: "home", - openExternalUrl: jest.fn().mockResolvedValue(undefined), + openExternalUrl: vi.fn().mockResolvedValue(undefined), presenter, rawBridgeEvent: JSON.stringify({ payload: { @@ -378,7 +380,7 @@ describe("usePaywallByLocation bridge coordinator", () => { await __internal_handlePaywallBridgeEventForTests({ client, locationKey: "home", - openExternalUrl: jest.fn().mockResolvedValue(undefined), + openExternalUrl: vi.fn().mockResolvedValue(undefined), paywallOptions: { onError, }, diff --git a/libraries/react-native/tsconfig.json b/libraries/react-native/tsconfig.json index 101b9ac40..22dfd0821 100644 --- a/libraries/react-native/tsconfig.json +++ b/libraries/react-native/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "outDir": "./build" }, - "include": ["./src"], + "include": ["./src", "./tests", "./vitest.setup.ts", "./vitest.unit.mts"], "exclude": ["**/__tests__/*", "**/__rsc_tests__/*", "**/node_modules/**"] } diff --git a/libraries/react-native/vitest.setup.ts b/libraries/react-native/vitest.setup.ts new file mode 100644 index 000000000..d9418a9ec --- /dev/null +++ b/libraries/react-native/vitest.setup.ts @@ -0,0 +1,53 @@ +import { randomUUID } from "node:crypto"; +import { ServiceMap } from "effect"; +import { expect } from "vitest"; + +expect.addEqualityTesters([]); + +if (typeof globalThis.crypto !== "object") { + Object.defineProperty(globalThis, "crypto", { + value: {}, + writable: true, + }); +} + +if (typeof globalThis.crypto.randomUUID !== "function") { + Object.defineProperty(globalThis.crypto, "randomUUID", { + value: randomUUID, + writable: true, + }); +} + +/** + * Effect v4 (effect-smol) no longer treats `ServiceMap.Service` classes as + * effects directly - they're `Yieldable` and must be unwrapped via + * `Service.asEffect()` or `yield* Service` before use in operators like + * `Effect.flatMap`. The fiber runtime checks for an `evaluate` symbol on the + * value it's stepping through and throws "Not a valid effect" otherwise. + * + * This codebase's tests still use the v3-era `Effect.flatMap(Service, fn)` + * pattern. Rather than touch every call site, we patch `ServiceProto` so a + * `Service` evaluates as its underlying effect - restoring the old behavior in + * tests only. Production code uses the `yield* Service` syntax and is + * unaffected. + */ +{ + const evaluateKey = "~effect/Effect/evaluate"; + class DummyService extends ServiceMap.Service< + DummyService, + Record + >()("__voidhash/VitestPolyfillProbe__") {} + const ServiceProto = Object.getPrototypeOf(Object.getPrototypeOf(DummyService)); + if (ServiceProto && !(evaluateKey in ServiceProto)) { + Object.defineProperty(ServiceProto, evaluateKey, { + configurable: true, + writable: true, + value(this: { asEffect: () => Record }, fiber: unknown) { + const effect = this.asEffect(); + return ( + effect as Record unknown> + )[evaluateKey](fiber); + }, + }); + } +} diff --git a/libraries/react-native/vitest.unit.mts b/libraries/react-native/vitest.unit.mts new file mode 100644 index 000000000..fcd14fed8 --- /dev/null +++ b/libraries/react-native/vitest.unit.mts @@ -0,0 +1,16 @@ +import react from "@vitejs/plugin-react"; +import { reactNative } from "@srsholmes/vitest-react-native"; +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [tsconfigPaths(), react(), reactNative()], + test: { + environment: "node", + exclude: ["./node_modules/**", "./build/**", "./lib/**"], + globals: false, + include: ["./tests/**/*.test.{ts,tsx}"], + reporters: ["verbose"], + setupFiles: ["./vitest.setup.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fb45882a..4f999252c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ catalogs: '@types/react-dom': specifier: ^19.1.0 version: 19.2.3 + '@vitejs/plugin-react': + specifier: ^5.1.1 + version: 5.2.0 better-auth: specifier: ^1.4.18 version: 1.4.18 @@ -210,7 +213,7 @@ importers: version: 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) babel-preset-expo: specifier: ^54.0.10 - version: 54.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.33)(react-refresh@0.17.0) + version: 54.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.33)(react-refresh@0.18.0) eslint: specifier: ^9.25.1 version: 9.39.2(jiti@2.6.1) @@ -266,16 +269,22 @@ importers: effect: specifier: 4.0.0-beta.23 version: 4.0.0-beta.23 + voidhash-cli: + specifier: workspace:* + version: link:../../apps/cli devDependencies: - '@types/jest': - specifier: ^29.5.14 - version: 29.5.14 + '@effect/vitest': + specifier: 4.0.0-beta.23 + version: 4.0.0-beta.23(effect@4.0.0-beta.23)(vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jsdom@20.0.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) + '@srsholmes/vitest-react-native': + specifier: ^0.1.5 + version: 0.1.5(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jsdom@20.0.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) '@types/react': specifier: ~19.1.10 version: 19.1.17 '@vitejs/plugin-react': - specifier: ^4.6.0 - version: 4.7.0(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + specifier: 'catalog:' + version: 5.2.0(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@voidhash/shared': specifier: workspace:* version: link:../../packages/shared @@ -290,16 +299,7 @@ importers: version: 8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-module-scripts: specifier: ^5.0.8 - version: 5.0.8(@babel/core@7.29.0)(@babel/runtime@7.28.4)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.25.12)(eslint@9.39.2(jiti@2.6.1))(expo@54.0.33)(jest@29.7.0(@types/node@24.10.4))(prettier@3.8.1)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react-refresh@0.17.0)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) - jest: - specifier: ~29.7.0 - version: 29.7.0(@types/node@24.10.4) - jest-expo: - specifier: ~54.0.17 - version: 54.0.17(@babel/core@7.29.0)(expo@54.0.33)(jest@29.7.0(@types/node@24.10.4))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - jest-fixed-jsdom: - specifier: ^0.0.9 - version: 0.0.9(jest-environment-jsdom@29.7.0) + version: 5.0.8(@babel/core@7.29.0)(@babel/runtime@7.28.4)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.25.12)(eslint@9.39.2(jiti@2.6.1))(expo@54.0.33)(jest@29.7.0(@types/node@24.10.4))(prettier@3.8.1)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react-refresh@0.18.0)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) nitro-codegen: specifier: 0.26.4 version: 0.26.4(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -318,6 +318,12 @@ importers: ultracite: specifier: 5.0.39 version: 5.0.39(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-tsconfig-paths: + specifier: 'catalog:' + version: 5.1.4(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + vitest: + specifier: ^4.1.6 + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jsdom@20.0.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) libraries/web: dependencies: @@ -1358,6 +1364,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.17.19': resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==} engines: {node: '>=12'} @@ -1382,6 +1394,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.17.19': resolution: {integrity: sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==} engines: {node: '>=12'} @@ -1406,6 +1424,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.17.19': resolution: {integrity: sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==} engines: {node: '>=12'} @@ -1430,6 +1454,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.17.19': resolution: {integrity: sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==} engines: {node: '>=12'} @@ -1454,6 +1484,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.17.19': resolution: {integrity: sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==} engines: {node: '>=12'} @@ -1478,6 +1514,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.17.19': resolution: {integrity: sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==} engines: {node: '>=12'} @@ -1502,6 +1544,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.17.19': resolution: {integrity: sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==} engines: {node: '>=12'} @@ -1526,6 +1574,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.17.19': resolution: {integrity: sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==} engines: {node: '>=12'} @@ -1550,6 +1604,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.17.19': resolution: {integrity: sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==} engines: {node: '>=12'} @@ -1574,6 +1634,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.17.19': resolution: {integrity: sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==} engines: {node: '>=12'} @@ -1598,6 +1664,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.17.19': resolution: {integrity: sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==} engines: {node: '>=12'} @@ -1622,6 +1694,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.17.19': resolution: {integrity: sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==} engines: {node: '>=12'} @@ -1646,6 +1724,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.17.19': resolution: {integrity: sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==} engines: {node: '>=12'} @@ -1670,6 +1754,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.17.19': resolution: {integrity: sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==} engines: {node: '>=12'} @@ -1694,6 +1784,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.17.19': resolution: {integrity: sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==} engines: {node: '>=12'} @@ -1718,6 +1814,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.17.19': resolution: {integrity: sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==} engines: {node: '>=12'} @@ -1742,6 +1844,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -1754,6 +1862,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.17.19': resolution: {integrity: sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==} engines: {node: '>=12'} @@ -1778,6 +1892,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -1790,6 +1910,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.17.19': resolution: {integrity: sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==} engines: {node: '>=12'} @@ -1814,6 +1940,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -1826,6 +1958,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.17.19': resolution: {integrity: sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==} engines: {node: '>=12'} @@ -1850,6 +1988,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.17.19': resolution: {integrity: sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==} engines: {node: '>=12'} @@ -1874,6 +2018,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.17.19': resolution: {integrity: sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==} engines: {node: '>=12'} @@ -1898,6 +2048,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.17.19': resolution: {integrity: sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==} engines: {node: '>=12'} @@ -1922,6 +2078,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.0': resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2787,6 +2949,9 @@ packages: '@react-native/normalize-colors@0.81.5': resolution: {integrity: sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==} + '@react-native/polyfills@2.0.0': + resolution: {integrity: sha512-K0aGNn1TjalKj+65D7ycc1//H9roAQ51GJVk5ZJQFb2teECGmzd86bYDC0aYdbRf7gtovescq4Zt6FR0tgXiHQ==} + '@react-native/virtualized-lists@0.81.5': resolution: {integrity: sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw==} engines: {node: '>= 20.19.4'} @@ -2842,12 +3007,12 @@ packages: '@react-navigation/routers@7.5.3': resolution: {integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==} - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - '@rolldown/pluginutils@1.0.0-beta.40': resolution: {integrity: sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==} + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rollup/rollup-android-arm-eabi@4.54.0': resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==} cpu: [arm] @@ -2973,6 +3138,15 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@srsholmes/vitest-react-native@0.1.5': + resolution: {integrity: sha512-LqUsKKevWSrma/q2jMVOz/yFUZ4KRTkHSBMTCbsyyx5m8RSAdgPci6FPT9rbnnEJv8nUmcEZIUbYWqiJQpQYEg==} + engines: {node: '>=20'} + peerDependencies: + react: 19.1.0 + react-native: '>=0.72' + vite: '>=6' + vitest: '>=4' + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3295,6 +3469,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -3399,15 +3574,18 @@ packages: peerDependencies: '@urql/core': ^5.0.0 - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: @@ -3419,18 +3597,41 @@ packages: vite: optional: true + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + '@vitest/runner@3.2.4': resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + '@vitest/ui@3.2.4': resolution: {integrity: sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==} peerDependencies: @@ -3439,9 +3640,13 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} @@ -3889,6 +4094,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -4463,6 +4672,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -4504,6 +4716,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -4999,6 +5216,11 @@ packages: flow-enums-runtime@0.0.6: resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + flow-remove-types@2.313.0: + resolution: {integrity: sha512-j+tfOktOZO4rLpBCFyZzoXh8hHfPFvDEB/x7N5rPnKWNHzjz1ifw3LzsQPI1cOKnho2weULMgdnmFqBFwOv6Cw==} + engines: {node: '>=4'} + hasBin: true + fontfaceobserver@2.3.0: resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} @@ -5185,12 +5407,18 @@ packages: hermes-estree@0.32.0: resolution: {integrity: sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==} + hermes-estree@0.36.0: + resolution: {integrity: sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==} + hermes-parser@0.29.1: resolution: {integrity: sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==} hermes-parser@0.32.0: resolution: {integrity: sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==} + hermes-parser@0.36.0: + resolution: {integrity: sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==} + hosted-git-info@7.0.2: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} @@ -5546,12 +5774,6 @@ packages: react-server-dom-webpack: optional: true - jest-fixed-jsdom@0.0.9: - resolution: {integrity: sha512-KPfqh2+sn5q2B+7LZktwDcwhCpOpUSue8a1I+BcixWLOQoEVyAjAGfH+IYZGoxZsziNojoHGRTC8xRbB1wDD4g==} - engines: {node: '>=18.0.0'} - peerDependencies: - jest-environment-jsdom: '>=28.0.0' - jest-get-type@29.6.3: resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6163,6 +6385,10 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-modules-regexp@1.0.0: + resolution: {integrity: sha512-JMaRS9L4wSRIR+6PTVEikTrq/lMGEZR43a48ETeilY0Q0iMwVnccMFrUM1k+tNzmYuIU0Vh710bCUqHX+/+ctQ==} + engines: {node: '>=0.10.0'} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -6228,6 +6454,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -6383,6 +6612,10 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pirates@3.0.2: + resolution: {integrity: sha512-c5CgUJq6H2k6MJz72Ak1F5sN9n9wlSlJyEnwvpm9/y3WB4E3pHBDT2c6PEiS1vyJvq2bUxUAIu0EGf8Cx4Ic7Q==} + engines: {node: '>= 4'} + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -6613,8 +6846,8 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} react-remove-scroll-bar@2.3.8: @@ -6694,6 +6927,9 @@ packages: regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -7065,6 +7301,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -7263,6 +7502,10 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} @@ -7569,6 +7812,7 @@ packages: uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-to-istanbul@9.3.0: @@ -7678,6 +7922,50 @@ packages: jsdom: optional: true + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vlq@0.2.3: + resolution: {integrity: sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==} + vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} @@ -8091,8 +8379,8 @@ snapshots: '@babel/helper-define-polyfill-provider@0.6.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 debug: 4.4.3 lodash.debounce: 4.0.8 resolve: 1.22.11 @@ -8102,8 +8390,8 @@ snapshots: '@babel/helper-define-polyfill-provider@0.6.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 debug: 4.4.3 lodash.debounce: 4.0.8 resolve: 1.22.11 @@ -8114,8 +8402,8 @@ snapshots: '@babel/helper-member-expression-to-functions@7.28.5': dependencies: - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -8171,7 +8459,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@babel/helper-plugin-utils@7.27.1': {} @@ -8182,7 +8470,7 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-wrap-function': 7.28.3 - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -8191,7 +8479,7 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-wrap-function': 7.28.3 - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -8200,7 +8488,7 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -8209,7 +8497,7 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -8234,8 +8522,8 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -8248,7 +8536,7 @@ snapshots: '@babel/helper-wrap-function@7.28.3': dependencies: '@babel/template': 7.28.6 - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -8348,42 +8636,42 @@ snapshots: '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-decorators@7.27.1(@babel/core@7.28.5)': dependencies: @@ -8443,22 +8731,22 @@ snapshots: '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': dependencies: @@ -8484,12 +8772,12 @@ snapshots: '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.5)': dependencies: @@ -8504,32 +8792,32 @@ snapshots: '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.5)': dependencies: @@ -8544,32 +8832,32 @@ snapshots: '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.28.5)': dependencies: @@ -9099,22 +9387,22 @@ snapshots: '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.28.5)': dependencies: @@ -9625,6 +9913,11 @@ snapshots: effect: 4.0.0-beta.23 vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@effect/vitest@4.0.0-beta.23(effect@4.0.0-beta.23)(vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jsdom@20.0.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': + dependencies: + effect: 4.0.0-beta.23 + vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jsdom@20.0.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@emnapi/core@1.7.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -9662,6 +9955,9 @@ snapshots: '@esbuild/aix-ppc64@0.27.2': optional: true + '@esbuild/aix-ppc64@0.27.7': + optional: true + '@esbuild/android-arm64@0.17.19': optional: true @@ -9674,6 +9970,9 @@ snapshots: '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm64@0.27.7': + optional: true + '@esbuild/android-arm@0.17.19': optional: true @@ -9686,6 +9985,9 @@ snapshots: '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-arm@0.27.7': + optional: true + '@esbuild/android-x64@0.17.19': optional: true @@ -9698,6 +10000,9 @@ snapshots: '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/android-x64@0.27.7': + optional: true + '@esbuild/darwin-arm64@0.17.19': optional: true @@ -9710,6 +10015,9 @@ snapshots: '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.27.7': + optional: true + '@esbuild/darwin-x64@0.17.19': optional: true @@ -9722,6 +10030,9 @@ snapshots: '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/darwin-x64@0.27.7': + optional: true + '@esbuild/freebsd-arm64@0.17.19': optional: true @@ -9734,6 +10045,9 @@ snapshots: '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.27.7': + optional: true + '@esbuild/freebsd-x64@0.17.19': optional: true @@ -9746,6 +10060,9 @@ snapshots: '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.27.7': + optional: true + '@esbuild/linux-arm64@0.17.19': optional: true @@ -9758,6 +10075,9 @@ snapshots: '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm64@0.27.7': + optional: true + '@esbuild/linux-arm@0.17.19': optional: true @@ -9770,6 +10090,9 @@ snapshots: '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-arm@0.27.7': + optional: true + '@esbuild/linux-ia32@0.17.19': optional: true @@ -9782,6 +10105,9 @@ snapshots: '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-ia32@0.27.7': + optional: true + '@esbuild/linux-loong64@0.17.19': optional: true @@ -9794,6 +10120,9 @@ snapshots: '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-loong64@0.27.7': + optional: true + '@esbuild/linux-mips64el@0.17.19': optional: true @@ -9806,6 +10135,9 @@ snapshots: '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-mips64el@0.27.7': + optional: true + '@esbuild/linux-ppc64@0.17.19': optional: true @@ -9818,6 +10150,9 @@ snapshots: '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-ppc64@0.27.7': + optional: true + '@esbuild/linux-riscv64@0.17.19': optional: true @@ -9830,6 +10165,9 @@ snapshots: '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.27.7': + optional: true + '@esbuild/linux-s390x@0.17.19': optional: true @@ -9842,6 +10180,9 @@ snapshots: '@esbuild/linux-s390x@0.27.2': optional: true + '@esbuild/linux-s390x@0.27.7': + optional: true + '@esbuild/linux-x64@0.17.19': optional: true @@ -9854,12 +10195,18 @@ snapshots: '@esbuild/linux-x64@0.27.2': optional: true + '@esbuild/linux-x64@0.27.7': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.27.2': optional: true + '@esbuild/netbsd-arm64@0.27.7': + optional: true + '@esbuild/netbsd-x64@0.17.19': optional: true @@ -9872,12 +10219,18 @@ snapshots: '@esbuild/netbsd-x64@0.27.2': optional: true + '@esbuild/netbsd-x64@0.27.7': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.27.2': optional: true + '@esbuild/openbsd-arm64@0.27.7': + optional: true + '@esbuild/openbsd-x64@0.17.19': optional: true @@ -9890,12 +10243,18 @@ snapshots: '@esbuild/openbsd-x64@0.27.2': optional: true + '@esbuild/openbsd-x64@0.27.7': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.27.2': optional: true + '@esbuild/openharmony-arm64@0.27.7': + optional: true + '@esbuild/sunos-x64@0.17.19': optional: true @@ -9908,6 +10267,9 @@ snapshots: '@esbuild/sunos-x64@0.27.2': optional: true + '@esbuild/sunos-x64@0.27.7': + optional: true + '@esbuild/win32-arm64@0.17.19': optional: true @@ -9920,6 +10282,9 @@ snapshots: '@esbuild/win32-arm64@0.27.2': optional: true + '@esbuild/win32-arm64@0.27.7': + optional: true + '@esbuild/win32-ia32@0.17.19': optional: true @@ -9932,6 +10297,9 @@ snapshots: '@esbuild/win32-ia32@0.27.2': optional: true + '@esbuild/win32-ia32@0.27.7': + optional: true + '@esbuild/win32-x64@0.17.19': optional: true @@ -9944,6 +10312,9 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true + '@esbuild/win32-x64@0.27.7': + optional: true + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2(jiti@2.6.1))': dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -11219,6 +11590,8 @@ snapshots: '@react-native/normalize-colors@0.81.5': {} + '@react-native/polyfills@2.0.0': {} + '@react-native/virtualized-lists@0.81.5(@types/react@19.1.17)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: invariant: 2.2.4 @@ -11351,11 +11724,11 @@ snapshots: dependencies: nanoid: 3.3.11 - '@rolldown/pluginutils@1.0.0-beta.27': {} - '@rolldown/pluginutils@1.0.0-beta.40': optional: true + '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rollup/rollup-android-arm-eabi@4.54.0': optional: true @@ -11436,6 +11809,18 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@srsholmes/vitest-react-native@0.1.5(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jsdom@20.0.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': + dependencies: + '@react-native/polyfills': 2.0.0 + esbuild: 0.27.7 + flow-remove-types: 2.313.0 + pirates: 4.0.7 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + regenerator-runtime: 0.14.1 + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jsdom@20.0.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -11728,24 +12113,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@types/chai@5.2.3': dependencies: @@ -12018,14 +12403,14 @@ snapshots: '@urql/core': 5.2.0 wonka: 6.3.5 - '@vitejs/plugin-react@4.7.0(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@5.2.0(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@babel/core': 7.28.5 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) - '@rolldown/pluginutils': 1.0.0-beta.27 + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 + react-refresh: 0.18.0 vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -12038,6 +12423,15 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/expect@4.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.0 + '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 @@ -12054,26 +12448,52 @@ snapshots: optionalDependencies: vite: 7.3.0(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.1.6(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 + '@vitest/runner@3.2.4': dependencies: '@vitest/utils': 3.2.4 pathe: 2.0.3 strip-literal: 3.1.0 + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 + pathe: 2.0.3 + '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 + '@vitest/spy@4.1.6': {} + '@vitest/ui@3.2.4(vitest@3.2.4)': dependencies: '@vitest/utils': 3.2.4 @@ -12086,12 +12506,30 @@ snapshots: vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) optional: true + '@vitest/ui@3.2.4(vitest@4.1.6)': + dependencies: + '@vitest/utils': 3.2.4 + fflate: 0.8.2 + flatted: 3.3.4 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 2.0.0 + vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jsdom@20.0.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + optional: true + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@xmldom/xmldom@0.8.11': {} abab@2.0.6: {} @@ -12334,8 +12772,8 @@ snapshots: babel-plugin-jest-hoist@29.6.3: dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 @@ -12479,7 +12917,7 @@ snapshots: - '@babel/core' - supports-color - babel-preset-expo@54.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.33)(react-refresh@0.17.0): + babel-preset-expo@54.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.33)(react-refresh@0.18.0): dependencies: '@babel/helper-module-imports': 7.27.1 '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.28.5) @@ -12502,7 +12940,7 @@ snapshots: babel-plugin-syntax-hermes-parser: 0.29.1 babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.28.5) debug: 4.4.3 - react-refresh: 0.17.0 + react-refresh: 0.18.0 resolve-from: 5.0.0 optionalDependencies: '@babel/runtime': 7.28.4 @@ -12543,7 +12981,7 @@ snapshots: - '@babel/core' - supports-color - babel-preset-expo@54.0.10(@babel/core@7.29.0)(@babel/runtime@7.28.4)(expo@54.0.33)(react-refresh@0.17.0): + babel-preset-expo@54.0.10(@babel/core@7.29.0)(@babel/runtime@7.28.4)(expo@54.0.33)(react-refresh@0.18.0): dependencies: '@babel/helper-module-imports': 7.27.1 '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.29.0) @@ -12566,7 +13004,7 @@ snapshots: babel-plugin-syntax-hermes-parser: 0.29.1 babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) debug: 4.4.3 - react-refresh: 0.17.0 + react-refresh: 0.18.0 resolve-from: 5.0.0 optionalDependencies: '@babel/runtime': 7.28.4 @@ -12765,6 +13203,8 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chai@6.2.2: {} + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -13337,6 +13777,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -13473,6 +13915,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -13494,7 +13965,7 @@ snapshots: eslint-compat-utils@0.5.1(eslint@9.39.2(jiti@2.6.1)): dependencies: eslint: 9.39.2(jiti@2.6.1) - semver: 7.7.3 + semver: 7.7.4 eslint-config-expo@10.0.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: @@ -13926,7 +14397,7 @@ snapshots: transitivePeerDependencies: - supports-color - expo-module-scripts@5.0.8(@babel/core@7.29.0)(@babel/runtime@7.28.4)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.25.12)(eslint@9.39.2(jiti@2.6.1))(expo@54.0.33)(jest@29.7.0(@types/node@24.10.4))(prettier@3.8.1)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react-refresh@0.17.0)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0): + expo-module-scripts@5.0.8(@babel/core@7.29.0)(@babel/runtime@7.28.4)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.25.12)(eslint@9.39.2(jiti@2.6.1))(expo@54.0.33)(jest@29.7.0(@types/node@24.10.4))(prettier@3.8.1)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react-refresh@0.18.0)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@babel/cli': 7.28.3(@babel/core@7.29.0) '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) @@ -13937,7 +14408,7 @@ snapshots: '@tsconfig/node18': 18.2.6 '@types/jest': 29.5.14 babel-plugin-dynamic-import-node: 2.3.3 - babel-preset-expo: 54.0.10(@babel/core@7.29.0)(@babel/runtime@7.28.4)(expo@54.0.33)(react-refresh@0.17.0) + babel-preset-expo: 54.0.10(@babel/core@7.29.0)(@babel/runtime@7.28.4)(expo@54.0.33)(react-refresh@0.18.0) commander: 12.1.0 eslint-config-universe: 15.0.3(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) glob: 13.0.0 @@ -14262,6 +14733,12 @@ snapshots: flow-enums-runtime@0.0.6: {} + flow-remove-types@2.313.0: + dependencies: + hermes-parser: 0.36.0 + pirates: 3.0.2 + vlq: 0.2.3 + fontfaceobserver@2.3.0: {} for-each@0.3.5: @@ -14443,6 +14920,8 @@ snapshots: hermes-estree@0.32.0: {} + hermes-estree@0.36.0: {} + hermes-parser@0.29.1: dependencies: hermes-estree: 0.29.1 @@ -14451,6 +14930,10 @@ snapshots: dependencies: hermes-estree: 0.32.0 + hermes-parser@0.36.0: + dependencies: + hermes-estree: 0.36.0 + hosted-git-info@7.0.2: dependencies: lru-cache: 10.4.3 @@ -14730,8 +15213,8 @@ snapshots: istanbul-lib-instrument@5.2.1: dependencies: - '@babel/core': 7.28.5 - '@babel/parser': 7.28.5 + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 6.3.1 @@ -14740,11 +15223,11 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: - '@babel/core': 7.28.5 - '@babel/parser': 7.28.5 + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 - semver: 7.7.3 + semver: 7.7.4 transitivePeerDependencies: - supports-color @@ -14849,10 +15332,10 @@ snapshots: jest-config@29.7.0(@types/node@24.10.4): dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.5) + babel-jest: 29.7.0(@babel/core@7.29.0) chalk: 4.1.2 ci-info: 3.9.0 deepmerge: 4.3.1 @@ -14879,10 +15362,10 @@ snapshots: jest-config@29.7.0(@types/node@25.3.3): dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.5) + babel-jest: 29.7.0(@babel/core@7.29.0) chalk: 4.1.2 ci-info: 3.9.0 deepmerge: 4.3.1 @@ -14985,10 +15468,6 @@ snapshots: - supports-color - utf-8-validate - jest-fixed-jsdom@0.0.9(jest-environment-jsdom@29.7.0): - dependencies: - jest-environment-jsdom: 29.7.0 - jest-get-type@29.6.3: {} jest-haste-map@29.7.0: @@ -15028,7 +15507,7 @@ snapshots: jest-message-util@29.7.0: dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.29.0 '@jest/types': 29.6.3 '@types/stack-utils': 2.0.3 chalk: 4.1.2 @@ -15448,7 +15927,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 make-error@1.3.6: {} @@ -15805,6 +16284,8 @@ snapshots: node-int64@0.4.0: {} + node-modules-regexp@1.0.0: {} + node-releases@2.0.27: {} normalize-path@3.0.0: {} @@ -15882,6 +16363,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.1: {} + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -15977,7 +16460,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.29.0 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -16036,6 +16519,10 @@ snapshots: pify@4.0.1: {} + pirates@3.0.2: + dependencies: + node-modules-regexp: 1.0.0 + pirates@4.0.7: {} pkg-dir@4.2.0: @@ -16411,7 +16898,7 @@ snapshots: react-refresh@0.14.2: {} - react-refresh@0.17.0: {} + react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.1.17)(react@19.1.0): dependencies: @@ -16493,6 +16980,8 @@ snapshots: regenerator-runtime@0.13.11: {} + regenerator-runtime@0.14.1: {} + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -16657,8 +17146,7 @@ snapshots: semver@7.7.3: {} - semver@7.7.4: - optional: true + semver@7.7.4: {} send@0.19.2: dependencies: @@ -16905,6 +17393,8 @@ snapshots: std-env@3.10.0: {} + std-env@4.1.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -17122,6 +17612,8 @@ snapshots: tinyrainbow@2.0.0: {} + tinyrainbow@3.1.0: {} + tinyspy@4.0.4: {} tmpl@1.0.5: {} @@ -17192,6 +17684,10 @@ snapshots: optionalDependencies: typescript: 5.6.3 + tsconfck@3.1.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -17528,6 +18024,17 @@ snapshots: - supports-color - typescript + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.3) + optionalDependencies: + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + - typescript + vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 @@ -17595,7 +18102,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.10.4 - '@vitest/ui': 3.2.4(vitest@3.2.4) + '@vitest/ui': 3.2.4(vitest@4.1.6) jsdom: 20.0.3 transitivePeerDependencies: - jiti @@ -17655,6 +18162,38 @@ snapshots: - tsx - yaml + vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jsdom@20.0.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 24.10.4 + '@vitest/ui': 3.2.4(vitest@4.1.6) + jsdom: 20.0.3 + transitivePeerDependencies: + - msw + + vlq@0.2.3: {} + vlq@1.0.1: {} w3c-xmlserializer@4.0.0: From 6a71e0753173b9f28a47e94d3fb0825455787718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Mon, 11 May 2026 19:22:21 +0200 Subject: [PATCH 016/129] wip: move to reactivity --- libraries/react-native/src/client-effect.ts | 20 ++- .../react-native/src/client-react-native.ts | 6 +- libraries/react-native/src/client.tsx | 18 +-- libraries/react-native/src/core/event-bus.ts | 71 --------- .../feature-flags/feature-flag-service.ts | 34 ++++- .../core/identity/customer-info-manager.ts | 3 - .../src/core/identity/identity-manager.ts | 17 ++- .../src/core/reactivity/client-state.ts | 54 +++++++ .../src/react/hooks/use-atom-value.ts | 59 ++++++++ .../src/react/hooks/use-customer.ts | 50 ++---- .../src/react/hooks/use-feature-flags.ts | 46 ++---- libraries/react-native/tests/client.test.ts | 4 +- .../tests/core/client-effect.test.ts | 143 +++++++++++++++++- .../tests/core/customer-info-manager.test.ts | 9 +- .../tests/core/identity-manager.test.ts | 50 +++--- .../tests/helpers/effect-test-harness.ts | 10 +- 16 files changed, 373 insertions(+), 221 deletions(-) delete mode 100644 libraries/react-native/src/core/event-bus.ts create mode 100644 libraries/react-native/src/core/reactivity/client-state.ts create mode 100644 libraries/react-native/src/react/hooks/use-atom-value.ts diff --git a/libraries/react-native/src/client-effect.ts b/libraries/react-native/src/client-effect.ts index b3b3b75d5..517c80046 100644 --- a/libraries/react-native/src/client-effect.ts +++ b/libraries/react-native/src/client-effect.ts @@ -1,7 +1,9 @@ import { Effect } from "effect"; +import { AtomRegistry } from "effect/unstable/reactivity"; import { AnalyticsService } from "./core/analytics/service"; import type { AnalyticsIngestEvent } from "./core/analytics/types"; +import { currentCustomerAtom } from "./core/reactivity/client-state"; import { CustomerAttributeManager } from "./core/identity/customer-attribute-manager"; import { CustomerInfoManager } from "./core/identity/customer-info-manager"; import { IdentityManager } from "./core/identity/identity-manager"; @@ -67,7 +69,15 @@ const makeUnitializedClient = () => ({ }); yield* customerAttributeManager.syncCustomerAttributes(distinctId); - yield* customerInfoManager.getCustomer(distinctId, "fetch"); + const prefetched = yield* customerInfoManager.getCustomer( + distinctId, + "fetch" + ); + + // Publish the prefetched customer so React subscribers see initial + // state without having to wait for a hook-driven refetch. + const atomRegistry = yield* AtomRegistry.AtomRegistry; + atomRegistry.set(currentCustomerAtom, prefetched); } // Fetch the runtime schema from the server's `GET /sdk/schema` @@ -134,11 +144,17 @@ const makeInitializedClient = (options: { schema: RuntimeSchema }) => Effect.gen(function* getCurrentCustomer() { const identityManager = yield* IdentityManager; const customerInfoManager = yield* CustomerInfoManager; + const atomRegistry = yield* AtomRegistry.AtomRegistry; const distinctId = yield* identityManager.getDistinctId(); - return yield* customerInfoManager.getCustomer( + const customer = yield* customerInfoManager.getCustomer( distinctId, forceFetch ? "fetch" : "fetch-while-stale" ); + // Publish to the reactive store so any subscribed React hook + // re-renders with the latest result (whether cached or freshly + // fetched). + atomRegistry.set(currentCustomerAtom, customer); + return customer; }), getDistinctId: () => diff --git a/libraries/react-native/src/client-react-native.ts b/libraries/react-native/src/client-react-native.ts index 1088b24bd..c9bc76db2 100644 --- a/libraries/react-native/src/client-react-native.ts +++ b/libraries/react-native/src/client-react-native.ts @@ -1,8 +1,8 @@ import Constants from "expo-constants"; +import { AtomRegistry } from "effect/unstable/reactivity"; import { Platform as RNPlatform } from "react-native"; import { VoidhashClient, type VoidhashClientOptions } from "./client"; -import { EventBus } from "./core/event-bus"; import { SchemeNotSetError } from "./errors"; import { voidhashProviderFactory } from "./react/components/provider"; import { useRetrieveAppStoreProduct } from "./react/hooks/app-store/use-retrieve-app-store-product"; @@ -44,7 +44,7 @@ export function createVoidhashClient( throw new SchemeNotSetError(); } - const eventBus = new EventBus(); + const atomRegistry = AtomRegistry.make(); const platform = RNPlatform.OS === "ios" ? "ios" : "android"; const client = new VoidhashClient( @@ -55,7 +55,7 @@ export function createVoidhashClient( publishableKey, readOnly, unstableSwallowErrors, - eventBus, + atomRegistry, platform, debug, options.unstable_internalSchema diff --git a/libraries/react-native/src/client.tsx b/libraries/react-native/src/client.tsx index 97c3c0ada..527009d61 100644 --- a/libraries/react-native/src/client.tsx +++ b/libraries/react-native/src/client.tsx @@ -1,10 +1,10 @@ import { Cause, Effect, Exit, Layer, ManagedRuntime, pipe } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; +import { AtomRegistry } from "effect/unstable/reactivity"; import { VoidhashEffectClient } from "./client-effect"; import { AsyncStorageCacheAdapter } from "./core/caching/async-storage-cache"; import { CacheManager } from "./core/caching/cache-manager"; -import { type EventBus, EventBusProvider } from "./core/event-bus"; import { CustomerAttributeManager } from "./core/identity/customer-attribute-manager"; import { CustomerInfoManager } from "./core/identity/customer-info-manager"; import { IdentityManager } from "./core/identity/identity-manager"; @@ -49,7 +49,7 @@ const CreateEffectRuntime = ( ingestUrl: string | undefined, publishableKey: string, readOnly: boolean, - eventBus: EventBus + atomRegistry: AtomRegistry.AtomRegistry ) => ManagedRuntime.make( pipe( @@ -70,7 +70,7 @@ const CreateEffectRuntime = ( Layer.provideMerge( platform === "ios" ? AppStoreAdapter : GooglePlayAdapter ), - Layer.provideMerge(Layer.succeed(EventBusProvider, eventBus)), + Layer.provideMerge(Layer.succeed(AtomRegistry.AtomRegistry, atomRegistry)), Layer.provideMerge(ReactNativePlatformProvider), Layer.provideMerge( Layer.succeed(SdkConfiguration, { @@ -116,7 +116,7 @@ export class VoidhashClient { private scheme: string; private internalSchema: RuntimeSchema | undefined; private unstableSwallowErrors: boolean; - private eventBus: EventBus; + private atomRegistry: AtomRegistry.AtomRegistry; private effectRuntime: ReturnType; @@ -131,7 +131,7 @@ export class VoidhashClient { publishableKey: string, readOnly: boolean, unstableSwallowErrors: boolean, - eventBus: EventBus, + atomRegistry: AtomRegistry.AtomRegistry, platform: Exclude, debug = false, internalSchema?: RuntimeSchema @@ -141,7 +141,7 @@ export class VoidhashClient { this.scheme = scheme; this.internalSchema = internalSchema; this.unstableSwallowErrors = unstableSwallowErrors; - this.eventBus = eventBus; + this.atomRegistry = atomRegistry; this.effectRuntime = CreateEffectRuntime( platform, baseUrl, @@ -149,7 +149,7 @@ export class VoidhashClient { ingestUrl, publishableKey, readOnly, - eventBus + atomRegistry ); this.unitializedClient = VoidhashEffectClient.makeUnitializedClient(); } @@ -447,8 +447,8 @@ export class VoidhashClient { // Internal helpers // =============================== - internal_getEventBus() { - return this.eventBus; + internal_getAtomRegistry() { + return this.atomRegistry; } /** diff --git a/libraries/react-native/src/core/event-bus.ts b/libraries/react-native/src/core/event-bus.ts deleted file mode 100644 index f475f1bb6..000000000 --- a/libraries/react-native/src/core/event-bus.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { SdkPerson as SdkCustomer } from "@voidhash/generated-clients"; -import { ServiceMap } from "effect"; - -export interface CustomerFetchedEvent { - type: "customer-fetched"; - customer: SdkCustomer; -} - -export interface CustomerSignedOutEvent { - type: "customer-signed-out"; -} - -export interface CustomerIdentifiedEvent { - type: "customer-identified"; -} - -export interface FeatureFlagsFetchedEvent { - readonly flags: ReadonlyArray<{ - readonly enabled: boolean; - readonly key: string; - readonly payload?: unknown | null; - readonly variantKey: string | null; - }>; -} - -export interface VoidhashEvents { - "customer-fetched": SdkCustomer; - // biome-ignore lint/suspicious/noConfusingVoidType: it specifies that the event has no payload - "customer-signed-out": void; - // biome-ignore lint/suspicious/noConfusingVoidType: it specifies that the event has no payload - "customer-identified": void; - "feature-flags-fetched": FeatureFlagsFetchedEvent; -} - -export type VoidhashClientEvent = keyof VoidhashEvents; - -export class EventBus { - private listeners: { - [key in VoidhashClientEvent]: ((...args: VoidhashEvents[key][]) => void)[]; - } = { - "customer-fetched": [], - "customer-identified": [], - "customer-signed-out": [], - "feature-flags-fetched": [], - }; - - on( - event: TEvent, - listener: (...args: VoidhashEvents[TEvent][]) => void - ) { - this.listeners[event] = this.listeners[event] || []; - this.listeners[event].push(listener); - - return () => { - (this.listeners[event] as (( - ...args: VoidhashEvents[TEvent][] - ) => void)[]) = this.listeners[event].filter((l) => l !== listener); - }; - } - - emit( - event: TEvent, - ...args: VoidhashEvents[TEvent][] - ) { - for (const listener of this.listeners[event]) { - listener(...args); - } - } -} - -export class EventBusProvider extends ServiceMap.Service()("rn-voidhash/EventBusProvider") {} diff --git a/libraries/react-native/src/core/feature-flags/feature-flag-service.ts b/libraries/react-native/src/core/feature-flags/feature-flag-service.ts index 8fbcd782b..48063777d 100644 --- a/libraries/react-native/src/core/feature-flags/feature-flag-service.ts +++ b/libraries/react-native/src/core/feature-flags/feature-flag-service.ts @@ -1,9 +1,13 @@ import { Effect, Layer, ServiceMap } from "effect"; +import { AtomRegistry } from "effect/unstable/reactivity"; import { CacheManager } from "../caching/cache-manager"; -import { EventBusProvider } from "../event-bus"; import { IdentityManager } from "../identity/identity-manager"; import { ApiClient } from "../networking/api-client"; +import { + featureFlagsByKeyAtom, + normalizeFeatureFlagKeys, +} from "../reactivity/client-state"; import { getCommonSdkHeaders } from "../utils/get-common-sdk-headers"; export interface FeatureFlagsResult { @@ -17,13 +21,16 @@ export interface FeatureFlagsResult { const FEATURE_FLAGS_CACHE_TTL_MS = 1000 * 60 * 5; +// Sort a *copy* of the caller's array — the input is part of their data and +// must not be mutated, which the previous in-place `.sort()` was doing. const generateCacheKey = (flagKeys: string[] | undefined) => - `feature-flags:${flagKeys?.sort().join(",") ?? "all"}`; + `feature-flags:${flagKeys && flagKeys.length > 0 ? [...flagKeys].sort().join(",") : "all"}`; /** - * Evaluates feature flags via the SDK API with a 5-minute cache. Emits a - * `feature-flags-fetched` event on the event bus whenever a fresh result is - * received from the server (cache hits don't re-emit). + * Evaluates feature flags via the SDK API with a 5-minute cache. Publishes + * each result (cached or fresh) into the reactive `featureFlagsByKeyAtom` + * keyed by the normalized request signature, so React hooks can subscribe to + * exactly the slice of state they asked for. */ export class FeatureFlagService extends ServiceMap.Service()( "rn-voidhash/FeatureFlagService", @@ -31,14 +38,27 @@ export class FeatureFlagService extends ServiceMap.Service() make: Effect.gen(function* () { const cacheManager = yield* CacheManager; const apiClient = yield* ApiClient; - const eventBus = yield* EventBusProvider; + const atomRegistry = yield* AtomRegistry.AtomRegistry; const identityManager = yield* IdentityManager; + const publishResult = ( + flagKeys: string[] | undefined, + result: FeatureFlagsResult + ) => { + const normalizedKey = normalizeFeatureFlagKeys(flagKeys); + const current = atomRegistry.get(featureFlagsByKeyAtom); + atomRegistry.set(featureFlagsByKeyAtom, { + ...current, + [normalizedKey]: result, + }); + }; + const getFeatureFlags = (flagKeys?: string[]) => Effect.gen(function* () { const cacheKey = generateCacheKey(flagKeys); const cached = yield* cacheManager.get(cacheKey); if (cached && !cached.isExpired && !cached.isStale) { + publishResult(flagKeys, cached.value); return cached.value; } @@ -56,7 +76,7 @@ export class FeatureFlagService extends ServiceMap.Service() ttl: FEATURE_FLAGS_CACHE_TTL_MS, }); - eventBus.emit("feature-flags-fetched", result); + publishResult(flagKeys, result); return result; }); diff --git a/libraries/react-native/src/core/identity/customer-info-manager.ts b/libraries/react-native/src/core/identity/customer-info-manager.ts index a9cfbe3e5..d14860b9e 100644 --- a/libraries/react-native/src/core/identity/customer-info-manager.ts +++ b/libraries/react-native/src/core/identity/customer-info-manager.ts @@ -2,14 +2,12 @@ import type { SdkPerson as SdkCustomer } from "@voidhash/generated-clients"; import { Effect, Layer, ServiceMap } from "effect"; import { CacheManager } from "../caching/cache-manager"; -import { EventBusProvider } from "../event-bus"; import { ApiClient } from "../networking/api-client"; import { getCommonSdkHeaders } from "../utils/get-common-sdk-headers"; const make = Effect.gen(function* effect() { const cacheManager = yield* CacheManager; const apiClient = yield* ApiClient; - const eventBus = yield* EventBusProvider; const generateCustomerCacheKey = (distinctId: string) => `customer:${distinctId}`; @@ -35,7 +33,6 @@ const make = Effect.gen(function* effect() { "x-distinct-id": distinctId, }, }); - eventBus.emit("customer-fetched", result); yield* cache(distinctId, result); return result; }); diff --git a/libraries/react-native/src/core/identity/identity-manager.ts b/libraries/react-native/src/core/identity/identity-manager.ts index d05e9e6e2..f763c656e 100644 --- a/libraries/react-native/src/core/identity/identity-manager.ts +++ b/libraries/react-native/src/core/identity/identity-manager.ts @@ -1,9 +1,13 @@ import { Effect, Layer, ServiceMap } from "effect"; +import { AtomRegistry } from "effect/unstable/reactivity"; import { ANONYMOUS_DISTINCT_ID_PREFIX } from "../../constants"; import { CacheManager } from "../caching/cache-manager"; -import { EventBusProvider } from "../event-bus"; import { ApiClient } from "../networking/api-client"; +import { + currentCustomerAtom, + featureFlagsByKeyAtom, +} from "../reactivity/client-state"; import { getCommonSdkHeaders } from "../utils/get-common-sdk-headers"; import { CustomerAttributeManager } from "./customer-attribute-manager"; import { CustomerInfoManager } from "./customer-info-manager"; @@ -14,7 +18,7 @@ const make = Effect.gen(function* effect() { const cacheManager = yield* CacheManager; const customerAttributeManager = yield* CustomerAttributeManager; const customerInfoManager = yield* CustomerInfoManager; - const eventBus = yield* EventBusProvider; + const atomRegistry = yield* AtomRegistry.AtomRegistry; const apiClient = yield* ApiClient; /** @@ -67,11 +71,13 @@ const make = Effect.gen(function* effect() { customerInfoManager.cache(distinctId, identifyRequest), ]); - eventBus.emit("customer-identified"); - eventBus.emit("customer-fetched", { + // Identity has changed: surface the new customer and clear stale + // feature flag state, since flag evaluations are identity-scoped. + atomRegistry.set(currentCustomerAtom, { ...identifyRequest, distinctId, }); + atomRegistry.set(featureFlagsByKeyAtom, {}); }); const reset = () => @@ -81,7 +87,8 @@ const make = Effect.gen(function* effect() { currentDistinctId ); yield* cacheManager.clear(); - eventBus.emit("customer-signed-out"); + atomRegistry.set(currentCustomerAtom, null); + atomRegistry.set(featureFlagsByKeyAtom, {}); }); // Helpers diff --git a/libraries/react-native/src/core/reactivity/client-state.ts b/libraries/react-native/src/core/reactivity/client-state.ts new file mode 100644 index 000000000..d44cc33f4 --- /dev/null +++ b/libraries/react-native/src/core/reactivity/client-state.ts @@ -0,0 +1,54 @@ +import type { SdkPerson as SdkCustomer } from "@voidhash/generated-clients"; +import { Atom } from "effect/unstable/reactivity"; + +import type { FeatureFlagsResult } from "../feature-flags/feature-flag-service"; + +export type { FeatureFlagsResult }; + +/** + * Reactive store of the currently identified customer. Written by the + * customer/identity facade paths and read by `useCurrentCustomer`. + */ +export const currentCustomerAtom: Atom.Writable = + Atom.make(null); + +/** + * Reactive store of feature flag results, keyed by their normalized flag-key + * request signature (see {@link normalizeFeatureFlagKeys}). Keeping every + * request set in its own slot prevents one hook's fetch from overwriting the + * value of another hook that asked for a different set of flags. + */ +export const featureFlagsByKeyAtom: Atom.Writable< + Readonly> +> = Atom.make>>({}); + +/** + * Normalizes a feature-flag request signature so that any callers asking for + * the same set of keys (regardless of order) share the same atom slot. We + * sort a copy because the caller's array is part of their input and must not + * be mutated. + */ +export const normalizeFeatureFlagKeys = ( + flagKeys?: readonly string[] +): string => { + if (!flagKeys || flagKeys.length === 0) { + return "all"; + } + return [...flagKeys].sort().join(","); +}; + +const featureFlagsForNormalizedKeyAtom = Atom.family((normalizedKey: string) => + Atom.make((get): FeatureFlagsResult | null => { + const byKey = get(featureFlagsByKeyAtom); + return byKey[normalizedKey] ?? null; + }) +); + +/** + * Derived atom that returns the cached `FeatureFlagsResult` for a particular + * set of flag keys (or `null` if nothing has been published yet). Reusing + * the same normalized key across requests means callers subscribe to the + * minimum slice of state they care about. + */ +export const featureFlagsForKeysAtom = (flagKeys?: readonly string[]) => + featureFlagsForNormalizedKeyAtom(normalizeFeatureFlagKeys(flagKeys)); diff --git a/libraries/react-native/src/react/hooks/use-atom-value.ts b/libraries/react-native/src/react/hooks/use-atom-value.ts new file mode 100644 index 000000000..7419b47fb --- /dev/null +++ b/libraries/react-native/src/react/hooks/use-atom-value.ts @@ -0,0 +1,59 @@ +import React from "react"; +import type { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +interface AtomStore { + readonly subscribe: (notify: () => void) => () => void; + readonly snapshot: () => A; +} + +// Mirrors the WeakMap caching pattern used by `@effect/atom-react` so the +// `useSyncExternalStore` subscribe/snapshot pair is stable across renders. +const storeRegistry = new WeakMap< + AtomRegistry.AtomRegistry, + WeakMap, AtomStore> +>(); + +function getStore( + registry: AtomRegistry.AtomRegistry, + atom: Atom.Atom +): AtomStore { + let stores = storeRegistry.get(registry); + if (stores === undefined) { + stores = new WeakMap(); + storeRegistry.set(registry, stores); + } + + const cached = stores.get(atom as Atom.Atom); + if (cached !== undefined) { + return cached as AtomStore; + } + + const store: AtomStore = { + subscribe: (notify) => registry.subscribe(atom, notify), + snapshot: () => registry.get(atom), + }; + stores.set(atom as Atom.Atom, store as AtomStore); + return store; +} + +/** + * Reads the value of an Effect `Atom` and re-renders whenever it changes. + * + * Internal binding around `React.useSyncExternalStore`, intentionally scoped + * to the SDK so we don't pull in `@effect/atom-react` (which targets a newer + * React peer range than this package currently supports). + */ +export function useAtomValue( + registry: AtomRegistry.AtomRegistry, + atom: Atom.Atom +): A { + const store = getStore(registry, atom); + const value = React.useSyncExternalStore(store.subscribe, store.snapshot); + + // Atoms are lazy: without an active mount they may be removed from the + // registry and lose listeners. Mounting on commit keeps the atom alive for + // the lifetime of the component subscription. + React.useEffect(() => registry.mount(atom), [registry, atom]); + + return value; +} diff --git a/libraries/react-native/src/react/hooks/use-customer.ts b/libraries/react-native/src/react/hooks/use-customer.ts index c0ea42fad..7c17feade 100644 --- a/libraries/react-native/src/react/hooks/use-customer.ts +++ b/libraries/react-native/src/react/hooks/use-customer.ts @@ -1,9 +1,10 @@ -import type { SdkPerson as SdkCustomer } from "@voidhash/generated-clients"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useMemo } from "react"; import type { VoidhashClient } from "../../client"; +import { currentCustomerAtom } from "../../core/reactivity/client-state"; import type { VoidhashContext } from "../components/provider"; import useAsyncFunction from "./use-async-function"; +import { useAtomValue } from "./use-atom-value"; export function currentCustomerHookFactory( client: VoidhashClient, @@ -11,54 +12,25 @@ export function currentCustomerHookFactory( ) { function useCurrentCustomer() { const voidhashContext = React.useContext(vhContext); - const [customer, setCustomer] = useState(null); - const setCustomerIfDifferent = useCallback( - (newCustomer: SdkCustomer | null) => { - if (JSON.stringify(customer) === JSON.stringify(newCustomer)) { - return; - } - setCustomer(newCustomer); - }, - [customer] - ); - - // Loading customer const getCustomerCallback = useCallback( () => client.getCurrentCustomer(), [] ); - const { - data: loadedCustomer, - isLoading, - error, - refetch, - } = useAsyncFunction(getCustomerCallback, { + const { isLoading, error, refetch } = useAsyncFunction(getCustomerCallback, { enabled: voidhashContext?.isInitialized, }); - // Listen for customer updates. Update state if there was a change. This is used to sync state between uses of this hook and for background updates. - useEffect(() => { - const eventBus = client.internal_getEventBus(); - const removeListener = eventBus.on("customer-fetched", (newCustomer) => { - setCustomerIfDifferent(newCustomer); - }); - - return () => { - removeListener(); - }; - }, [setCustomerIfDifferent]); - - // Processing - const data = useMemo( - () => ({ - ...customer, - }), - [customer] + const customer = useAtomValue( + client.internal_getAtomRegistry(), + currentCustomerAtom ); - setCustomerIfDifferent(loadedCustomer ?? null); + // Preserve the previous return shape: `data` spreads the customer fields, + // so callers reading e.g. `data.email` keep working and `null` becomes + // `{}` rather than `null`. + const data = useMemo(() => ({ ...customer }), [customer]); return { data, diff --git a/libraries/react-native/src/react/hooks/use-feature-flags.ts b/libraries/react-native/src/react/hooks/use-feature-flags.ts index 6c2c39ef2..24167448f 100644 --- a/libraries/react-native/src/react/hooks/use-feature-flags.ts +++ b/libraries/react-native/src/react/hooks/use-feature-flags.ts @@ -1,11 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useMemo } from "react"; import type { VoidhashClient } from "../../client"; -import type { FeatureFlagsFetchedEvent } from "../../core/event-bus"; +import { featureFlagsForKeysAtom } from "../../core/reactivity/client-state"; import type { VoidhashContext } from "../components/provider"; import useAsyncFunction from "./use-async-function"; - -type FeatureFlagsResult = FeatureFlagsFetchedEvent; +import { useAtomValue } from "./use-atom-value"; export function featureFlagsHookFactory( client: VoidhashClient, @@ -13,47 +12,22 @@ export function featureFlagsHookFactory( ) { function useFeatureFlags(flagKeys?: string[]) { const voidhashContext = React.useContext(vhContext); - const [flags, setFlags] = useState(null); - - const setFlagsIfDifferent = useCallback( - (newFlags: FeatureFlagsResult | null) => { - if (JSON.stringify(flags) === JSON.stringify(newFlags)) { - return; - } - setFlags(newFlags); - }, - [flags] - ); const fetchFlags = useCallback( () => client.getFeatureFlags(flagKeys), [flagKeys] ); - const { - data: loadedFlags, - isLoading, - error, - refetch, - } = useAsyncFunction(fetchFlags, { + const { isLoading, error, refetch } = useAsyncFunction(fetchFlags, { enabled: voidhashContext?.isInitialized, }); - useEffect(() => { - const eventBus = client.internal_getEventBus(); - const removeListener = eventBus.on( - "feature-flags-fetched", - (newFlags) => { - setFlagsIfDifferent(newFlags); - } - ); - - return () => { - removeListener(); - }; - }, [setFlagsIfDifferent]); - - setFlagsIfDifferent(loadedFlags ?? null); + // Subscribe to only the slice of flag state matching our request. The + // `featureFlagsForKeysAtom` family memoizes by normalized key so two + // hooks asking for the same keys (in any order) share an atom, and hooks + // asking for different keys can't trample each other. + const flagsAtom = useMemo(() => featureFlagsForKeysAtom(flagKeys), [flagKeys]); + const flags = useAtomValue(client.internal_getAtomRegistry(), flagsAtom); const isEnabled = useCallback( (key: string) => diff --git a/libraries/react-native/tests/client.test.ts b/libraries/react-native/tests/client.test.ts index 810f00c12..04f5e0a45 100644 --- a/libraries/react-native/tests/client.test.ts +++ b/libraries/react-native/tests/client.test.ts @@ -1,4 +1,5 @@ import { Exit } from "effect"; +import { AtomRegistry } from "effect/unstable/reactivity"; import { vi } from "vitest"; import { describe, expect, it } from "./helpers/effect-vitest"; @@ -42,7 +43,6 @@ vi.mock("../src/core/platform/react-native-platform-provider", async () => { }); import { VoidhashClient } from "../src/client"; -import { EventBus } from "../src/core/event-bus"; import { ReadOnlyModePurchaseNotAllowedError, VoidhashError, @@ -58,7 +58,7 @@ function createClient(readOnly = false, unstableSwallowErrors = false) { "pk_test", readOnly, unstableSwallowErrors, - new EventBus(), + AtomRegistry.make(), "ios", false, createTestSchema() diff --git a/libraries/react-native/tests/core/client-effect.test.ts b/libraries/react-native/tests/core/client-effect.test.ts index afc8a9d3d..7a59a1d57 100644 --- a/libraries/react-native/tests/core/client-effect.test.ts +++ b/libraries/react-native/tests/core/client-effect.test.ts @@ -11,11 +11,16 @@ import { Product, SubscriptionProduct } from "../../src/core/entities/product"; import { Transaction } from "../../src/core/entities/transaction"; import { CustomerAttributeManager } from "../../src/core/identity/customer-attribute-manager"; import { SDK_VERSION } from "../../src/core/constants"; +import { + currentCustomerAtom, + featureFlagsForKeysAtom, +} from "../../src/core/reactivity/client-state"; import { createApiClientDouble, createEffectTestHarness, createInMemoryCacheAdapter, createPaymentAdapterDouble, + createSdkCustomer, } from "../helpers/effect-test-harness"; import { describe, expect, it } from "../helpers/effect-vitest"; import { createTestSchema } from "../helpers/test-schema"; @@ -140,7 +145,7 @@ describe("VoidhashEffectClient", () => { } }); - it("getFeatureFlags caches by sorted keys and emits event only for fetch", async () => { + it("getFeatureFlags caches by sorted keys and publishes to the reactive atom", async () => { const schema = createTestSchema(); const apiDouble = createApiClientDouble({ evaluateFeatureFlagsResult: { @@ -164,18 +169,17 @@ describe("VoidhashEffectClient", () => { const initializedClient = await harness.runtime.runPromise( VoidhashEffectClient.makeInitializedClient({ schema }) ); - const events: string[] = []; - const remove = harness.eventBus.on("feature-flags-fetched", () => { - events.push("feature-flags-fetched"); - }); try { await harness.runtime.runPromise( Effect.flatMap(CacheManager.asEffect(), (manager) => manager.set("distinctId", "feature-user")) ); + const inputKeys = ["b", "a"]; + const inputSnapshot = [...inputKeys]; + const first = await harness.runtime.runPromise( - initializedClient.getFeatureFlags(["b", "a"]) + initializedClient.getFeatureFlags(inputKeys) ); const second = await harness.runtime.runPromise( initializedClient.getFeatureFlags(["a", "b"]) @@ -184,9 +188,132 @@ describe("VoidhashEffectClient", () => { expect(first.flags).toHaveLength(1); expect(second).toEqual(first); expect(apiDouble.state.evaluateFeatureFlagsCalls).toHaveLength(1); - expect(events).toEqual(["feature-flags-fetched"]); + + // The caller's input array must remain in its original order — the + // service must sort a copy, not mutate the input. + expect(inputKeys).toEqual(inputSnapshot); + + // Both reversed and original orders observe the same atom slot. + const publishedForBA = harness.atomRegistry.get( + featureFlagsForKeysAtom(["b", "a"]) + ); + const publishedForAB = harness.atomRegistry.get( + featureFlagsForKeysAtom(["a", "b"]) + ); + expect(publishedForBA).toEqual(first); + expect(publishedForAB).toEqual(first); + } finally { + await harness.runtime.dispose(); + } + }); + + it("maintains separate atom entries for distinct flag-key requests", async () => { + const schema = createTestSchema(); + const responses: Record }> = { + a: { flags: [{ enabled: true, key: "a", payload: null, variantKey: null }] }, + b: { flags: [{ enabled: false, key: "b", payload: null, variantKey: null }] }, + }; + const apiDouble = createApiClientDouble(); + (apiDouble.apiClient as { + sdk: { + evaluateFeatureFlags: (request: { + headers: Record; + payload?: { flagKeys?: string[] }; + }) => unknown; + }; + }).sdk.evaluateFeatureFlags = (request) => { + apiDouble.state.evaluateFeatureFlagsCalls.push(request); + const key = request.payload?.flagKeys?.[0] ?? "all"; + return Effect.succeed(responses[key] ?? { flags: [] }); + }; + + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + paymentAdapter: paymentDouble.paymentAdapter, + }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); + + try { + await harness.runtime.runPromise(initializedClient.getFeatureFlags(["a"])); + await harness.runtime.runPromise(initializedClient.getFeatureFlags(["b"])); + + const forA = harness.atomRegistry.get(featureFlagsForKeysAtom(["a"])); + const forB = harness.atomRegistry.get(featureFlagsForKeysAtom(["b"])); + expect(forA?.flags[0]?.key).toBe("a"); + expect(forB?.flags[0]?.key).toBe("b"); + } finally { + await harness.runtime.dispose(); + } + }); + + it("getCurrentCustomer publishes both cached and freshly fetched results to the reactive atom", async () => { + const schema = createTestSchema(); + const fetched = createSdkCustomer("fetched-customer"); + const apiDouble = createApiClientDouble({ getCustomerResult: fetched }); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + paymentAdapter: paymentDouble.paymentAdapter, + }); + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeInitializedClient({ schema }) + ); + + try { + await harness.runtime.runPromise( + Effect.flatMap(CacheManager.asEffect(), (manager) => + manager.set("distinctId", "fetched-customer") + ) + ); + + // First call fetches and publishes the network result. + await harness.runtime.runPromise( + initializedClient.getCurrentCustomer(true) + ); + expect(harness.atomRegistry.get(currentCustomerAtom)).toEqual(fetched); + + // Reset atom then verify a cached read still publishes back. + harness.atomRegistry.set(currentCustomerAtom, null); + await harness.runtime.runPromise( + initializedClient.getCurrentCustomer() + ); + expect(harness.atomRegistry.get(currentCustomerAtom)).toEqual(fetched); + } finally { + await harness.runtime.dispose(); + } + }); + + it("init without distinct id publishes the prefetched customer to the reactive atom", async () => { + const schema = createTestSchema(); + const apiDouble = createApiClientDouble(); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + paymentAdapter: paymentDouble.paymentAdapter, + }); + + try { + await harness.runtime.runPromise( + VoidhashEffectClient.makeUnitializedClient().init({ + internalSchema: schema, + }) + ); + + const published = harness.atomRegistry.get(currentCustomerAtom); + expect(published).not.toBeNull(); + expect( + published?.distinctId.startsWith(ANONYMOUS_DISTINCT_ID_PREFIX) + ).toBe(true); } finally { - remove(); await harness.runtime.dispose(); } }); diff --git a/libraries/react-native/tests/core/customer-info-manager.test.ts b/libraries/react-native/tests/core/customer-info-manager.test.ts index fdaea8b58..e5f95cfa6 100644 --- a/libraries/react-native/tests/core/customer-info-manager.test.ts +++ b/libraries/react-native/tests/core/customer-info-manager.test.ts @@ -48,7 +48,7 @@ describe("CustomerInfoManager", () => { } }); - it("fetch policy always requests API, updates cache and emits event", async () => { + it("fetch policy always requests API and updates cache", async () => { const apiDouble = createApiClientDouble({ getCustomerResult: createSdkCustomer("fetched-user"), }); @@ -60,11 +60,6 @@ describe("CustomerInfoManager", () => { paymentAdapter: paymentDouble.paymentAdapter, }); - const fetchedEvents: string[] = []; - const remove = harness.eventBus.on("customer-fetched", (customer) => { - fetchedEvents.push(customer.distinctId); - }); - try { const result = await harness.runtime.runPromise( Effect.flatMap(CustomerInfoManager.asEffect(), (manager) => @@ -84,9 +79,7 @@ describe("CustomerInfoManager", () => { expect(result.distinctId).toBe("fetched-user"); expect(apiDouble.state.getCustomerCalls).toHaveLength(1); expect(cached?.value.distinctId).toBe("fetched-user"); - expect(fetchedEvents).toEqual(["fetched-user"]); } finally { - remove(); await harness.runtime.dispose(); } }); diff --git a/libraries/react-native/tests/core/identity-manager.test.ts b/libraries/react-native/tests/core/identity-manager.test.ts index dae9f8462..ff5b9ce2f 100644 --- a/libraries/react-native/tests/core/identity-manager.test.ts +++ b/libraries/react-native/tests/core/identity-manager.test.ts @@ -5,11 +5,16 @@ import { CacheManager } from "../../src/core/caching/cache-manager"; import { CustomerAttributeManager } from "../../src/core/identity/customer-attribute-manager"; import { CustomerInfoManager } from "../../src/core/identity/customer-info-manager"; import { IdentityManager } from "../../src/core/identity/identity-manager"; +import { + currentCustomerAtom, + featureFlagsByKeyAtom, +} from "../../src/core/reactivity/client-state"; import { createApiClientDouble, createEffectTestHarness, createInMemoryCacheAdapter, createPaymentAdapterDouble, + createSdkCustomer, } from "../helpers/effect-test-harness"; import { describe, expect, it } from "../helpers/effect-vitest"; @@ -64,7 +69,7 @@ describe("IdentityManager", () => { } }); - it("identify syncs previous traits, updates cache and emits events", async () => { + it("identify syncs previous traits, updates cache and publishes new customer", async () => { const apiDouble = createApiClientDouble(); const paymentDouble = createPaymentAdapterDouble(); const cache = createInMemoryCacheAdapter(); @@ -74,17 +79,12 @@ describe("IdentityManager", () => { paymentAdapter: paymentDouble.paymentAdapter, }); - const identifiedEvents: string[] = []; - const fetchedEvents: string[] = []; - - const removeIdentified = harness.eventBus.on("customer-identified", () => { - identifiedEvents.push("customer-identified"); - }); - const removeFetched = harness.eventBus.on("customer-fetched", (customer) => { - fetchedEvents.push(customer.distinctId); - }); - try { + // Seed feature flag state to verify identity changes clear it. + harness.atomRegistry.set(featureFlagsByKeyAtom, { + all: { flags: [{ enabled: true, key: "k", payload: null, variantKey: null }] }, + }); + await harness.runtime.runPromise( Effect.flatMap(CacheManager.asEffect(), (manager) => Effect.all([ @@ -130,16 +130,16 @@ describe("IdentityManager", () => { expect(cachedDistinctId).toBe("new-user"); expect(cachedCustomer?.value.distinctId).toBe("new-user"); - expect(identifiedEvents).toEqual(["customer-identified"]); - expect(fetchedEvents).toEqual(["new-user"]); + + const publishedCustomer = harness.atomRegistry.get(currentCustomerAtom); + expect(publishedCustomer?.distinctId).toBe("new-user"); + expect(harness.atomRegistry.get(featureFlagsByKeyAtom)).toEqual({}); } finally { - removeIdentified(); - removeFetched(); await harness.runtime.dispose(); } }); - it("reset syncs attributes, clears cache and emits signed out event", async () => { + it("reset syncs attributes, clears cache and resets reactive state", async () => { const apiDouble = createApiClientDouble(); const paymentDouble = createPaymentAdapterDouble(); const cache = createInMemoryCacheAdapter(); @@ -149,12 +149,16 @@ describe("IdentityManager", () => { paymentAdapter: paymentDouble.paymentAdapter, }); - const signOutEvents: string[] = []; - const remove = harness.eventBus.on("customer-signed-out", () => { - signOutEvents.push("customer-signed-out"); - }); - try { + // Seed reactive state so we can assert reset clears it. + harness.atomRegistry.set( + currentCustomerAtom, + createSdkCustomer("signed-in-user") + ); + harness.atomRegistry.set(featureFlagsByKeyAtom, { + all: { flags: [] }, + }); + await harness.runtime.runPromise( Effect.flatMap(CacheManager.asEffect(), (manager) => Effect.all([ @@ -189,9 +193,9 @@ describe("IdentityManager", () => { ); expect(distinctIdFromCache).toBeNull(); expect(cacheKeys).toEqual([]); - expect(signOutEvents).toEqual(["customer-signed-out"]); + expect(harness.atomRegistry.get(currentCustomerAtom)).toBeNull(); + expect(harness.atomRegistry.get(featureFlagsByKeyAtom)).toEqual({}); } finally { - remove(); await harness.runtime.dispose(); } }); diff --git a/libraries/react-native/tests/helpers/effect-test-harness.ts b/libraries/react-native/tests/helpers/effect-test-harness.ts index e955cd6dc..99dfe94b6 100644 --- a/libraries/react-native/tests/helpers/effect-test-harness.ts +++ b/libraries/react-native/tests/helpers/effect-test-harness.ts @@ -1,12 +1,12 @@ import type { SdkPerson as SdkCustomer } from "@voidhash/generated-clients"; import { Effect, Layer, ManagedRuntime, pipe } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; +import { AtomRegistry } from "effect/unstable/reactivity"; import { CacheAdapter } from "../../src/core/caching/cache-adapter"; import { CacheManager } from "../../src/core/caching/cache-manager"; import { Product, type SubscriptionProduct } from "../../src/core/entities/product"; import { Transaction } from "../../src/core/entities/transaction"; -import { EventBus, EventBusProvider } from "../../src/core/event-bus"; import { CustomerAttributeManager } from "../../src/core/identity/customer-attribute-manager"; import { CustomerInfoManager } from "../../src/core/identity/customer-info-manager"; import { IdentityManager } from "../../src/core/identity/identity-manager"; @@ -234,10 +234,10 @@ export function createLifecycleAdapterDouble() { export interface EffectTestHarnessOptions { apiClient: unknown; + atomRegistry?: AtomRegistry.AtomRegistry; baseUrl?: string; cacheAdapter: ReturnType["adapter"]; debug?: boolean; - eventBus?: EventBus; fetch?: typeof globalThis.fetch; ingestUrl?: string; lifecycleAdapter?: ReturnType; @@ -261,7 +261,7 @@ const defaultPlatformInfo: PlatformInfo = { }; export function createEffectTestHarness(options: EffectTestHarnessOptions) { - const eventBus = options.eventBus ?? new EventBus(); + const atomRegistry = options.atomRegistry ?? AtomRegistry.make(); const lifecycle = options.lifecycleAdapter ?? createLifecycleAdapterDouble(); @@ -288,7 +288,7 @@ export function createEffectTestHarness(options: EffectTestHarnessOptions) { options.paymentAdapter as typeof PaymentAdapter.Service ) ), - Layer.provideMerge(Layer.succeed(EventBusProvider, eventBus)), + Layer.provideMerge(Layer.succeed(AtomRegistry.AtomRegistry, atomRegistry)), Layer.provideMerge( Layer.succeed(PlatformProvider, { ...defaultPlatformInfo, @@ -313,7 +313,7 @@ export function createEffectTestHarness(options: EffectTestHarnessOptions) { : baseLayer; return { - eventBus, + atomRegistry, runtime: ManagedRuntime.make(layer), }; } From 611babfae7f307f2fcd5d520883802ce6c5b4f49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Mon, 11 May 2026 20:42:48 +0200 Subject: [PATCH 017/129] feat: schema loading with caching --- libraries/react-native/src/client-effect.ts | 103 +++--- libraries/react-native/src/client.tsx | 2 + .../src/core/reactivity/client-state.ts | 13 + .../src/core/schema/schema-manager.ts | 163 ++++++++++ libraries/react-native/src/errors.ts | 2 + .../tests/core/client-effect.test.ts | 62 +++- .../tests/core/schema-manager.test.ts | 305 ++++++++++++++++++ .../tests/helpers/effect-test-harness.ts | 14 +- 8 files changed, 605 insertions(+), 59 deletions(-) create mode 100644 libraries/react-native/src/core/schema/schema-manager.ts create mode 100644 libraries/react-native/tests/core/schema-manager.test.ts diff --git a/libraries/react-native/src/client-effect.ts b/libraries/react-native/src/client-effect.ts index 517c80046..ccd0b2b4d 100644 --- a/libraries/react-native/src/client-effect.ts +++ b/libraries/react-native/src/client-effect.ts @@ -11,17 +11,13 @@ import type { SubscriptionProduct } from "./core/entities/product"; import type { Transaction } from "./core/entities/transaction"; import { FeatureFlagService } from "./core/feature-flags/feature-flag-service"; import { LifecycleService } from "./core/lifecycle/lifecycle-service"; -import { ApiClient } from "./core/networking/api-client"; import { PaymentAdapter } from "./core/payment-adapters/payment-adapter"; import { PaywallService } from "./core/paywalls/paywall-service"; import { ProductService, type ProductsBySlug } from "./core/products/product-service"; import type { LocationSlug } from "./core/schema/registry"; -import { - type RuntimeSchema, - createEmptyRuntimeSchema, -} from "./core/schema/runtime"; +import type { RuntimeSchema } from "./core/schema/runtime"; +import { SchemaManager } from "./core/schema/schema-manager"; import { TransactionService } from "./core/transactions/transaction-service"; -import { getCommonSdkHeaders } from "./core/utils/get-common-sdk-headers"; import { UnsupportedPlatformError } from "./errors"; export type { ProductsBySlug }; @@ -40,8 +36,15 @@ interface InitOptions { /** * Build the initial state of the SDK before `init()` has run. Returns an - * object whose `init` method performs identity establishment, schema fetching, - * and then yields the fully-initialized client facade. + * object whose `init` method establishes identity, resolves the runtime + * schema (via `SchemaManager`'s stale-while-revalidate cache), and yields + * the fully-initialized client facade. The independent network calls are + * run concurrently via `Effect.all({ concurrency: "unbounded" })`. + * + * A missing/failed schema fetch is fatal — when the cache is cold and the + * server is unreachable, `init` rejects with `FailedToFetchSchemaError`. + * This trades silent degradation for a loud failure that surfaces through + * `client.tsx`'s `runEffect` wrapping. */ const makeUnitializedClient = () => ({ init: (initOptions: InitOptions = {}) => @@ -49,63 +52,51 @@ const makeUnitializedClient = () => ({ const identityManager = yield* IdentityManager; const customerAttributeManager = yield* CustomerAttributeManager; const customerInfoManager = yield* CustomerInfoManager; - const apiClient = yield* ApiClient; + const schemaManager = yield* SchemaManager; + const atomRegistry = yield* AtomRegistry.AtomRegistry; if (initOptions.distinctId) { yield* Effect.logDebug("Initializing with provided distinct id", { distinctId: initOptions.distinctId, }); - const distinctId = yield* identityManager.getDistinctIdFromCache(); - if (distinctId) { - yield* customerAttributeManager.syncCustomerAttributes(distinctId); - } - - yield* identityManager.identify(initOptions.distinctId, {}); - } else { - const distinctId = yield* identityManager.getDistinctId(); - yield* Effect.logDebug("Initializing without provided distinct id", { - distinctId, - }); - - yield* customerAttributeManager.syncCustomerAttributes(distinctId); - const prefetched = yield* customerInfoManager.getCustomer( - distinctId, - "fetch" + // `identityManager.identify()` internally syncs attributes for the + // current cached distinctId AND publishes the new customer to + // `currentCustomerAtom`, so we don't duplicate either here. + const [, runtimeSchema] = yield* Effect.all( + [ + identityManager.identify(initOptions.distinctId, {}), + schemaManager.resolveSchema({ + distinctId: initOptions.distinctId, + internalSchema: initOptions.internalSchema, + }), + ], + { concurrency: "unbounded" } ); - - // Publish the prefetched customer so React subscribers see initial - // state without having to wait for a hook-driven refetch. - const atomRegistry = yield* AtomRegistry.AtomRegistry; - atomRegistry.set(currentCustomerAtom, prefetched); + return yield* makeInitializedClient({ schema: runtimeSchema }); } - // Fetch the runtime schema from the server's `GET /sdk/schema` - // endpoint and cache it on the initialized client. A failure here is - // non-fatal — non-schema hooks (paywall resolution, feature flags, - // identify) still work — but product-related hooks will return empty - // data until the next successful init. - let runtimeSchema = - initOptions.internalSchema ?? createEmptyRuntimeSchema(); - if (!initOptions.internalSchema) { - const commonHeaders = yield* getCommonSdkHeaders(); - const distinctId = yield* identityManager.getDistinctId(); - const fetched = yield* Effect.exit( - apiClient.sdk.getSchema({ - headers: { - ...commonHeaders, - "x-distinct-id": distinctId, - }, - }) - ); - if (fetched._tag === "Success") { - runtimeSchema = fetched.value; - } else { - yield* Effect.logWarning( - "[voidhash] Failed to fetch schema at init — product-related hooks will return empty data." - ); - } - } + const distinctId = yield* identityManager.getDistinctId(); + yield* Effect.logDebug("Initializing without provided distinct id", { + distinctId, + }); + + const [, prefetchedCustomer, runtimeSchema] = yield* Effect.all( + [ + customerAttributeManager.syncCustomerAttributes(distinctId), + customerInfoManager.getCustomer(distinctId, "fetch"), + schemaManager.resolveSchema({ + distinctId, + internalSchema: initOptions.internalSchema, + }), + ], + { concurrency: "unbounded" } + ); + + // Publish the prefetched customer so React subscribers see initial + // state without having to wait for a hook-driven refetch. + // `SchemaManager` publishes `schemaAtom` itself. + atomRegistry.set(currentCustomerAtom, prefetchedCustomer); return yield* makeInitializedClient({ schema: runtimeSchema }); }), diff --git a/libraries/react-native/src/client.tsx b/libraries/react-native/src/client.tsx index 527009d61..a9617bfe1 100644 --- a/libraries/react-native/src/client.tsx +++ b/libraries/react-native/src/client.tsx @@ -21,6 +21,7 @@ import { ProductService } from "./core/products/product-service"; import { TransactionService } from "./core/transactions/transaction-service"; import type { LocationSlug, ProductSlug } from "./core/schema/registry"; import type { RuntimeSchema } from "./core/schema/runtime"; +import { SchemaManager } from "./core/schema/schema-manager"; import { SdkConfiguration } from "./core/sdk-configuration"; import { ReadOnlyModePurchaseNotAllowedError, VoidhashError } from "./errors"; import { AnalyticsService } from "./core/analytics/service"; @@ -62,6 +63,7 @@ const CreateEffectRuntime = ( Layer.provideMerge(LifecycleService.layer), Layer.provideMerge(ReactNativeLifecycleAdapter), Layer.provideMerge(CustomerInfoManager.Default), + Layer.provideMerge(SchemaManager.layer), Layer.provideMerge(IdentityManager.Default), Layer.provideMerge(CacheManager.Default), Layer.provideMerge(AsyncStorageCacheAdapter), diff --git a/libraries/react-native/src/core/reactivity/client-state.ts b/libraries/react-native/src/core/reactivity/client-state.ts index d44cc33f4..d48ed79d7 100644 --- a/libraries/react-native/src/core/reactivity/client-state.ts +++ b/libraries/react-native/src/core/reactivity/client-state.ts @@ -2,6 +2,7 @@ import type { SdkPerson as SdkCustomer } from "@voidhash/generated-clients"; import { Atom } from "effect/unstable/reactivity"; import type { FeatureFlagsResult } from "../feature-flags/feature-flag-service"; +import type { RuntimeSchema } from "../schema/runtime"; export type { FeatureFlagsResult }; @@ -12,6 +13,18 @@ export type { FeatureFlagsResult }; export const currentCustomerAtom: Atom.Writable = Atom.make(null); +/** + * Reactive store of the runtime schema fetched at init time and refreshed + * in the background by `SchemaManager`. `null` until init has resolved a + * schema. Read by React hooks that need to react to in-session refreshes + * (e.g. when the SWR background fetch lands a newer schema than the one + * served at init). Note: on a cache-hit init, subscribers may observe two + * publishes — the cached value first, then the freshly refreshed value + * when the background fetch lands. The two values are usually identical. + */ +export const schemaAtom: Atom.Writable = + Atom.make(null); + /** * Reactive store of feature flag results, keyed by their normalized flag-key * request signature (see {@link normalizeFeatureFlagKeys}). Keeping every diff --git a/libraries/react-native/src/core/schema/schema-manager.ts b/libraries/react-native/src/core/schema/schema-manager.ts new file mode 100644 index 000000000..060ff3e0e --- /dev/null +++ b/libraries/react-native/src/core/schema/schema-manager.ts @@ -0,0 +1,163 @@ +import { Effect, Layer, ServiceMap } from "effect"; +import { AtomRegistry } from "effect/unstable/reactivity"; + +import { FailedToFetchSchemaError } from "../../errors"; +import { CacheManager } from "../caching/cache-manager"; +import { ApiClient } from "../networking/api-client"; +import { PlatformProvider } from "../platform/platform-provider"; +import { schemaAtom } from "../reactivity/client-state"; +import { getCommonSdkHeaders } from "../utils/get-common-sdk-headers"; +import type { RuntimeSchema } from "./runtime"; + +/** + * 30 days. Covers long offline gaps (user reopens the app after a month) + * while bounding cache staleness. Combined with the unconditional background + * refresh on cache hits this gives a stale-while-revalidate read path: hot + * sessions always get cached data immediately and the next session benefits + * from the refresh that landed in the background. + */ +const SCHEMA_CACHE_TTL_MS = 1000 * 60 * 60 * 24 * 30; + +const generateSchemaCacheKey = (appVersion: string) => `schema:${appVersion}`; + +const toFetchError = (cause: unknown): FailedToFetchSchemaError => { + const errorCause = cause instanceof Error ? cause : new Error(String(cause)); + return new FailedToFetchSchemaError( + "Failed to fetch schema at init", + errorCause + ); +}; + +interface ResolveSchemaArgs { + readonly distinctId: string; + readonly internalSchema?: RuntimeSchema; +} + +/** + * Resolves the runtime schema with a stale-while-revalidate cache keyed by + * the current app version. On a cold cache the synchronous fetch is fatal + * (`FailedToFetchSchemaError`). On a warm cache the cached value is returned + * immediately and a background fiber refreshes both the cache and + * `schemaAtom`. + * + * App-version keying matters because features in a new app build may + * reference products, locations, or perks that don't exist in an older + * cached schema — using a separate cache key per version makes upgrades + * safe by forcing a fresh fetch on the first launch of a new build. + */ +export class SchemaManager extends ServiceMap.Service()( + "rn-voidhash/SchemaManager", + { + make: Effect.gen(function* () { + const cacheManager = yield* CacheManager; + const apiClient = yield* ApiClient; + const platformProvider = yield* PlatformProvider; + const atomRegistry = yield* AtomRegistry.AtomRegistry; + + const publishSchema = (schema: RuntimeSchema) => { + atomRegistry.set(schemaAtom, schema); + }; + + const fetchFromServer = (distinctId: string) => + Effect.gen(function* () { + const commonHeaders = yield* getCommonSdkHeaders(); + return yield* apiClient.sdk.getSchema({ + headers: { + ...commonHeaders, + "x-distinct-id": distinctId, + }, + }); + }); + + const cacheAndPublish = (cacheKey: string, schema: RuntimeSchema) => + Effect.gen(function* () { + yield* cacheManager.set(cacheKey, schema, { + ttl: SCHEMA_CACHE_TTL_MS, + }); + publishSchema(schema); + }); + + /** + * Fork a background refresh that outlives `init()`'s scope. + * `forkDetach` decouples the fiber from the caller scope so a cache + * hit on init returns synchronously while the refresh still completes + * later. Long-term, the proper shape is a scoped consumer fiber + * backed by a queue (see `analytics/service.ts` for that pattern); + * `forkDetach` is the lightweight equivalent for a single-shot + * request. + */ + const scheduleBackgroundRefresh = ( + cacheKey: string, + distinctId: string + ) => + fetchFromServer(distinctId).pipe( + Effect.tap((schema) => cacheAndPublish(cacheKey, schema)), + Effect.catch((cause) => + Effect.logDebug( + "[voidhash] schema background refresh failed", + { cause } + ) + ), + // `startImmediately: true` so the fiber runs without waiting for + // the next yield point — important because `resolveSchema` + // returns synchronously after this and the caller may not yield + // again for some time. + Effect.forkDetach({ startImmediately: true }) + ); + + const resolveSchema = ({ + distinctId, + internalSchema, + }: ResolveSchemaArgs) => + Effect.gen(function* () { + // Test/internal escape hatch — never hit the cache or network. + if (internalSchema) { + publishSchema(internalSchema); + return internalSchema; + } + + const { appVersion } = platformProvider; + + // No app version means we can't safely key the cache (features in + // a future build could reference items missing from the cached + // schema). Skip the cache entirely and always fetch synchronously; + // a failure is fatal. + if (!appVersion) { + yield* Effect.logWarning( + "[voidhash] No appVersion available — skipping schema cache and fetching synchronously." + ); + const schema = yield* fetchFromServer(distinctId).pipe( + Effect.mapError(toFetchError) + ); + publishSchema(schema); + return schema; + } + + const cacheKey = generateSchemaCacheKey(appVersion); + const cached = yield* cacheManager.get(cacheKey); + + // Cache hit: serve immediately and unconditionally revalidate in + // the background. `CacheManager.get` already drops expired + // entries before returning, so any non-null hit is fresh enough + // to serve. + if (cached) { + publishSchema(cached.value); + yield* scheduleBackgroundRefresh(cacheKey, distinctId); + return cached.value; + } + + // Cache miss (including expired-and-dropped entries). Synchronous + // fetch — failures are fatal. + const schema = yield* fetchFromServer(distinctId).pipe( + Effect.mapError(toFetchError) + ); + yield* cacheAndPublish(cacheKey, schema); + return schema; + }); + + return { resolveSchema } as const; + }), + } +) { + static readonly layer = Layer.effect(this, this.make); +} diff --git a/libraries/react-native/src/errors.ts b/libraries/react-native/src/errors.ts index af0db917c..647c396c2 100644 --- a/libraries/react-native/src/errors.ts +++ b/libraries/react-native/src/errors.ts @@ -9,6 +9,8 @@ export class FailedToInitializeNativeAdapterError extends VoidhashError {} export class FailedToEndNativeAdapterError extends VoidhashError {} +export class FailedToFetchSchemaError extends VoidhashError {} + export class NotInitializedError extends VoidhashError { constructor() { super( diff --git a/libraries/react-native/tests/core/client-effect.test.ts b/libraries/react-native/tests/core/client-effect.test.ts index 7a59a1d57..dcea6148b 100644 --- a/libraries/react-native/tests/core/client-effect.test.ts +++ b/libraries/react-native/tests/core/client-effect.test.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Cause, Effect, Exit } from "effect"; import { vi } from "vitest"; import { @@ -14,7 +14,9 @@ import { SDK_VERSION } from "../../src/core/constants"; import { currentCustomerAtom, featureFlagsForKeysAtom, + schemaAtom, } from "../../src/core/reactivity/client-state"; +import { FailedToFetchSchemaError } from "../../src/errors"; import { createApiClientDouble, createEffectTestHarness, @@ -58,7 +60,10 @@ describe("VoidhashEffectClient", () => { ); expect(initializedClient).toHaveProperty("getProducts"); - expect(apiDouble.state.syncCustomerAttributesCalls).toHaveLength(2); + // `identify()` syncs attributes for the current cached distinctId + // internally, so init no longer makes a second explicit sync call + // before identify — one POST suffices. + expect(apiDouble.state.syncCustomerAttributesCalls).toHaveLength(1); expect(apiDouble.state.syncCustomerAttributesCalls[0]?.headers["x-distinct-id"]).toBe( "cached-before-init" ); @@ -104,6 +109,59 @@ describe("VoidhashEffectClient", () => { } }); + it("init without internalSchema fetches the schema once and publishes it to schemaAtom", async () => { + const remoteSchema = createTestSchema(); + const apiDouble = createApiClientDouble({ getSchemaResult: remoteSchema }); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + paymentAdapter: paymentDouble.paymentAdapter, + }); + // Module-level singleton: reset before asserting on it. + harness.atomRegistry.set(schemaAtom, null); + + try { + const initializedClient = await harness.runtime.runPromise( + VoidhashEffectClient.makeUnitializedClient().init({}) + ); + + expect(apiDouble.state.getSchemaCalls).toHaveLength(1); + expect(initializedClient.getSchema()).toEqual(remoteSchema); + expect(harness.atomRegistry.get(schemaAtom)).toEqual(remoteSchema); + } finally { + await harness.runtime.dispose(); + } + }); + + it("init fails fatally with FailedToFetchSchemaError when schema fetch fails and no cache exists", async () => { + const apiDouble = createApiClientDouble({ getSchemaShouldFail: true }); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + paymentAdapter: paymentDouble.paymentAdapter, + }); + harness.atomRegistry.set(schemaAtom, null); + + try { + const exit = await harness.runtime.runPromiseExit( + VoidhashEffectClient.makeUnitializedClient().init({}) + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(FailedToFetchSchemaError); + } + expect(harness.atomRegistry.get(schemaAtom)).toBeNull(); + } finally { + await harness.runtime.dispose(); + } + }); + it("getProducts caches native result and maps missing schema keys to null", async () => { const schema = createTestSchema(); const monthlyProduct = new Product( diff --git a/libraries/react-native/tests/core/schema-manager.test.ts b/libraries/react-native/tests/core/schema-manager.test.ts new file mode 100644 index 000000000..bbf403978 --- /dev/null +++ b/libraries/react-native/tests/core/schema-manager.test.ts @@ -0,0 +1,305 @@ +import { Cause, Effect, Exit } from "effect"; + +import { CacheManager } from "../../src/core/caching/cache-manager"; +import { schemaAtom } from "../../src/core/reactivity/client-state"; +import type { RuntimeSchema } from "../../src/core/schema/runtime"; +import { SchemaManager } from "../../src/core/schema/schema-manager"; +import { FailedToFetchSchemaError } from "../../src/errors"; +import { + createApiClientDouble, + createEffectTestHarness, + createInMemoryCacheAdapter, + createPaymentAdapterDouble, +} from "../helpers/effect-test-harness"; +import { describe, expect, it } from "../helpers/effect-vitest"; +import { createTestSchema } from "../helpers/test-schema"; + +/** + * Poll `predicate` until it returns true (or `timeoutMs` elapses) without + * relying on `vi.waitFor`, which isn't available under the project's bun + * test runner. + */ +const waitFor = async ( + predicate: () => boolean, + options: { timeoutMs?: number; intervalMs?: number } = {} +) => { + const timeoutMs = options.timeoutMs ?? 1_000; + const intervalMs = options.intervalMs ?? 5; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error(`waitFor timed out after ${timeoutMs}ms`); +}; + +const resolveSchemaEffect = (args: { + distinctId: string; + internalSchema?: RuntimeSchema; +}) => + Effect.flatMap(SchemaManager.asEffect(), (manager) => + manager.resolveSchema(args) + ); + +const createAltSchema = (): RuntimeSchema => ({ + version: "sha256:alt", + perks: {}, + locations: {}, + products: {}, +}); + +describe("SchemaManager", () => { + it("internalSchema bypasses cache and network and publishes to schemaAtom", async () => { + const apiDouble = createApiClientDouble(); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + paymentAdapter: paymentDouble.paymentAdapter, + }); + const internalSchema = createTestSchema(); + + try { + const result = await harness.runtime.runPromise( + resolveSchemaEffect({ + distinctId: "user-1", + internalSchema, + }) + ); + + expect(result).toEqual(internalSchema); + expect(apiDouble.state.getSchemaCalls).toHaveLength(0); + expect(harness.atomRegistry.get(schemaAtom)).toEqual(internalSchema); + // Cache should still be empty because we bypassed it entirely. + const cached = await harness.runtime.runPromise( + Effect.flatMap(CacheManager.asEffect(), (manager) => + manager.get("schema:1.0.0") + ) + ); + expect(cached).toBeNull(); + } finally { + await harness.runtime.dispose(); + } + }); + + it("cache miss fetches from server, caches under appVersion key, and publishes to atom", async () => { + const remoteSchema = createTestSchema(); + const apiDouble = createApiClientDouble({ getSchemaResult: remoteSchema }); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + paymentAdapter: paymentDouble.paymentAdapter, + }); + + try { + const result = await harness.runtime.runPromise( + resolveSchemaEffect({ distinctId: "user-1" }) + ); + + expect(result).toEqual(remoteSchema); + expect(apiDouble.state.getSchemaCalls).toHaveLength(1); + expect(harness.atomRegistry.get(schemaAtom)).toEqual(remoteSchema); + + const cached = await harness.runtime.runPromise( + Effect.flatMap(CacheManager.asEffect(), (manager) => + manager.get("schema:1.0.0") + ) + ); + expect(cached?.value).toEqual(remoteSchema); + } finally { + await harness.runtime.dispose(); + } + }); + + it("cache hit returns cached value immediately and schedules a background refresh", async () => { + const cachedSchema = createAltSchema(); + const refreshedSchema = createTestSchema(); + const apiDouble = createApiClientDouble({ + getSchemaResult: refreshedSchema, + }); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + paymentAdapter: paymentDouble.paymentAdapter, + }); + + try { + // Prime the cache with a different schema so we can tell which one + // is being returned. + await harness.runtime.runPromise( + Effect.flatMap(CacheManager.asEffect(), (manager) => + manager.set("schema:1.0.0", cachedSchema, { + ttl: 1000 * 60 * 60 * 24 * 30, + }) + ) + ); + + const result = await harness.runtime.runPromise( + resolveSchemaEffect({ distinctId: "user-1" }) + ); + + // The synchronous return value is the cached schema — what the + // caller actually receives. (The atom transitions cached → refreshed + // back-to-back in this test because every Effect is synchronous, so + // we don't assert the intermediate atom state here.) + expect(result).toEqual(cachedSchema); + + // The background refresh eventually lands the refreshed schema in + // both the atom and the cache. + await waitFor( + () => + apiDouble.state.getSchemaCalls.length === 1 && + harness.atomRegistry.get(schemaAtom) === refreshedSchema + ); + expect(apiDouble.state.getSchemaCalls).toHaveLength(1); + expect(harness.atomRegistry.get(schemaAtom)).toEqual(refreshedSchema); + + const cached = await harness.runtime.runPromise( + Effect.flatMap(CacheManager.asEffect(), (manager) => + manager.get("schema:1.0.0") + ) + ); + expect(cached?.value).toEqual(refreshedSchema); + } finally { + await harness.runtime.dispose(); + } + }); + + it("cache miss with failing fetch surfaces FailedToFetchSchemaError and leaves schemaAtom null", async () => { + const apiDouble = createApiClientDouble({ getSchemaShouldFail: true }); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + paymentAdapter: paymentDouble.paymentAdapter, + }); + // Reset atom because it's a module-level singleton — other tests may + // have left it populated. + harness.atomRegistry.set(schemaAtom, null); + + try { + const exit = await harness.runtime.runPromiseExit( + resolveSchemaEffect({ distinctId: "user-1" }) + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(FailedToFetchSchemaError); + } + expect(harness.atomRegistry.get(schemaAtom)).toBeNull(); + } finally { + await harness.runtime.dispose(); + } + }); + + it("changing app version misses the cache and fetches fresh", async () => { + const previousVersionSchema = createAltSchema(); + const remoteSchema = createTestSchema(); + const apiDouble = createApiClientDouble({ getSchemaResult: remoteSchema }); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + paymentAdapter: paymentDouble.paymentAdapter, + platform: { appVersion: "2.0.0" }, + }); + + try { + // Cache a schema under the OLD app version key — should not be used + // when the harness is configured for "2.0.0". + await harness.runtime.runPromise( + Effect.flatMap(CacheManager.asEffect(), (manager) => + manager.set("schema:1.0.0", previousVersionSchema, { + ttl: 1000 * 60 * 60 * 24 * 30, + }) + ) + ); + + const result = await harness.runtime.runPromise( + resolveSchemaEffect({ distinctId: "user-1" }) + ); + + expect(result).toEqual(remoteSchema); + expect(apiDouble.state.getSchemaCalls).toHaveLength(1); + + const cachedForNewVersion = await harness.runtime.runPromise( + Effect.flatMap(CacheManager.asEffect(), (manager) => + manager.get("schema:2.0.0") + ) + ); + expect(cachedForNewVersion?.value).toEqual(remoteSchema); + } finally { + await harness.runtime.dispose(); + } + }); + + it("missing appVersion skips the cache and fetches synchronously", async () => { + const remoteSchema = createTestSchema(); + const apiDouble = createApiClientDouble({ getSchemaResult: remoteSchema }); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + paymentAdapter: paymentDouble.paymentAdapter, + platform: { appVersion: undefined }, + }); + + try { + const result = await harness.runtime.runPromise( + resolveSchemaEffect({ distinctId: "user-1" }) + ); + + expect(result).toEqual(remoteSchema); + expect(apiDouble.state.getSchemaCalls).toHaveLength(1); + expect(harness.atomRegistry.get(schemaAtom)).toEqual(remoteSchema); + + // No cache key should have been written. + const cacheKeys = await harness.runtime.runPromise( + Effect.flatMap(CacheManager.asEffect(), (manager) => + manager.getCacheKeys() + ) + ); + expect( + cacheKeys.some((key) => key.startsWith("schema:")) + ).toBe(false); + } finally { + await harness.runtime.dispose(); + } + }); + + it("missing appVersion with failing fetch surfaces FailedToFetchSchemaError", async () => { + const apiDouble = createApiClientDouble({ getSchemaShouldFail: true }); + const paymentDouble = createPaymentAdapterDouble(); + const cache = createInMemoryCacheAdapter(); + const harness = createEffectTestHarness({ + apiClient: apiDouble.apiClient, + cacheAdapter: cache.adapter, + paymentAdapter: paymentDouble.paymentAdapter, + platform: { appVersion: undefined }, + }); + harness.atomRegistry.set(schemaAtom, null); + + try { + const exit = await harness.runtime.runPromiseExit( + resolveSchemaEffect({ distinctId: "user-1" }) + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(FailedToFetchSchemaError); + } + } finally { + await harness.runtime.dispose(); + } + }); +}); diff --git a/libraries/react-native/tests/helpers/effect-test-harness.ts b/libraries/react-native/tests/helpers/effect-test-harness.ts index 99dfe94b6..fc0ef1c31 100644 --- a/libraries/react-native/tests/helpers/effect-test-harness.ts +++ b/libraries/react-native/tests/helpers/effect-test-harness.ts @@ -20,6 +20,7 @@ import { PaymentAdapter } from "../../src/core/payment-adapters/payment-adapter" import type { PlatformInfo } from "../../src/core/platform/platform-provider"; import { PlatformProvider } from "../../src/core/platform/platform-provider"; import { ProductService } from "../../src/core/products/product-service"; +import { SchemaManager } from "../../src/core/schema/schema-manager"; import { SdkConfiguration } from "../../src/core/sdk-configuration"; import { TransactionService } from "../../src/core/transactions/transaction-service"; import { createTestSchema } from "./test-schema"; @@ -41,6 +42,7 @@ export type ApiSdkCall = { export interface ApiClientDoubleState { readonly evaluateFeatureFlagsCalls: ApiSdkCall[]; readonly getCustomerCalls: ApiSdkCall[]; + readonly getSchemaCalls: ApiSdkCall[]; readonly identifyCalls: ApiSdkCall[]; readonly syncCustomerAttributesCalls: ApiSdkCall[]; readonly syncTransactionCalls: ApiSdkCall[]; @@ -49,6 +51,8 @@ export interface ApiClientDoubleState { export interface ApiClientDoubleOptions { evaluateFeatureFlagsResult?: FeatureFlagsResult; getCustomerResult?: SdkCustomer; + getSchemaResult?: ReturnType; + getSchemaShouldFail?: boolean; identifyResult?: SdkCustomer; syncTransactionShouldFail?: boolean; } @@ -66,6 +70,7 @@ export function createApiClientDouble(options: ApiClientDoubleOptions = {}) { const state: ApiClientDoubleState = { evaluateFeatureFlagsCalls: [], getCustomerCalls: [], + getSchemaCalls: [], identifyCalls: [], syncCustomerAttributesCalls: [], syncTransactionCalls: [], @@ -73,7 +78,13 @@ export function createApiClientDouble(options: ApiClientDoubleOptions = {}) { const apiClient = { sdk: { - getSchema: () => Effect.succeed(createTestSchema()), + getSchema: (request: ApiSdkCall) => { + state.getSchemaCalls.push(request); + if (options.getSchemaShouldFail) { + return Effect.fail(new Error("getSchema failed")); + } + return Effect.succeed(options.getSchemaResult ?? createTestSchema()); + }, evaluateFeatureFlags: (request: ApiSdkCall) => { state.evaluateFeatureFlagsCalls.push(request); return Effect.succeed( @@ -275,6 +286,7 @@ export function createEffectTestHarness(options: EffectTestHarnessOptions) { Layer.provideMerge(LifecycleService.layer), Layer.provideMerge(Layer.succeed(LifecycleAdapter, lifecycle.adapter)), Layer.provideMerge(CustomerInfoManager.Default), + Layer.provideMerge(SchemaManager.layer), Layer.provideMerge(IdentityManager.Default), Layer.provideMerge(CacheManager.Default), Layer.provideMerge(Layer.succeed(CacheAdapter, options.cacheAdapter)), From 7ec202b1648c88e8253262824d5b8c6f76209125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Mon, 11 May 2026 23:00:43 +0200 Subject: [PATCH 018/129] fix: analytics --- .../utils/voidhash/client.ts | 52 ++++- .../src/core/analytics/service.ts | 204 ++++++++---------- .../tests/core/client-effect.test.ts | 11 +- 3 files changed, 147 insertions(+), 120 deletions(-) diff --git a/examples/react-native-example/utils/voidhash/client.ts b/examples/react-native-example/utils/voidhash/client.ts index a43e8dc2a..8282f7afb 100644 --- a/examples/react-native-example/utils/voidhash/client.ts +++ b/examples/react-native-example/utils/voidhash/client.ts @@ -1,4 +1,52 @@ import { createVoidhashClient } from "@voidhash/react-native"; +import Constants from "expo-constants"; + +const LOCALHOST_NAMES = new Set([ + "0.0.0.0", + "127.0.0.1", + "::1", + "[::1]", + "localhost", +]); + +const isLocalhost = (hostname: string) => + LOCALHOST_NAMES.has(hostname.toLowerCase()); + +const parseHostUriHostname = (hostUri: string | null | undefined) => { + if (!hostUri) return null; + + try { + const url = hostUri.includes("://") + ? new URL(hostUri) + : new URL(`http://${hostUri}`); + return url.hostname || null; + } catch { + return null; + } +}; + +const resolveExampleApiUrl = (baseUrl: string) => { + let url: URL; + try { + url = new URL(baseUrl); + } catch { + return baseUrl; + } + + if (!isLocalhost(url.hostname)) return baseUrl; + + const hostHostname = parseHostUriHostname( + Constants.expoConfig?.hostUri ?? Constants.expoGoConfig?.debuggerHost, + ); + if (!hostHostname || isLocalhost(hostHostname)) return baseUrl; + + url.hostname = hostHostname; + return url.toString(); +}; + +const apiUrl = process.env.EXPO_PUBLIC_VOIDHASH_API_URL + ? resolveExampleApiUrl(process.env.EXPO_PUBLIC_VOIDHASH_API_URL) + : undefined; /** * Voidhash client for the example app. @@ -11,8 +59,6 @@ export const voidhash = createVoidhashClient( "vh_pk_hrvyOZJoxtonGGPtTnkMehrCoEPsAbwD", { debug: true, - ...(process.env.EXPO_PUBLIC_VOIDHASH_API_URL - ? { baseUrl: process.env.EXPO_PUBLIC_VOIDHASH_API_URL } - : {}), + ...(apiUrl ? { baseUrl: apiUrl } : {}), }, ); diff --git a/libraries/react-native/src/core/analytics/service.ts b/libraries/react-native/src/core/analytics/service.ts index 7bf9ecf4a..38a679312 100644 --- a/libraries/react-native/src/core/analytics/service.ts +++ b/libraries/react-native/src/core/analytics/service.ts @@ -1,3 +1,7 @@ +import { + make as makeEventCaptureClient, + type VoidhashEventCaptureClient, +} from "@voidhash/generated-clients/event-capture"; import { Duration, Effect, @@ -7,14 +11,9 @@ import { Schedule, ServiceMap, } from "effect"; -import { - HttpClient, - HttpClientRequest, - HttpClientResponse, -} from "effect/unstable/http"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { CacheManager } from "../caching/cache-manager"; -import { SDK_VERSION } from "../constants"; import { IdentityManager } from "../identity/identity-manager"; import { SdkConfiguration } from "../sdk-configuration"; import { getNonce } from "../utils/crypto"; @@ -32,7 +31,13 @@ import { const ANALYTICS_BATCH_SIZE = 20; const ANALYTICS_FLUSH_INTERVAL_MS = 5000; const MAX_ANALYTICS_RETRY_DELAY_MS = 30_000; -const RETRYABLE_ANALYTICS_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]); +/** + * Status codes treated as retryable for raw HTTP failures that don't surface a + * typed error from the generated event-capture client (e.g. 408, 502, 504). + * 429/500/503 are also retryable but reach the catch handlers as their typed + * counterparts and so don't go through the status-set fallback. + */ +const RETRYABLE_HTTP_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]); const ANALYTICS_LAST_SEEN_APP_RELEASE_STORAGE_KEY = "voidhash:analytics:last-seen-app-release"; @@ -77,39 +82,6 @@ const parseRetryAfterMs = ( return Math.max(retryAt - Date.now(), 0); }; -const getRetryAfterMsFromResponseBody = ( - data: unknown -): number | undefined => { - if ( - data !== null && - typeof data === "object" && - "retry_after_ms" in data && - typeof (data as { retry_after_ms?: unknown }).retry_after_ms === "number" - ) { - return (data as { retry_after_ms: number }).retry_after_ms; - } - return undefined; -}; - -const resolveIngestEventsUrl = (options: { - baseUrl: string; - ingestUrl: string | undefined; -}) => { - const baseUrl = options.ingestUrl - ? new URL(options.ingestUrl) - : buildDefaultIngestBaseUrl(options.baseUrl); - return new URL("/batch", baseUrl).toString(); -}; - -const buildDefaultIngestBaseUrl = (apiBaseUrl: string) => { - const parsedApiUrl = new URL(apiBaseUrl); - parsedApiUrl.hostname = `i.${parsedApiUrl.hostname}`; - parsedApiUrl.hash = ""; - parsedApiUrl.pathname = "/"; - parsedApiUrl.search = ""; - return parsedApiUrl; -}; - /** * Inline retry schedule used inside `flush()`: exponential backoff capped at 3 * total attempts. Retry-After-bearing failures are excluded via the `while` @@ -149,27 +121,43 @@ export class AnalyticsService extends ServiceMap.Service()( const getStandardizedProperties = getAnalyticsStandardizedProperties(); let flushCallback: (() => void) | null = null; - const ingestEventsUrl = resolveIngestEventsUrl({ - baseUrl: sdkConfiguration.baseUrl, - ingestUrl: sdkConfiguration.ingestUrl, - }); + // The ingest endpoint lives on the same host as the API but under the + // `/i/v1/...` path prefix. The generated client owns the path, so we + // only need to inject the base origin via `prependUrl`. `ingestUrl` + // remains as an override for local/test ingest servers. + const ingestBaseUrl = sdkConfiguration.ingestUrl ?? sdkConfiguration.baseUrl; + const eventCaptureClient = makeEventCaptureClient( + httpClient as VoidhashEventCaptureClient["httpClient"], + { + transformClient: (client) => + Effect.succeed( + client.pipe( + HttpClient.mapRequest((request) => + HttpClientRequest.prependUrl(request, ingestBaseUrl) + ) + ) + ), + } + ); - const buildRetryableFailure = (response: HttpClientResponse.HttpClientResponse) => - Effect.gen(function* () { - const body = yield* response.json.pipe( - Effect.orElseSucceed(() => undefined as unknown) - ); - return yield* Effect.fail( - new AnalyticsSendFailure({ - message: `Analytics ingest request failed: ${response.status}`, - retryAfterMs: - parseRetryAfterMs(response.headers["retry-after"]) ?? - getRetryAfterMsFromResponseBody(body), - retryable: true, - status: response.status, - }) - ); - }); + const failNonRetryable = (status: number) => + Effect.fail( + new AnalyticsSendFailure({ + message: `Analytics ingest request failed: ${status}`, + retryable: false, + status, + }) + ); + + const failRetryable = (status: number, retryAfterMs?: number) => + Effect.fail( + new AnalyticsSendFailure({ + message: `Analytics ingest request failed: ${status}`, + retryAfterMs, + retryable: true, + status, + }) + ); const sendAnalyticsEvents = ( events: ReadonlyArray @@ -178,60 +166,58 @@ export class AnalyticsService extends ServiceMap.Service()( if (events.length === 0) return; const distinctId = yield* identityManager.getDistinctId(); - const request = HttpClientRequest.post(ingestEventsUrl).pipe( - HttpClientRequest.bodyJsonUnsafe({ - events: events.map((event) => ({ - context: event.context, - distinct_id: distinctId, - event: event.event_name, - properties: event.properties, - request: { - sdk_name: "react-native", - sdk_version: SDK_VERSION, - }, - session_id: event.session_id, - timestamp: event.event_ts, - uuid: event.event_id, - })), - sent_at: new Date().toISOString(), - token: sdkConfiguration.publishableKey, - }) - ); - - const response = yield* httpClient.execute(request).pipe( - Effect.catchTag("HttpClientError", (cause) => - Effect.fail( + yield* eventCaptureClient.eventCaptureBatch({ + events: events.map((event) => ({ + context: event.context, + distinct_id: distinctId, + event: event.event_name, + properties: event.properties, + session_id: event.session_id, + timestamp: event.event_ts, + uuid: event.event_id, + })), + sent_at: new Date().toISOString(), + token: sdkConfiguration.publishableKey, + }); + }).pipe( + Effect.catchTags({ + CaptureDependencyUnavailableError: (err) => + failRetryable(err.response.status), + CaptureInternalServerError: (err) => + failRetryable(err.response.status), + CapturePayloadTooLargeError: (err) => + failNonRetryable(err.response.status), + CaptureRateLimitedError: (err) => + failRetryable( + err.response.status, + parseRetryAfterMs(err.response.headers["retry-after"]) ?? + err.data.retry_after_ms ?? + undefined + ), + CaptureUnauthorizedError: (err) => + failNonRetryable(err.response.status), + EventCaptureBatch400: (err) => + failNonRetryable(err.response.status), + }), + // Unmapped status codes (e.g. 408/502/504) surface as + // `HttpClientError`; treat network errors and the retryable subset + // as retryable, everything else as non-retryable. + Effect.catchTag("HttpClientError", (cause) => { + const status = cause.response?.status; + if (status === undefined) { + return Effect.fail( new AnalyticsSendFailure({ cause, message: "Analytics request failed", retryable: true, }) - ) - ) - ); - - return yield* HttpClientResponse.matchStatus(response, { - "2xx": () => Effect.void, - 413: () => - Effect.fail( - new AnalyticsSendFailure({ - message: `Analytics ingest request failed: ${response.status}`, - retryable: false, - status: response.status, - }) - ), - orElse: (res) => - RETRYABLE_ANALYTICS_STATUS_CODES.has(res.status) - ? buildRetryableFailure(res) - : Effect.fail( - new AnalyticsSendFailure({ - message: `Analytics ingest request failed: ${res.status}`, - retryable: false, - status: res.status, - }) - ), - }); - }); + ); + } + return RETRYABLE_HTTP_STATUS_CODES.has(status) + ? failRetryable(status) + : failNonRetryable(status); + }) + ); // Inline retry wrapper used by the queue-draining `flush()` path. Public // `sendAnalyticsEvents` stays single-shot so callers can implement their diff --git a/libraries/react-native/tests/core/client-effect.test.ts b/libraries/react-native/tests/core/client-effect.test.ts index dcea6148b..9883f46bd 100644 --- a/libraries/react-native/tests/core/client-effect.test.ts +++ b/libraries/react-native/tests/core/client-effect.test.ts @@ -10,7 +10,6 @@ import { CacheManager } from "../../src/core/caching/cache-manager"; import { Product, SubscriptionProduct } from "../../src/core/entities/product"; import { Transaction } from "../../src/core/entities/transaction"; import { CustomerAttributeManager } from "../../src/core/identity/customer-attribute-manager"; -import { SDK_VERSION } from "../../src/core/constants"; import { currentCustomerAtom, featureFlagsForKeysAtom, @@ -648,7 +647,7 @@ describe("VoidhashEffectClient", () => { }; describe("sendAnalyticsEvents", () => { - it("sends analytics to derived i. subdomain by default", async () => { + it("sends analytics to the /i/v1/batch path on the API host by default", async () => { const originalFetch = global.fetch; const fetchMock = vi.fn().mockResolvedValue(acceptedAnalyticsResponse()); global.fetch = fetchMock as unknown as typeof global.fetch; @@ -681,7 +680,7 @@ describe("VoidhashEffectClient", () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "https://i.api.voidhash.test/batch" + "https://api.voidhash.test/i/v1/batch" ); const request = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined; @@ -697,10 +696,6 @@ describe("VoidhashEffectClient", () => { properties: { button_name: "Get Started", }, - request: { - sdk_name: "react-native", - sdk_version: SDK_VERSION, - }, session_id: "sess_1", timestamp: "2026-01-01T00:00:00.000Z", uuid: "evt_1", @@ -741,7 +736,7 @@ describe("VoidhashEffectClient", () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "http://localhost:8083/batch" + "http://localhost:8083/i/v1/batch" ); } finally { global.fetch = originalFetch; From de175200b58b65b7674c033fe3cd52c873d3f98d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Wed, 3 Jun 2026 10:41:08 +0200 Subject: [PATCH 019/129] wip: paywall specs --- PAYWALLS-MVP.md | 28 +++++++++++++++++++ .../.voidhash/components/canvas.tsx | 17 +++++++++++ .../.voidhash/paywalls/onboarding-green.tsx | 18 ++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 PAYWALLS-MVP.md create mode 100644 examples/react-native-example/.voidhash/components/canvas.tsx create mode 100644 examples/react-native-example/.voidhash/paywalls/onboarding-green.tsx diff --git a/PAYWALLS-MVP.md b/PAYWALLS-MVP.md new file mode 100644 index 000000000..58aac8aa4 --- /dev/null +++ b/PAYWALLS-MVP.md @@ -0,0 +1,28 @@ +# Paywalls MVP + +## Context +Voidhash is a Google Play and App Store subscription management platform. It includes analytics, revenue tracking, server side recipe validation and much more. + +## Objective +In this task, we want to add highly requested feature - Paywalls. Similar to Superwall, we want to enable our customers to quickly change and test their paywalls without re-deploying their app. In the future, we will have a GUI paywall builder but for now, we will have fully code driven paywall building experience. For this task, I have scaffolded an MVP version of the code, that will define both paywalls and re-usable components in ./examples/react-native-example (in a .voidhash folder) + +## Paywalls +Paywalls are screens in a mobile app where the app user can purchase something - this could be subscription, one time items etc. Each app needs it's own distinct look and have a different use-case for it. In our MVP, paywalls are code driven and they are defined via a createPaywall. Both paywalls and components will live in .voidhash folder, that will be scaffolded by the CLI. There will be 2 folders - components for re-usable component definitions and paywalls for individual paywall designs. + +## Components +As mentioned previously, components are re-usable primitives across paywalls. They are mostly designed to add dynamic, interactive logic (carousels, sheets etc.). + + +## How will they work, custom react, renderers +Paywalls will be powered by react. Instead of using react-dom, we will impement our own renderer (that will use HTML DOM under the hood). We want to build a scoped, abstract way for building paywalls to allow as to build native renderers in the future as well. The API will be similar to react-native with View, Text, Pressable, ScrollView etc. + +## Studio +Studio is vite js application, that is ran via our CLI. It previews the paywalls and refreshes in real-time as the paywall changes. There will be a sidepanel on the right with list of all paywalls. We can switch between them. In the middle, there will be 9:16 aspect ratio (phone) preview. It will use tailwind for styling and shadcn for UI primitives. + +## CLI +We need to extend our CLI with few commands +```voidhash-cli studio``` will launch the paywall preview studio +```voidhash-cli deploy``` will build and deploy everything to Voidhash + +# Bundling and deployment +As part of this task, we need to define the entire flow and specify, what will be sent to our server and in what format. Modifying the backend service will be the next task. We need to create final html / js, that will be server and rendered in a mobile webview as the paywall itself. We also want to send the raw source code files to the server (both, components and paywalls) and we need to deploy assets. Create PAYWALLS-MVP-SERVER-SPEC.md that will map all the requirements and protocols the server should implement for it to integrate together well. \ No newline at end of file diff --git a/examples/react-native-example/.voidhash/components/canvas.tsx b/examples/react-native-example/.voidhash/components/canvas.tsx new file mode 100644 index 000000000..28d7b7eb6 --- /dev/null +++ b/examples/react-native-example/.voidhash/components/canvas.tsx @@ -0,0 +1,17 @@ + +import { defineComponent, View, Text, Slot } from "@voidhash/paywalls" + +export const componentDefinition = defineComponent({ + props: (c) => ({ + title: c.string().withLabel("Title").withDefault("Untitled") + }) +}) + +export default componentDefinition.render((props) => { + return ( + + Canvas {props.title} + + + ) +}) diff --git a/examples/react-native-example/.voidhash/paywalls/onboarding-green.tsx b/examples/react-native-example/.voidhash/paywalls/onboarding-green.tsx new file mode 100644 index 000000000..d61c98e07 --- /dev/null +++ b/examples/react-native-example/.voidhash/paywalls/onboarding-green.tsx @@ -0,0 +1,18 @@ +import { createPaywall, View, Pressable, Text } from "@voidhash/paywalls" +import Canvas from '../components/canvas'; + +const paywall = createPaywall({ + title: "Onboarding paywall", + render: ( + + + Hello World + + + Hello + + + ) +}) + +export default paywall; From b517ec7d0ed6f8558b161ead6f7702ce9e08efba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Fri, 12 Jun 2026 09:57:28 +0200 Subject: [PATCH 020/129] feat: paywalls and refactor --- .claude/launch.json | 23 + PAYWALLS-MVP-SERVER-SPEC.md | 407 +++++++ apps/cli/build.dev.ts | 8 +- apps/cli/build.ts | 10 +- apps/cli/package.json | 6 +- apps/cli/src/cli/commands/deploy.ts | 179 +++ apps/cli/src/cli/commands/studio.ts | 133 +++ apps/cli/src/cli/index.ts | 26 +- apps/cli/src/domain/schema/paywall-deploy.ts | 163 +++ apps/cli/src/domain/services/paywall-build.ts | 1018 +++++++++++++++++ .../domain/services/paywall-closed-imports.ts | 121 ++ .../domain/services/paywall-deploy-upload.ts | 397 +++++++ .../src/domain/services/paywall-typecheck.ts | 186 +++ apps/cli/src/utils/api-client.ts | 2 +- .../domain/schema/paywall-deploy.test.ts | 159 +++ .../services/paywall-build-warnings.test.ts | 72 ++ .../services/paywall-closed-imports.test.ts | 142 +++ .../services/paywall-content-hash.test.ts | 106 ++ .../services/paywall-deploy-upload.test.ts | 270 +++++ .../domain/services/paywall-typecheck.test.ts | 102 ++ apps/studio/index.html | 12 + apps/studio/package.json | 40 + apps/studio/src/App.tsx | 88 ++ .../src/components/ComponentPreview.tsx | 136 +++ apps/studio/src/components/PaywallPreview.tsx | 36 + apps/studio/src/components/PhoneFrame.tsx | 28 + .../src/components/PreviewErrorBoundary.tsx | 52 + apps/studio/src/components/Sidebar.tsx | 206 ++++ apps/studio/src/components/ui/button.tsx | 44 + apps/studio/src/index.css | 20 + apps/studio/src/lib/cn.ts | 4 + apps/studio/src/main.tsx | 14 + apps/studio/src/server/config.ts | 54 + apps/studio/src/server/index.ts | 50 + .../src/server/virtual-paywalls-plugin.ts | 159 +++ apps/studio/src/vite-env.d.ts | 20 + apps/studio/src/voidhash/paywalls.ts | 131 +++ apps/studio/src/voidhash/preview-runtime.ts | 91 ++ apps/studio/tsconfig.json | 11 + apps/studio/vite.config.ts | 19 + docs/specs/paywall-deploy-contract.md | 297 +++++ examples/react-native-example/.gitignore | 4 +- .../.voidhash/components/canvas.tsx | 32 +- .../.voidhash/components/product-option.tsx | 73 ++ .../.voidhash/paywalls/onboarding-green.tsx | 18 - .../.voidhash/paywalls/onboarding.tsx | 82 ++ examples/react-native-example/package.json | 1 + .../react-native-example/voidhash.config.ts | 4 +- libraries/paywalls/README.md | 121 ++ libraries/paywalls/build.ts | 50 + libraries/paywalls/package.json | 102 ++ libraries/paywalls/src/authoring/actions.ts | 119 ++ .../paywalls/src/authoring/create-paywall.ts | 81 ++ .../src/authoring/define-component.tsx | 186 +++ libraries/paywalls/src/authoring/manifest.ts | 172 +++ libraries/paywalls/src/authoring/props.ts | 254 ++++ libraries/paywalls/src/dom.tsx | 77 ++ libraries/paywalls/src/index.ts | 192 ++++ .../paywalls/src/internal/action-brand.ts | 33 + libraries/paywalls/src/panel.ts | 43 + .../paywalls/src/primitives/components.tsx | 53 + .../paywalls/src/primitives/host-context.tsx | 34 + libraries/paywalls/src/primitives/slot.tsx | 29 + libraries/paywalls/src/primitives/types.ts | 102 ++ libraries/paywalls/src/renderer/dom-host.tsx | 227 ++++ .../paywalls/src/renderer/error-boundary.tsx | 49 + .../src/renderer/paywall-renderer.tsx | 47 + libraries/paywalls/src/runtime/bridge.ts | 202 ++++ libraries/paywalls/src/runtime/config.ts | 90 ++ libraries/paywalls/src/runtime/envelope.ts | 306 +++++ libraries/paywalls/src/runtime/runtime.tsx | 268 +++++ .../paywalls/src/schema/component-manifest.ts | 111 ++ libraries/paywalls/src/schema/node-tree.ts | 73 ++ libraries/paywalls/src/schema/style.ts | 211 ++++ libraries/paywalls/src/style/resolve.ts | 117 ++ .../src/tree-renderer/react-reconciler.d.ts | 53 + .../src/tree-renderer/render-to-node-tree.tsx | 434 +++++++ .../paywalls/src/tree-renderer/tree-host.ts | 95 ++ libraries/paywalls/src/tree.ts | 30 + libraries/paywalls/tests/envelope.test.ts | 242 ++++ libraries/paywalls/tests/manifest.test.tsx | 250 ++++ .../paywalls/tests/observability.test.tsx | 161 +++ libraries/paywalls/tests/render.test.tsx | 147 +++ libraries/paywalls/tests/style.test.ts | 140 +++ libraries/paywalls/tests/tree.test.tsx | 256 +++++ libraries/paywalls/tests/types.test.ts | 156 +++ libraries/paywalls/tsconfig.json | 10 + libraries/paywalls/vitest.unit.mts | 12 + libraries/react-native/src/client-effect.ts | 85 +- libraries/react-native/src/client.tsx | 105 +- .../core/paywalls/paywall-runtime-config.ts | 96 ++ .../src/core/paywalls/paywall-service.ts | 24 +- .../src/internal/paywall-bridge/parser.ts | 21 +- .../src/internal/paywall-bridge/protocol.ts | 113 +- .../react/hooks/use-paywall-by-location.ts | 253 +++- .../tests/core/paywall-runtime-config.test.ts | 175 +++ .../internal/paywall-bridge-configure.test.ts | 113 ++ .../internal/paywall-bridge-parser.test.ts | 80 +- .../react/use-paywall-by-location.test.tsx | 309 ++++- .../generated-clients/src/core/generated.ts | 10 +- pnpm-lock.yaml | 569 +++++++-- 101 files changed, 12287 insertions(+), 257 deletions(-) create mode 100644 .claude/launch.json create mode 100644 PAYWALLS-MVP-SERVER-SPEC.md create mode 100644 apps/cli/src/cli/commands/deploy.ts create mode 100644 apps/cli/src/cli/commands/studio.ts create mode 100644 apps/cli/src/domain/schema/paywall-deploy.ts create mode 100644 apps/cli/src/domain/services/paywall-build.ts create mode 100644 apps/cli/src/domain/services/paywall-closed-imports.ts create mode 100644 apps/cli/src/domain/services/paywall-deploy-upload.ts create mode 100644 apps/cli/src/domain/services/paywall-typecheck.ts create mode 100644 apps/cli/tests/domain/schema/paywall-deploy.test.ts create mode 100644 apps/cli/tests/domain/services/paywall-build-warnings.test.ts create mode 100644 apps/cli/tests/domain/services/paywall-closed-imports.test.ts create mode 100644 apps/cli/tests/domain/services/paywall-content-hash.test.ts create mode 100644 apps/cli/tests/domain/services/paywall-deploy-upload.test.ts create mode 100644 apps/cli/tests/domain/services/paywall-typecheck.test.ts create mode 100644 apps/studio/index.html create mode 100644 apps/studio/package.json create mode 100644 apps/studio/src/App.tsx create mode 100644 apps/studio/src/components/ComponentPreview.tsx create mode 100644 apps/studio/src/components/PaywallPreview.tsx create mode 100644 apps/studio/src/components/PhoneFrame.tsx create mode 100644 apps/studio/src/components/PreviewErrorBoundary.tsx create mode 100644 apps/studio/src/components/Sidebar.tsx create mode 100644 apps/studio/src/components/ui/button.tsx create mode 100644 apps/studio/src/index.css create mode 100644 apps/studio/src/lib/cn.ts create mode 100644 apps/studio/src/main.tsx create mode 100644 apps/studio/src/server/config.ts create mode 100644 apps/studio/src/server/index.ts create mode 100644 apps/studio/src/server/virtual-paywalls-plugin.ts create mode 100644 apps/studio/src/vite-env.d.ts create mode 100644 apps/studio/src/voidhash/paywalls.ts create mode 100644 apps/studio/src/voidhash/preview-runtime.ts create mode 100644 apps/studio/tsconfig.json create mode 100644 apps/studio/vite.config.ts create mode 100644 docs/specs/paywall-deploy-contract.md create mode 100644 examples/react-native-example/.voidhash/components/product-option.tsx delete mode 100644 examples/react-native-example/.voidhash/paywalls/onboarding-green.tsx create mode 100644 examples/react-native-example/.voidhash/paywalls/onboarding.tsx create mode 100644 libraries/paywalls/README.md create mode 100644 libraries/paywalls/build.ts create mode 100644 libraries/paywalls/package.json create mode 100644 libraries/paywalls/src/authoring/actions.ts create mode 100644 libraries/paywalls/src/authoring/create-paywall.ts create mode 100644 libraries/paywalls/src/authoring/define-component.tsx create mode 100644 libraries/paywalls/src/authoring/manifest.ts create mode 100644 libraries/paywalls/src/authoring/props.ts create mode 100644 libraries/paywalls/src/dom.tsx create mode 100644 libraries/paywalls/src/index.ts create mode 100644 libraries/paywalls/src/internal/action-brand.ts create mode 100644 libraries/paywalls/src/panel.ts create mode 100644 libraries/paywalls/src/primitives/components.tsx create mode 100644 libraries/paywalls/src/primitives/host-context.tsx create mode 100644 libraries/paywalls/src/primitives/slot.tsx create mode 100644 libraries/paywalls/src/primitives/types.ts create mode 100644 libraries/paywalls/src/renderer/dom-host.tsx create mode 100644 libraries/paywalls/src/renderer/error-boundary.tsx create mode 100644 libraries/paywalls/src/renderer/paywall-renderer.tsx create mode 100644 libraries/paywalls/src/runtime/bridge.ts create mode 100644 libraries/paywalls/src/runtime/config.ts create mode 100644 libraries/paywalls/src/runtime/envelope.ts create mode 100644 libraries/paywalls/src/runtime/runtime.tsx create mode 100644 libraries/paywalls/src/schema/component-manifest.ts create mode 100644 libraries/paywalls/src/schema/node-tree.ts create mode 100644 libraries/paywalls/src/schema/style.ts create mode 100644 libraries/paywalls/src/style/resolve.ts create mode 100644 libraries/paywalls/src/tree-renderer/react-reconciler.d.ts create mode 100644 libraries/paywalls/src/tree-renderer/render-to-node-tree.tsx create mode 100644 libraries/paywalls/src/tree-renderer/tree-host.ts create mode 100644 libraries/paywalls/src/tree.ts create mode 100644 libraries/paywalls/tests/envelope.test.ts create mode 100644 libraries/paywalls/tests/manifest.test.tsx create mode 100644 libraries/paywalls/tests/observability.test.tsx create mode 100644 libraries/paywalls/tests/render.test.tsx create mode 100644 libraries/paywalls/tests/style.test.ts create mode 100644 libraries/paywalls/tests/tree.test.tsx create mode 100644 libraries/paywalls/tests/types.test.ts create mode 100644 libraries/paywalls/tsconfig.json create mode 100644 libraries/paywalls/vitest.unit.mts create mode 100644 libraries/react-native/src/core/paywalls/paywall-runtime-config.ts create mode 100644 libraries/react-native/tests/core/paywall-runtime-config.test.ts create mode 100644 libraries/react-native/tests/internal/paywall-bridge-configure.test.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..d68adbb21 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,23 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "studio", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["--filter", "@voidhash/studio", "dev", "--port", "4830"], + "port": 4830 + }, + { + "name": "deployed", + "runtimeExecutable": "python3", + "runtimeArgs": [ + "-m", + "http.server", + "4831", + "--directory", + "examples/react-native-example/.voidhash/.build" + ], + "port": 4831 + } + ] +} diff --git a/PAYWALLS-MVP-SERVER-SPEC.md b/PAYWALLS-MVP-SERVER-SPEC.md new file mode 100644 index 000000000..15158a527 --- /dev/null +++ b/PAYWALLS-MVP-SERVER-SPEC.md @@ -0,0 +1,407 @@ +# Paywalls MVP — Server Protocol Spec + +This document specifies the backend Voidhash must implement so the paywall system +(`@voidhash/paywalls`, the Studio, and the `voidhash-cli deploy`/`studio` +commands) integrates end-to-end. It is the contract between three actors: + +| Actor | Role | +| --- | --- | +| **CLI** (`voidhash-cli deploy`) | Builds paywalls and **uploads** the deploy payload. | +| **Server** (this spec) | Stores deploys, serves the live paywall to devices, resolves placements + entitlements. | +| **Device SDK** (`@voidhash/react-native`) | Presents a paywall in a WebView, **injects** runtime config, handles the **bridge**. | + +The client side is implemented today. Everything marked **[server]** is what this +task hands off; everything marked **[done]** already exists in this repo and the +server must remain compatible with it. + +> **Source of truth for shapes.** The TypeScript contracts referenced here are +> real and version-locked: +> - Deploy payload: [`apps/cli/src/domain/schema/paywall-deploy.ts`](apps/cli/src/domain/schema/paywall-deploy.ts) (`DeployManifest`, `schemaVersion: 1`). +> - Runtime config: [`libraries/paywalls/src/runtime/config.ts`](libraries/paywalls/src/runtime/config.ts) (`PaywallRuntimeConfig`). +> - Bridge protocol: [`libraries/paywalls/src/runtime/bridge.ts`](libraries/paywalls/src/runtime/bridge.ts) (`PaywallOutboundMessage`, `PaywallInboundMessage`). +> The server MUST keep these in sync; bump `schemaVersion` on any breaking change. + +--- + +## 1. Concepts & glossary + +- **Paywall** — a code-driven screen authored in `.voidhash/paywalls/.tsx`. + Compiles to a self-contained HTML+JS bundle. +- **Component** — a reusable piece in `.voidhash/components/.tsx`. Not served + standalone; shipped as raw source only (for the future GUI builder + diffing). +- **Asset** — a binary (image/font) referenced by a paywall, content-addressed. +- **Deploy** — one immutable upload of all paywalls/components/assets for a + project, produced by `voidhash-cli deploy`. +- **Placement** (a.k.a. *location*) — a named slot in the app (e.g. `onboarding`, + `settings_upgrade`) that the SDK presents. A placement is *assigned* a paywall. + This indirection is what lets customers swap paywalls without an app release. +- **Content hash** — lowercase hex SHA-256. Every file and every paywall has one; + identical content ⇒ identical hash ⇒ dedupe + cache key. +- **Release / channel** — a pointer that maps placements → paywall versions for a + given audience (e.g. `production`, `staging`). The device resolves through a + channel, never directly to a deploy. + +--- + +## 2. The full flow + +``` + author .voidhash/*.tsx + │ voidhash-cli deploy + ▼ + ┌──────────────┐ POST /v1/paywalls/deploys (manifest + files) ┌──────────┐ + │ CLI │ ───────────────────────────────────────────▶ │ Server │ + └──────────────┘ ◀─── 201 { deployId, missingFiles? } └────┬─────┘ + │ PUT missing blobs (content-addressed) │ store deploy (immutable) + │ POST .../finalize │ assign placements (manual or auto) + ▼ ▼ + dashboard: assign placement → paywall, publish to a channel + │ + ▼ + ┌──────────────┐ GET /v1/paywalls/resolve?placement=onboarding ┌──────────┐ + │ Device SDK │ ───────────────────────────────────────────────▶│ Server │ + └──────┬───────┘ ◀── { url, contentHash, products, variables } └──────────┘ + │ open WebView(url), inject window.__VOIDHASH_PAYWALL__ + │ bundle boots → renders paywall + ▼ + user taps "Subscribe" → bridge postMessage → SDK runs StoreKit/Billing + │ SDK pushes status back into the WebView + ▼ + purchase complete → SDK dismisses paywall, unlocks entitlement +``` + +--- + +## 3. Deploy API **[server]** + +The CLI today builds the payload and writes it to `.voidhash/.build/` with a +`manifest.json`; the upload call is the only missing piece (see the `// Upload` +block in [`apps/cli/src/cli/commands/deploy.ts`](apps/cli/src/cli/commands/deploy.ts)). +Implement the endpoints below; wiring the CLI to them is a one-function change. + +### 3.1 Authentication + +All deploy endpoints require a **secret** API key (`x-api-key: vh_sk_…`), the same +scheme the CLI already uses (see [`apps/cli/src/utils/api-client.ts`](apps/cli/src/utils/api-client.ts)). +The key authorizes a single `{team, project}`. Reject if `manifest.team` / +`manifest.project` don't match the key's scope (`403`). + +### 3.2 Content-addressed upload (two-phase) + +To avoid re-uploading unchanged bundles/assets on every deploy, uploads are +content-addressed and two-phase. + +**Phase 1 — create the deploy.** The CLI POSTs the **manifest only**. + +``` +POST /v1/paywalls/deploys +Content-Type: application/json +x-api-key: vh_sk_… + + // exactly the manifest.json the CLI produced +``` + +The manifest lists every file with its `sha256`. The server responds with the +deploy id and the subset of hashes it does **not** already have stored: + +``` +201 Created +{ + "deployId": "dep_…", + "missing": ["", "", …] // upload only these +} +``` + +**Phase 2 — upload missing blobs.** For each missing hash, the CLI uploads the +raw bytes. Content-addressed, so the path is the hash: + +``` +PUT /v1/paywalls/deploys/dep_…/blobs/ +Content-Type: application/octet-stream +x-api-key: vh_sk_… + + +``` + +The server MUST verify `sha256(body) === ` and reject mismatches (`422`). +A blob already present returns `200`/`204` (idempotent). + +**Finalize.** Once all blobs are present: + +``` +POST /v1/paywalls/deploys/dep_…/finalize +→ 200 { "deployId", "paywalls": [{ "id", "contentHash" }], "status": "ready" } +``` + +The server validates that every file referenced by the manifest now resolves to a +stored blob; if any are missing it returns `409 { missing: […] }`. Finalize is the +commit point — a deploy is **immutable** afterward. + +> A simple server MAY accept the whole payload in one multipart request instead of +> the two-phase flow (see §3.4). The two-phase flow is recommended because most +> deploys change one paywall, so most blobs are already stored. + +### 3.3 What's in the payload + +The `DeployManifest` (schema v1) groups everything (paths are relative to the +project root, POSIX-separated): + +```jsonc +{ + "schemaVersion": 1, + "cliVersion": "0.0.1-alpha.1", + "runtimeVersion": "0.0.1-alpha.1", // @voidhash/paywalls version built against + "team": "voidhash-dev-sro", + "project": "dev-proj", + "createdAt": "2026-06-03T10:00:00.000Z", + "paywalls": [ + { + "id": "onboarding-green", + "title": "Onboarding (Green)", + "description": "Full-screen onboarding paywall with selectable plans.", + "source": { "path": ".voidhash/paywalls/onboarding-green.tsx", "bytes": 4096, "sha256": "…" }, + "artifacts": { + "html": { "path": ".voidhash/.build/onboarding-green/index.html", "bytes": 900, "sha256": "…", "contentType": "text/html; charset=utf-8" }, + "js": { "path": ".voidhash/.build/onboarding-green/bundle.js", "bytes": 201000,"sha256": "…", "contentType": "text/javascript; charset=utf-8" } + }, + "assets": [".voidhash/.build/onboarding-green/assets/hero-AB12CD.png"], + "contentHash": "5b00934c90ee…" // identity of the deployable paywall + } + ], + "components": [ + { "id": "product-option", "source": { "path": ".voidhash/components/product-option.tsx", "bytes": 1500, "sha256": "…" } } + ], + "config": { "path": "voidhash.config.ts", "bytes": 120, "sha256": "…" }, + "assets": [ + { "path": ".voidhash/.build/onboarding-green/assets/hero-AB12CD.png", "bytes": 88000, "sha256": "…", "contentType": "image/png" } + ] +} +``` + +Three classes of content, all in one deploy: + +1. **Compiled artifacts** — `paywalls[].artifacts.html` + `.js`: the WebView-ready + bundle (the paywall React tree + the DOM renderer + React, IIFE, minified, + `NODE_ENV=production`, targeting `es2019`/`safari13`). This is what the device + renders. +2. **Raw source** — `paywalls[].source`, `components[].source`, `config`: the + author's `.tsx`/config. Stored for the future GUI builder, deploy diffing, and + support. **Not** served to devices. +3. **Assets** — `assets[]`: binaries referenced by bundles, content-addressed. + +### 3.4 Single-request fallback + +For a minimal first server, accept `multipart/form-data` at +`POST /v1/paywalls/deploys` with one `manifest` part (JSON) and one part per file +keyed by its `sha256`. Server validates hashes and finalizes atomically. Same +result, fewer round-trips; lose the dedupe optimization. + +### 3.5 `contentHash` semantics + +`paywalls[].contentHash = sha256( sha256(html) : sha256(js) : sorted(asset hashes) )` +(see `buildPaywalls` in [`apps/cli/src/domain/services/paywall-build.ts`](apps/cli/src/domain/services/paywall-build.ts)). +The server MUST treat it as the deployable paywall's identity: dedupe storage by +it, use it as the device cache key (§4.3), and expose it in resolve responses. + +--- + +## 4. Delivery API **[server]** + +How a device gets a paywall to show. The SDK never references a deploy or a raw +paywall id directly — it asks for a **placement**, and the server resolves the +placement through the active **channel** to a concrete paywall version + the +products/variables to inject. + +### 4.1 Resolve a placement + +``` +GET /v1/paywalls/resolve?placement=onboarding&platform=ios&locale=en-US +x-api-key: vh_pk_… // PUBLISHABLE key (safe to ship in the app) +``` + +```jsonc +200 OK +{ + "placement": "onboarding", + "paywall": { + "id": "onboarding-green", + "contentHash": "5b00934c90ee…", + "url": "https://paywalls.voidhash.com/p/5b00934c90ee…/index.html", + "assetBaseUrl": "https://paywalls.voidhash.com/p/5b00934c90ee…/" + }, + "runtime": { // becomes window.__VOIDHASH_PAYWALL__ (see §5.1) + "products": [ + { "id": "com.app.pro.yearly", "displayName": "Yearly", "priceString": "$59.99", + "price": 59.99, "currencyCode": "USD", "period": "year", "trialPeriod": "7d" } + ], + "variables": { "accentColor": "#16a34a" }, + "locale": "en-US", + "platform": "ios", + "defaultSelectedProductId": "com.app.pro.yearly" + }, + "presentation": { "style": "fullScreen" } // optional SDK presentation hints +} +``` + +- **No paywall assigned** to the placement → `204 No Content`. The SDK shows + nothing (or a hardcoded fallback). Never error the app over a missing paywall. +- **`products`** — the server resolves the placement's product group to store + product ids. The SDK enriches localized price/title from StoreKit/Billing and + presents; the server's `priceString`/`price` are display fallbacks. (The + `products`/`variables` map 1:1 onto `PaywallRuntimeConfig`; see §5.1.) +- **`variables`** — author-overridable values, the seam for A/B tests & + remote config. Returning different paywall ids or variables per audience is how + experiments are run — opaque to this MVP, but `resolve` is the hook. + +### 4.2 Serving the bundle + +`paywall.url` serves the stored `index.html`; `assetBaseUrl` + the asset filename +serves each asset. The HTML references `./bundle.js` and `./assets/…` relatively, +so **all three must be served under the same path prefix** (`/p//`). +Recommended: object storage + CDN, immutable + long `Cache-Control` (content is +addressed by hash, so it never changes for a given URL). + +`Content-Type` per the manifest. Set permissive CORS / WebView-friendly headers. +The SDK MAY also pre-fetch and serve the bundle from a local cache (see §4.3). + +### 4.3 Caching & offline + +- The bundle URL is immutable per `contentHash`; the SDK SHOULD cache by it and + reuse across launches, only re-downloading when `resolve` returns a new hash. +- The SDK SHOULD pre-warm the current paywall on app start so presentation is + instant and works offline. +- `resolve` responses are short-lived (products/prices change); the bundle is + effectively permanent. + +--- + +## 5. Runtime contract **[done — server must conform]** + +Once the WebView loads the bundle, two channels connect it to the native app. + +### 5.1 Config injection + +Before the bundle script runs, the SDK MUST set the global +`window.__VOIDHASH_PAYWALL__` to the `runtime` object from §4.1. On a React Native +WebView this is `injectedJavaScriptBeforeContentLoaded`: + +```js +`window.__VOIDHASH_PAYWALL__ = ${JSON.stringify(runtime)};` +``` + +The bundle reads it via `readInjectedConfig()` and exposes it to author code +through `usePaywallProducts()`, `usePaywallVariables()`, `useSelectedProduct()`, +`usePaywallStatus()`. The shape is `PaywallRuntimeConfig` +([`config.ts`](libraries/paywalls/src/runtime/config.ts)) — the server's `resolve` +`runtime` block MUST match it field-for-field. If injection is skipped the paywall +still mounts with no products (safe default). + +### 5.2 The native bridge + +Author actions (`usePaywallActions()`) are **requests** the paywall sends up to +the native host; the host owns the actual store transaction and pushes status +back down. The wire format is fixed in +[`bridge.ts`](libraries/paywalls/src/runtime/bridge.ts). + +**Outbound — WebView → native** (delivered via `window.ReactNativeWebView.postMessage(json)`). +The SDK parses `event.nativeEvent.data` as JSON: + +| `type` | Payload | Native action | +| --- | --- | --- | +| `voidhash.paywall.ready` | — | Paywall mounted; safe to inject status. | +| `voidhash.paywall.purchase` | `{ productId }` | Start StoreKit/Billing purchase for `productId`. | +| `voidhash.paywall.restore` | — | Restore entitlements. | +| `voidhash.paywall.close` | — | Dismiss the paywall. | +| `voidhash.paywall.openUrl` | `{ url }` | Open `url` (terms/privacy) in a browser. | +| `voidhash.paywall.event` | `{ name, properties? }` | Forward to analytics (ties into existing Voidhash analytics). | + +**Inbound — native → WebView.** The SDK calls the function the runtime installs on +`window`: + +```js +webview.injectJavaScript(`window.__voidhashPaywallReceive(${JSON.stringify(msg)});`) +``` + +| `type` | Payload | +| --- | --- | +| `voidhash.paywall.status` | `{ status, productId?, error? }` where `status ∈ idle \| purchasing \| restoring \| purchased \| restored \| cancelled \| failed` | + +The paywall reflects `status` (e.g. disables the CTA while `purchasing`). On +`purchased`/`restored` the SDK validates the receipt (existing Voidhash +server-side validation), unlocks the entitlement, and dismisses. + +### 5.3 Purchase → entitlement (ties into existing platform) + +The bridge only conveys intent. Receipt validation, entitlement state, and +revenue tracking continue to flow through the **existing** Voidhash subscription +APIs and analytics — this spec does not change them. The paywall is purely the +presentation + intent layer. + +--- + +## 6. Suggested data model **[server]** + +``` +deploys (id, team_id, project_id, schema_version, cli_version, + runtime_version, created_at, status, manifest_json) +blobs (sha256 PK, bytes, content_type, storage_key) -- content-addressed +deploy_files (deploy_id, role, logical_path, sha256 → blobs) -- role: html|js|asset|source|config +paywalls (id, deploy_id, slug, title, description, content_hash) +placements (id, project_id, key) -- e.g. "onboarding" +channels (id, project_id, key) -- e.g. "production" +placement_assignments(channel_id, placement_id, paywall_content_hash, product_group_id, variables_json, updated_at) +``` + +- `blobs` deduped by `sha256` across all deploys ⇒ unchanged bundles/assets stored + once. +- A **deploy** is immutable; **assignments** are the only mutable, audience-facing + state (what `resolve` reads). This cleanly separates "what was built" from "what + is live", enabling instant rollback (repoint an assignment) without a rebuild. + +--- + +## 7. Cross-cutting **[server]** + +- **Versioning.** Honor `manifest.schemaVersion`; reject unknown majors with a + clear "upgrade the CLI" error. Echo a server `apiVersion`. +- **Idempotency.** Re-POSTing an identical manifest (same file set) returns the + same `deployId` (key on team+project+manifest hash). Blob PUTs are idempotent by + hash. +- **Limits.** Cap bundle size (e.g. 5 MB) and asset size; return `413` with the + offending path. Validate `contentType` against an allowlist. +- **Validation.** On finalize, recompute each paywall's `contentHash` from stored + blobs and reject mismatches — never serve an unverified bundle. +- **Auth split.** Deploy needs a **secret** key (`vh_sk_`); `resolve` + bundle/asset + serving accept the **publishable** key (`vh_pk_`). Bundles/assets are public, + immutable, content-addressed — no secrets ever go in a paywall bundle. +- **Errors.** JSON `{ error: { code, message, details? } }`; `4xx` for client + faults (bad hash, scope mismatch, missing blob), `5xx` for server faults. + +--- + +## 8. Out of scope (future) + +- **GUI paywall builder** — will read the stored **raw source** and prop schemas + (`defineComponent` editor metadata: kind/label/default/options) to render an + editor. The deploy already ships everything it needs. +- **Native renderers** — the renderer is abstracted behind a host-component + registry (`RendererProvider`), so a future native target reuses the same authored + paywalls without server changes; delivery would serve a native bundle instead of + HTML/JS, keyed by the same `contentHash` model. +- **Experiments / targeting** — `resolve` is the designed hook (return different + paywall/variables per audience); the allocation engine is a later task. + +--- + +## 9. Server implementation checklist + +- [ ] `POST /v1/paywalls/deploys` — accept `DeployManifest`, return `{ deployId, missing[] }`. +- [ ] `PUT /v1/paywalls/deploys/:id/blobs/:sha256` — verify hash, store blob. +- [ ] `POST /v1/paywalls/deploys/:id/finalize` — validate completeness + hashes, mark ready. +- [ ] Object storage + CDN for `/p//index.html|bundle.js|assets/*` (immutable, CORS, correct `Content-Type`). +- [ ] `GET /v1/paywalls/resolve` — placement → `{ paywall.url, contentHash, runtime{products,variables,…} }`, `204` when unassigned. +- [ ] Dashboard: assign placement → paywall, publish to a channel, rollback. +- [ ] Enforce key scopes (`vh_sk_` deploy, `vh_pk_` resolve) + size/type limits. +- [ ] Keep `runtime` (resolve) and the bridge message types in lockstep with `@voidhash/paywalls`; bump `schemaVersion` on breaking changes. +- [ ] Wire the CLI upload: replace the `// Upload` placeholder in `deploy.ts` with the phase-1/2 calls. +``` diff --git a/apps/cli/build.dev.ts b/apps/cli/build.dev.ts index 0c4de7371..d75f0864c 100644 --- a/apps/cli/build.dev.ts +++ b/apps/cli/build.dev.ts @@ -6,7 +6,13 @@ esbuild.buildSync({ }, bundle: true, entryPoints: ["./src/cli/index.ts"], - external: ["esbuild"], + external: [ + "esbuild", + "@voidhash/studio", + "@voidhash/paywalls", + "vite", + "typescript", + ], format: "cjs", outfile: "dist/index.cjs", platform: "node", diff --git a/apps/cli/build.ts b/apps/cli/build.ts index c548640c4..68970eea2 100644 --- a/apps/cli/build.ts +++ b/apps/cli/build.ts @@ -12,7 +12,15 @@ esbuild.buildSync({ "process.env.VOIDHASH_CLI_VERSION": `"${pkg.version}"`, }, entryPoints: ["./src/cli/index.ts"], - external: ["esbuild"], + // These are resolved/launched at runtime (Studio's Vite app, the paywalls + // runtime, Vite itself) — keep them out of the bundle. + external: [ + "esbuild", + "@voidhash/studio", + "@voidhash/paywalls", + "vite", + "typescript", + ], format: "cjs", outfile: "dist/bin.cjs", platform: "node", diff --git a/apps/cli/package.json b/apps/cli/package.json index 38fb9fb79..34ac1dc52 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -35,10 +35,13 @@ "@effect/platform-node": "4.0.0-beta.23", "@voidhash/generated-clients": "workspace:*", "@voidhash/shared": "workspace:*", + "@voidhash/studio": "workspace:*", "better-auth": "catalog:", "effect": "4.0.0-beta.23", + "esbuild": "^0.25.10", "esbuild-register": "^3.6.0", - "nanoid": "^5.1.5" + "nanoid": "^5.1.5", + "typescript": "5.6.3" }, "devDependencies": { "@effect/language-service": "catalog:", @@ -49,7 +52,6 @@ "esbuild": "^0.25.10", "tsup": "^6.1.3", "tsx": "^4.19.3", - "typescript": "5.6.3", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.0.9" } diff --git a/apps/cli/src/cli/commands/deploy.ts b/apps/cli/src/cli/commands/deploy.ts new file mode 100644 index 000000000..56f7d6692 --- /dev/null +++ b/apps/cli/src/cli/commands/deploy.ts @@ -0,0 +1,179 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { Console, Effect, Path } from "effect"; +import { Command, Flag } from "effect/unstable/cli"; +import { + type BuildPaywallsResult, + buildPaywalls, +} from "../../domain/services/paywall-build"; +import { + type UploadPaywallDeployResult, + uploadPaywallDeploy, +} from "../../domain/services/paywall-deploy-upload"; +import { SourceCode } from "../../domain/services/source-code"; +import { userError } from "../../utils/error-formatter"; + +const formatBytes = (bytes: number): string => + bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`; + +const reportBuild = ({ manifest, outDir, manifestPath }: BuildPaywallsResult) => + Effect.gen(function* reportBuild() { + yield* Console.log( + `\nBuilt ${manifest.paywalls.length} paywall(s) and ` + + `${manifest.components.length} component(s) for ` + + `${manifest.team}/${manifest.project}:\n`, + ); + for (const paywall of manifest.paywalls) { + const size = paywall.artifacts.html.bytes + paywall.artifacts.js.bytes; + yield* Console.log( + ` • ${paywall.title} (${paywall.id})\n` + + ` hash ${paywall.contentHash.slice(0, 12)}\n` + + ` bundle ${formatBytes(size)}` + + (paywall.assets.length ? `, ${paywall.assets.length} asset(s)` : ""), + ); + } + for (const component of manifest.components) { + yield* Console.log( + ` • ${component.title ?? component.id} (${component.id}, component)\n` + + ` hash ${component.contentHash.slice(0, 12)}\n` + + ` runtime ${formatBytes(component.artifacts.runtime.bytes)}, ` + + `${component.previews.length} preview(s)` + + (component.artifacts.panel ? ", custom panel" : ""), + ); + } + yield* Console.log(`\n ${manifest.assets.length} asset(s)`); + yield* Console.log(` Output: ${outDir}`); + yield* Console.log(` Manifest: ${manifestPath}`); + }); + +const reportDeploy = (result: UploadPaywallDeployResult) => + Effect.gen(function* reportDeploy() { + yield* Console.log( + `\nDeploy ${result.deployId} is ${result.finalize.status} ` + + `(${result.uploadedCount} blob(s) uploaded, ${result.cachedCount} reused).`, + ); + if (result.finalize.paywalls.length > 0) { + yield* Console.log("\nPaywalls:"); + for (const paywall of result.finalize.paywalls) { + yield* Console.log( + ` • ${paywall.id} v${paywall.version}\n ${paywall.url}`, + ); + } + } + if (result.finalize.components.length > 0) { + yield* Console.log("\nComponents:"); + for (const component of result.finalize.components) { + yield* Console.log(` • ${component.id} v${component.version}`); + } + } + }); + +/** + * `voidhash-cli deploy [--dry-run]` + * + * Builds every paywall and component in `.voidhash` into the content-addressed + * schemaVersion-2 deploy payload, then runs the contract-§4 upload flow: + * create the deploy from the manifest, upload missing blobs, finalize, and + * print the released paywall URLs/versions. `--dry-run` stops after the build. + */ +export const deployCommand = Command.make( + "deploy", + { + dryRun: Flag.boolean("dry-run").pipe( + Flag.withDescription("Build the deploy payload without uploading it"), + Flag.withDefault(false), + ), + }, + ({ dryRun }) => + Effect.gen(function* deployCommand() { + const sourceCode = yield* SourceCode; + const path = yield* Path.Path; + + const config = yield* sourceCode + .loadVoidhashConfig() + .pipe( + Effect.catchTag("VoidhashConfigNotFoundError", () => + Effect.fail( + userError( + "voidhash.config.ts not found. Run 'voidhash-cli init' to create one.", + ), + ), + ), + ); + + const projectRoot = path.resolve("."); + const cliVersion = process.env.VOIDHASH_CLI_VERSION ?? "0.0.0"; + + // Best-effort: stamp the @voidhash/paywalls version the bundle was built + // against, resolved from the user's project. The package's exports map + // does not expose ./package.json, so walk up from the resolved entry. + const runtimeVersion = yield* Effect.try({ + try: () => { + const entry = require.resolve("@voidhash/paywalls", { + paths: [projectRoot], + }); + for ( + let dir = dirname(entry); + dir !== dirname(dir); + dir = dirname(dir) + ) { + const pkgPath = join(dir, "package.json"); + if (existsSync(pkgPath)) { + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { + name?: string; + version?: string; + }; + if ( + pkg.name === "@voidhash/paywalls" && + typeof pkg.version === "string" + ) { + return pkg.version; + } + } + } + throw new Error("@voidhash/paywalls package.json not found"); + }, + catch: (cause) => cause, + }).pipe(Effect.orElseSucceed(() => "unknown")); + + yield* Console.log("Building paywalls…"); + + const result = yield* buildPaywalls({ + cliVersion, + onWarn: (message) => Console.log(` Warning: ${message}`), + project: config.project, + projectRoot, + runtimeVersion, + team: config.team, + }).pipe( + Effect.catchTag("PaywallBuildError", (e) => + Effect.fail(userError(e.message)).pipe( + Effect.tapError(() => Effect.logDebug(e.cause)), + ), + ), + ); + + yield* reportBuild(result); + + if (dryRun) { + yield* Console.log("\nDry run — nothing uploaded."); + return; + } + + yield* Console.log("\nDeploying…"); + + const deployed = yield* uploadPaywallDeploy({ + manifest: result.manifest, + onProgress: (message) => Console.log(` ${message}`), + projectRoot, + }).pipe( + Effect.catchTag("PaywallDeployUploadError", (e) => + Effect.fail(userError(e.message)).pipe( + Effect.tapError(() => Effect.logDebug(e.cause)), + ), + ), + ); + + yield* reportDeploy(deployed); + }), +).pipe(Command.withDescription("Build and deploy paywalls to Voidhash.")); diff --git a/apps/cli/src/cli/commands/studio.ts b/apps/cli/src/cli/commands/studio.ts new file mode 100644 index 000000000..38b1aff80 --- /dev/null +++ b/apps/cli/src/cli/commands/studio.ts @@ -0,0 +1,133 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { dirname, join } from "node:path"; +import { Console, Effect, FileSystem, Path } from "effect"; +import { Command, Flag } from "effect/unstable/cli"; + +import { userError } from "../../utils/error-formatter"; + +const DEFAULT_PORT = 4830; + +/** Resolves the installed Studio app directory and the Vite CLI entry point. */ +const resolveStudioPaths = () => + Effect.try({ + try: () => { + // `require.resolve` works both in the bundled CJS binary and under tsx in + // development. We resolve the package manifest to get the app root, and + // Vite's own CLI entry so we can launch it without depending on bin + // shims being hoisted in any particular way. + const studioDir = dirname( + require.resolve("@voidhash/studio/package.json"), + ); + // Resolve Vite via its package.json (an exported subpath) from the Studio + // package, then join the CLI entry — `vite/bin/vite.js` is not an exported + // subpath, so it can't be resolved directly under Node's exports rules. + const viteDir = dirname( + require.resolve("vite/package.json", { paths: [studioDir] }), + ); + const viteBin = join(viteDir, "bin", "vite.js"); + return { studioDir, viteBin }; + }, + catch: () => + userError( + "Could not locate the Voidhash Studio app. Reinstall the CLI and try again.", + ), + }); + +/** Best-effort: open the given URL in the user's default browser. */ +const openBrowser = (url: string): void => { + const command = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "cmd" + : "xdg-open"; + const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; + try { + spawn(command, args, { stdio: "ignore", detached: true }).unref(); + } catch { + // Opening the browser is a convenience; never fail the command over it. + } +}; + +/** + * `voidhash-cli studio [--port] [--no-open]` + * + * Launches the paywall preview Studio (a Vite app) against the current project. + * The project's `.voidhash` folder is the source of truth; the Studio process is + * pointed at it via the `VOIDHASH_PROJECT_ROOT` env var. Runs until interrupted + * (Ctrl+C), at which point the Vite child process is terminated. + */ +export const studioCommand = Command.make( + "studio", + { + port: Flag.integer("port").pipe( + Flag.withAlias("p"), + Flag.withDescription("Port for the Studio dev server"), + Flag.withDefault(DEFAULT_PORT), + ), + open: Flag.boolean("open").pipe( + Flag.withDescription("Open Studio in your browser once it starts"), + Flag.withDefault(true), + ), + }, + ({ port, open }) => + Effect.gen(function* studioCommand() { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const projectRoot = path.resolve("."); + const voidhashDir = path.join(projectRoot, ".voidhash"); + + const hasVoidhash = yield* fs.exists(voidhashDir); + if (!hasVoidhash) { + yield* Console.warn( + `No .voidhash folder found in ${projectRoot}.\n` + + "Studio will start, but there are no paywalls to preview yet.\n" + + "Create .voidhash/paywalls/.tsx to get started.\n", + ); + } + + const { studioDir, viteBin } = yield* resolveStudioPaths(); + const url = `http://localhost:${port}`; + + yield* Console.log("\n Voidhash Studio"); + yield* Console.log(` Project: ${projectRoot}`); + yield* Console.log(` Preview: ${url}\n`); + + // Spawn Vite, keep the command alive until the child exits, and ensure the + // child is terminated if the fiber is interrupted (Ctrl+C). + yield* Effect.acquireUseRelease( + Effect.sync(() => + spawn( + process.execPath, + [viteBin, "--port", String(port), "--strictPort"], + { + cwd: studioDir, + env: { ...process.env, VOIDHASH_PROJECT_ROOT: projectRoot }, + stdio: "inherit", + }, + ), + ), + (child: ChildProcess) => { + if (open) { + // Give Vite a moment to bind the port before opening the browser. + setTimeout(() => openBrowser(url), 1500); + } + return Effect.callback((resume) => { + child.on("exit", () => resume(Effect.void)); + child.on("error", (error) => resume(Effect.die(error))); + }); + }, + (child: ChildProcess) => + Effect.sync(() => { + if (child.exitCode === null && !child.killed) { + child.kill("SIGTERM"); + } + }), + ); + }), +).pipe( + Command.withDescription( + "Launch the paywall preview Studio for this project.", + ), +); diff --git a/apps/cli/src/cli/index.ts b/apps/cli/src/cli/index.ts index 85284d429..6ca1350c4 100644 --- a/apps/cli/src/cli/index.ts +++ b/apps/cli/src/cli/index.ts @@ -1,6 +1,6 @@ -import { Command } from "effect/unstable/cli"; -import { NodeServices, NodeRuntime } from "@effect/platform-node"; +import { NodeRuntime, NodeServices } from "@effect/platform-node"; import { Effect, Layer, References } from "effect"; +import { Command } from "effect/unstable/cli"; import { FetchHttpClient } from "effect/unstable/http"; import { Auth } from "../domain/services/auth"; @@ -15,12 +15,16 @@ import { } from "../utils/error-formatter"; import { authCommand } from "./commands/auth"; import { configCommand } from "./commands/config"; +import { deployCommand } from "./commands/deploy"; import { initCommand } from "./commands/init"; +import { studioCommand } from "./commands/studio"; import { typesCommand } from "./commands/types"; import { debugOption } from "./shared-options"; -const command = Command.make("voidhash", { debug: debugOption }, () => - Effect.void +const command = Command.make( + "voidhash", + { debug: debugOption }, + () => Effect.void, ).pipe( Command.withDescription("Voidhash CLI application."), Command.withSubcommands([ @@ -28,7 +32,9 @@ const command = Command.make("voidhash", { debug: debugOption }, () => authCommand, typesCommand, configCommand, - ]) + studioCommand, + deployCommand, + ]), ); const cli = Command.run(command, { @@ -37,14 +43,16 @@ const cli = Command.run(command, { // Apply debug log level if --debug flag is present const cliEffect = cli.pipe( - isDebugMode() ? Effect.provideService(References.MinimumLogLevel, "Debug") : (x) => x + isDebugMode() + ? Effect.provideService(References.MinimumLogLevel, "Debug") + : (x) => x, ); const ServicesLayer = Layer.mergeAll( SourceCode.Default, Auth.Default, Codegen.Default, - SchemaService.Default + SchemaService.Default, ); const PlatformLayer = Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer); @@ -52,9 +60,9 @@ const PlatformLayer = Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer); const MainLayer = ServicesLayer.pipe( Layer.provideMerge(ApiClient.Default), Layer.provideMerge(CliConfig.Default), - Layer.provideMerge(PlatformLayer) + Layer.provideMerge(PlatformLayer), ); NodeRuntime.runMain( - cliEffect.pipe(Effect.provide(MainLayer), withValidationErrorHandler) + cliEffect.pipe(Effect.provide(MainLayer), withValidationErrorHandler), ); diff --git a/apps/cli/src/domain/schema/paywall-deploy.ts b/apps/cli/src/domain/schema/paywall-deploy.ts new file mode 100644 index 000000000..d1fb2b6db --- /dev/null +++ b/apps/cli/src/domain/schema/paywall-deploy.ts @@ -0,0 +1,163 @@ +/** + * The deploy manifest contract (schemaVersion 2) — the payload `voidhash-cli + * deploy` produces at `.voidhash/.build/manifest.json` and uploads to the + * Voidhash backend. The wire-format source of truth is + * `docs/specs/paywall-deploy-contract.md` (§1); these schemas mirror it + * exactly and MUST stay in sync. Breaking changes bump the schema version. + */ +import { Schema } from "effect"; + +/** Current deploy manifest schema version (contract §1). */ +export const DEPLOY_MANIFEST_VERSION = 2 as const; + +/** Paywall/component slug shape (contract §1.1). */ +export const DEPLOY_SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,63}$/; + +const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/; + +/** A slug identifier derived from a source file name, e.g. `onboarding`. */ +export const DeploySlugSchema = Schema.String.check( + Schema.isPattern(DEPLOY_SLUG_REGEX), +); + +/** Lowercase hex SHA-256 digest. */ +export const DeploySha256Schema = Schema.String.check( + Schema.isPattern(SHA256_HEX_REGEX), +); + +/** A file's identity: where it lives, how big it is, and its content hash. */ +export const DeployFileSchema = Schema.Struct({ + /** Path relative to the project root, POSIX-separated. */ + path: Schema.String, + bytes: Schema.Number.check(Schema.isInt()), + /** Lowercase hex SHA-256 of the file's raw bytes. */ + sha256: DeploySha256Schema, +}); +export type DeployFile = typeof DeployFileSchema.Type; + +/** A deployable output file with its MIME type. */ +export const DeployArtifactSchema = Schema.Struct({ + ...DeployFileSchema.fields, + contentType: Schema.String, +}); +export type DeployArtifact = typeof DeployArtifactSchema.Type; + +/** A binary asset (image, font, …) referenced by one or more paywalls. */ +export const DeployAssetSchema = DeployArtifactSchema; +export type DeployAsset = typeof DeployAssetSchema.Type; + +/** Author variable values: `string | number | boolean` only (contract §1.1). */ +export const DeployVariableValueSchema = Schema.Union([ + Schema.String, + Schema.Number, + Schema.Boolean, +]); +export type DeployVariableValue = typeof DeployVariableValueSchema.Type; + +/** The paywall's author variables, keyed by name. */ +export const DeployVariablesSchema = Schema.Record( + Schema.String, + DeployVariableValueSchema, +); +export type DeployVariables = typeof DeployVariablesSchema.Type; + +/** The compiled, WebView-ready output for a single paywall. */ +export const DeployPaywallArtifactsSchema = Schema.Struct({ + /** The HTML shell that boots the paywall in a WebView. */ + html: DeployArtifactSchema, + /** The bundled JS (paywall tree + renderer + React) the shell loads. */ + js: DeployArtifactSchema, +}); +export type DeployPaywallArtifacts = typeof DeployPaywallArtifactsSchema.Type; + +/** One paywall in the deploy manifest (contract §1). */ +export const DeployPaywallSchema = Schema.Struct({ + /** Slug derived from the source file name, e.g. `onboarding`. */ + id: DeploySlugSchema, + title: Schema.String, + description: Schema.optional(Schema.String), + /** Product slugs the paywall uses (may be empty). */ + products: Schema.Array(Schema.String), + variables: DeployVariablesSchema, + /** The raw author source for this paywall. */ + source: DeployFileSchema, + artifacts: DeployPaywallArtifactsSchema, + /** Paths into the manifest's top-level `assets[]` this paywall references. */ + assets: Schema.Array(Schema.String), + /** + * `sha256(sha256(html) + ":" + sha256(js) + ":" + sortedAssetHashes.join(":"))` + * (contract §1.2) — the paywall's deployable identity: storage prefix, cache + * key, dedupe key. + */ + contentHash: DeploySha256Schema, +}); +export type DeployPaywall = typeof DeployPaywallSchema.Type; + +/** One rendered preview state of a component (contract §3 node tree file). */ +export const DeployComponentPreviewSchema = Schema.Struct({ + state: Schema.String, + file: DeployArtifactSchema, +}); +export type DeployComponentPreview = typeof DeployComponentPreviewSchema.Type; + +/** The compiled artifacts of a component. */ +export const DeployComponentArtifactsSchema = Schema.Struct({ + /** ESM bundle of the component module (react/@voidhash/paywalls external). */ + runtime: DeployArtifactSchema, + /** Custom editor panel bundle, or `null` when none is declared. */ + panel: Schema.NullOr(DeployArtifactSchema), +}); +export type DeployComponentArtifacts = + typeof DeployComponentArtifactsSchema.Type; + +/** One reusable component in the deploy manifest (contract §1). */ +export const DeployComponentSchema = Schema.Struct({ + /** Slug derived from the source file name, e.g. `product-option`. */ + id: DeploySlugSchema, + title: Schema.optional(Schema.String), + /** The raw author source for this component. */ + source: DeployFileSchema, + /** The §2 component manifest artifact. */ + manifest: DeployArtifactSchema, + /** §3 preview node trees, one per preview state. */ + previews: Schema.Array(DeployComponentPreviewSchema), + artifacts: DeployComponentArtifactsSchema, + /** + * `sha256(sha256(manifest) + ":" + sha256(runtime) + ":" + (sha256(panel) | "") + * + ":" + sortedPreviewHashes.join(":"))` (contract §1.2). + */ + contentHash: DeploySha256Schema, +}); +export type DeployComponent = typeof DeployComponentSchema.Type; + +/** The full manifest written to `.voidhash/.build/manifest.json` (contract §1). */ +export const DeployManifestSchema = Schema.Struct({ + schemaVersion: Schema.Literal(DEPLOY_MANIFEST_VERSION), + cliVersion: Schema.String, + /** Version of `@voidhash/paywalls` the bundles were built against. */ + runtimeVersion: Schema.String, + /** Organization slug. */ + team: Schema.String, + /** Project slug. */ + project: Schema.String, + /** ISO-8601 build timestamp. */ + createdAt: Schema.String, + paywalls: Schema.Array(DeployPaywallSchema), + components: Schema.Array(DeployComponentSchema), + /** The project's `voidhash.config.*`. */ + config: DeployFileSchema, + /** Every binary asset, deduped by path. */ + assets: Schema.Array(DeployAssetSchema), +}).check( + // Contract §1.1: at least one paywall or one component. + Schema.makeFilter( + (manifest: { + readonly paywalls: ReadonlyArray; + readonly components: ReadonlyArray; + }) => + manifest.paywalls.length > 0 || manifest.components.length > 0 + ? undefined + : "manifest must contain at least one paywall or one component", + ), +); +export type DeployManifest = typeof DeployManifestSchema.Type; diff --git a/apps/cli/src/domain/services/paywall-build.ts b/apps/cli/src/domain/services/paywall-build.ts new file mode 100644 index 000000000..654fd44db --- /dev/null +++ b/apps/cli/src/domain/services/paywall-build.ts @@ -0,0 +1,1018 @@ +/** + * The deploy build pipeline: scans `.voidhash/paywalls` and + * `.voidhash/components`, typechecks them, compiles every paywall into a + * WebView-ready HTML/JS bundle and every component into its §2 manifest, §3 + * preview trees and runtime bundle, and assembles the content-addressed + * schemaVersion-2 deploy manifest (contract: docs/specs/paywall-deploy-contract.md). + */ +import { createHash } from "node:crypto"; +import { existsSync, promises as fsp, readdirSync, statSync } from "node:fs"; +import { + basename, + dirname, + extname, + join, + posix, + relative, + sep, +} from "node:path"; +import { Data, Effect, Schema } from "effect"; +import * as esbuild from "esbuild"; + +import { + DEPLOY_MANIFEST_VERSION, + DEPLOY_SLUG_REGEX, + type DeployArtifact, + type DeployAsset, + type DeployComponent, + type DeployComponentPreview, + type DeployFile, + type DeployManifest, + DeployManifestSchema, + type DeployPaywall, + type DeployVariables, +} from "../schema/paywall-deploy"; +import { closedImportsPlugin } from "./paywall-closed-imports"; +import { + PAYWALL_ASSET_EXTENSIONS, + typecheckPaywallSources, +} from "./paywall-typecheck"; + +export class PaywallBuildError extends Data.TaggedError("PaywallBuildError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** Directory (relative to the project root) where build output is written. */ +export const BUILD_DIR = join(".voidhash", ".build"); + +const SOURCE_EXTENSIONS = [".tsx", ".jsx", ".ts", ".js"]; + +/** + * Binary asset types paywall bundles may import; emitted as files. Derived + * from the typecheck gate's extension list so the two can never drift. + */ +const PAYWALL_ASSET_LOADERS: Record = + Object.fromEntries( + PAYWALL_ASSET_EXTENSIONS.map((ext) => [`.${ext}`, "file"]), + ); + +/** + * Component runtime bundles must stay a single file (the manifest has no + * per-component asset list), so binary imports are inlined as data URLs. + */ +const COMPONENT_ASSET_LOADERS: Record = + Object.fromEntries( + Object.keys(PAYWALL_ASSET_LOADERS).map((ext) => [ext, "dataurl"]), + ); + +const CONTENT_TYPES: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +const textEncoder = new TextEncoder(); + +/** Lowercase hex SHA-256 of a string or byte payload. */ +export const sha256Hex = (data: Uint8Array | string): string => + createHash("sha256").update(data).digest("hex"); + +/** + * Contract §1.2 paywall content hash: + * `sha256(sha256(html) + ":" + sha256(js) + ":" + sortedAssetHashes.join(":"))`. + */ +export const computePaywallContentHash = (input: { + readonly htmlSha256: string; + readonly jsSha256: string; + readonly assetSha256s: ReadonlyArray; +}): string => + sha256Hex( + `${input.htmlSha256}:${input.jsSha256}:${[...input.assetSha256s] + .sort() + .join(":")}`, + ); + +/** + * Contract §1.2 component content hash: + * `sha256(sha256(manifest) + ":" + sha256(runtime) + ":" + (sha256(panel) | "") + * + ":" + sortedPreviewHashes.join(":"))`. + */ +export const computeComponentContentHash = (input: { + readonly manifestSha256: string; + readonly runtimeSha256: string; + readonly panelSha256?: string | null; + readonly previewSha256s: ReadonlyArray; +}): string => + sha256Hex( + `${input.manifestSha256}:${input.runtimeSha256}:${ + input.panelSha256 ?? "" + }:${[...input.previewSha256s].sort().join(":")}`, + ); + +const contentTypeFor = (path: string): string => + CONTENT_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream"; + +/** Normalizes an absolute path to a project-root-relative POSIX path. */ +const toRelPosix = (projectRoot: string, abs: string): string => + relative(projectRoot, abs).split(sep).join(posix.sep); + +// Discovery (isSourceFile / idFromFile / listFilesRecursive) is mirrored by +// Studio's virtual-paywalls plugin +// (apps/studio/src/server/virtual-paywalls-plugin.ts) — keep both in sync. +const isSourceFile = (name: string): boolean => + SOURCE_EXTENSIONS.some((ext) => name.endsWith(ext)) && + !name.endsWith(".d.ts"); + +const idFromFile = (file: string): string => + basename(file).replace(/\.(tsx|jsx|ts|js)$/, ""); + +/** Recursively lists files under a directory (absolute paths). */ +const listFilesRecursive = (dir: string): string[] => { + if (!existsSync(dir)) return []; + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...listFilesRecursive(full)); + } else { + out.push(full); + } + } + return out; +}; + +const listSourceFiles = (dir: string): string[] => + listFilesRecursive(dir).filter((f) => isSourceFile(basename(f))); + +/** Turns an esbuild failure into a readable, file-located error message. */ +const describeEsbuildFailure = (cause: unknown): string | undefined => { + if ( + typeof cause === "object" && + cause !== null && + "errors" in cause && + Array.isArray((cause as esbuild.BuildFailure).errors) + ) { + return (cause as esbuild.BuildFailure).errors + .map((error) => { + const location = error.location + ? `${error.location.file}:${error.location.line}:${error.location.column}: ` + : ""; + return ` ${location}${error.text}`; + }) + .join("\n"); + } + return; +}; + +const bundleFailure = (subject: string) => (cause: unknown) => { + const details = describeEsbuildFailure(cause); + return new PaywallBuildError({ + cause, + message: details + ? `Failed to bundle ${subject}:\n${details}` + : `Failed to bundle ${subject}`, + }); +}; + +// ── User-project library access ────────────────────────────────────────────── +// +// `@voidhash/paywalls` (and React) are intentionally NOT dependencies of the +// CLI: modules are resolved from the *user's* project so the paywall module, +// the tree renderer and React are all one instance. + +interface UserPaywallsLib { + readonly extractComponentManifest: ( + definition: ComponentDefinitionLike, + ) => { readonly id: string } & Record; +} + +interface UserTreeLib { + readonly renderToNodeTree: ( + element: unknown, + options?: { + readonly config?: { + readonly products?: ReadonlyArray; + readonly variables?: Record; + }; + readonly state?: string; + }, + ) => Promise; +} + +interface UserReactLib { + readonly createElement: ( + type: unknown, + props: Record | null, + ) => unknown; +} + +interface ComponentPreviewStateLike { + readonly props?: Record; + readonly data?: { + readonly products?: ReadonlyArray; + readonly variables?: Record; + }; +} + +interface ComponentDefinitionLike { + readonly id: string; + readonly title?: string; + readonly description?: string; + readonly previews: Record; + readonly panel?: unknown; + readonly component: unknown; + readonly __voidhash: { readonly kind: string }; +} + +const requireFromProject = ( + projectRoot: string, + specifier: string, +): Effect.Effect => + Effect.try({ + try: () => + require(require.resolve(specifier, { paths: [projectRoot] })) as T, + catch: (cause) => + new PaywallBuildError({ + cause, + message: + `Failed to load "${specifier}" from the project. ` + + `Make sure "@voidhash/paywalls" is installed in your project.`, + }), + }); + +/** + * Registers an esbuild `require` hook with the `tsx` loader so paywall and + * component modules (which contain JSX) can be loaded for metadata extraction + * and preview rendering. The shared `safeRegister` helper uses the `ts` + * loader, which rejects JSX — hence a dedicated hook here. + */ +const registerTsxLoader = (): Effect.Effect< + { unregister: () => void }, + PaywallBuildError +> => + Effect.tryPromise({ + try: async () => { + const { register } = await import("esbuild-register/dist/node"); + return register({ format: "cjs", loader: "tsx" }); + }, + catch: (cause) => + new PaywallBuildError({ + cause, + message: "Failed to initialize the TypeScript/JSX loader.", + }), + }); + +const loadModuleDefault = ( + file: string, +): Effect.Effect => + Effect.try({ + try: () => { + delete require.cache[require.resolve(file)]; + const mod = require(file) as { default?: unknown }; + return mod?.default ?? mod; + }, + catch: (cause) => + new PaywallBuildError({ + cause, + message: `Failed to load ${file}: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + }), + }); + +const isScalar = (value: unknown): value is string | number | boolean => + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean"; + +interface PaywallModuleMeta { + readonly title: string; + readonly description?: string; + readonly products: ReadonlyArray; + readonly variables: DeployVariables; +} + +/** Reads the `__voidhash` metadata off a paywall module's default export. */ +const loadPaywallMeta = ( + file: string, +): Effect.Effect => + loadModuleDefault(file).pipe( + Effect.flatMap((def) => { + const meta = ( + def as { __voidhash?: Record } | null | undefined + )?.__voidhash; + if (!meta || meta.kind !== "paywall") { + return Effect.fail( + new PaywallBuildError({ + message: `${file} must default-export createPaywall({ … }) from "@voidhash/paywalls".`, + }), + ); + } + const title = + typeof meta.title === "string" && meta.title.length > 0 + ? meta.title + : idFromFile(file); + const description = + typeof meta.description === "string" ? meta.description : undefined; + const products = Array.isArray(meta.products) + ? meta.products.filter((p): p is string => typeof p === "string") + : []; + const rawVariables = + typeof meta.variables === "object" && meta.variables !== null + ? (meta.variables as Record) + : {}; + const variables: Record = {}; + for (const [key, value] of Object.entries(rawVariables)) { + if (!isScalar(value)) { + return Effect.fail( + new PaywallBuildError({ + message: + `Variable "${key}" of paywall ${basename(file)} must be a ` + + "string, number or boolean (contract §1.1).", + }), + ); + } + variables[key] = value; + } + return Effect.succeed({ + description, + products, + title, + variables, + }); + }), + ); + +/** Loads a component module's default export and validates its shape. */ +const loadComponentDefinition = ( + file: string, +): Effect.Effect => + loadModuleDefault(file).pipe( + Effect.flatMap((def) => { + const candidate = def as Partial | null; + if ( + !candidate || + candidate.__voidhash?.kind !== "component" || + typeof candidate.component !== "function" || + typeof candidate.id !== "string" + ) { + return Effect.fail( + new PaywallBuildError({ + message: `${file} must default-export defineComponent({ … }) from "@voidhash/paywalls".`, + }), + ); + } + const expectedId = idFromFile(file); + if (candidate.id !== expectedId) { + return Effect.fail( + new PaywallBuildError({ + message: + `Component id "${candidate.id}" does not match its file name ` + + `"${expectedId}" (${basename(file)}). Rename the file or the id.`, + }), + ); + } + return Effect.succeed({ + ...candidate, + previews: candidate.previews ?? {}, + } as ComponentDefinitionLike); + }), + ); + +// ── Output writing ─────────────────────────────────────────────────────────── + +const writeFile = ( + absPath: string, + bytes: Uint8Array, +): Effect.Effect => + Effect.tryPromise({ + try: async () => { + await fsp.mkdir(dirname(absPath), { recursive: true }); + await fsp.writeFile(absPath, bytes); + }, + catch: (cause) => + new PaywallBuildError({ cause, message: `Failed to write ${absPath}` }), + }); + +/** Writes `bytes` to `absPath` and returns its manifest artifact entry. */ +const writeArtifact = ( + projectRoot: string, + absPath: string, + bytes: Uint8Array, +): Effect.Effect => + writeFile(absPath, bytes).pipe( + Effect.map(() => ({ + bytes: bytes.byteLength, + contentType: contentTypeFor(absPath), + path: toRelPosix(projectRoot, absPath), + sha256: sha256Hex(bytes), + })), + ); + +const readDeployFile = ( + projectRoot: string, + absPath: string, +): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const bytes = await fsp.readFile(absPath); + return { + bytes: bytes.byteLength, + path: toRelPosix(projectRoot, absPath), + sha256: sha256Hex(bytes), + }; + }, + catch: (cause) => + new PaywallBuildError({ cause, message: `Failed to read ${absPath}` }), + }); + +// ── Paywall bundling ───────────────────────────────────────────────────────── + +/** The WebView HTML shell that boots a compiled paywall bundle. */ +const htmlShell = (jsFileName: string): string => + ` + + + + + Voidhash Paywall + + + + +
+ + + +`; + +/** The in-memory entry esbuild bundles for a paywall. */ +const paywallEntryContents = (paywallAbsPath: string): string => + `import paywall from ${JSON.stringify(paywallAbsPath)}; +import { mountPaywall } from "@voidhash/paywalls/dom"; +const root = document.getElementById("root"); +if (root) mountPaywall(paywall, root); +`; + +interface BuiltPaywallArtifacts { + readonly htmlBytes: Uint8Array; + readonly jsBytes: Uint8Array; + readonly jsFileName: string; + readonly assets: ReadonlyArray<{ relName: string; bytes: Uint8Array }>; +} + +/** Bundles a single paywall to HTML + JS (+ assets) in memory via esbuild. */ +const bundlePaywall = ( + projectRoot: string, + voidhashDir: string, + paywallAbsPath: string, +): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const result = await esbuild.build({ + assetNames: "assets/[name]-[hash]", + bundle: true, + define: { "process.env.NODE_ENV": '"production"' }, + format: "iife", + jsx: "automatic", + jsxImportSource: "react", + loader: PAYWALL_ASSET_LOADERS, + logLevel: "silent", + minify: true, + outdir: "out", + platform: "browser", + plugins: [closedImportsPlugin(voidhashDir)], + publicPath: ".", + stdin: { + contents: paywallEntryContents(paywallAbsPath), + loader: "tsx", + resolveDir: projectRoot, + sourcefile: "voidhash-entry.tsx", + }, + target: ["es2019", "safari13"], + write: false, + }); + + let jsBytes: Uint8Array | undefined; + const assets: Array<{ relName: string; bytes: Uint8Array }> = []; + + for (const file of result.outputFiles) { + const rel = file.path.split(sep).join(posix.sep); + if (rel.endsWith(".js")) { + jsBytes = file.contents; + } else { + // Asset emitted under out/assets/… — keep the assets/… suffix. + const idx = rel.indexOf("/assets/"); + const relName = idx >= 0 ? rel.slice(idx + 1) : posix.basename(rel); + assets.push({ bytes: file.contents, relName }); + } + } + + if (!jsBytes) { + throw new Error("esbuild produced no JavaScript output"); + } + + const jsFileName = "bundle.js"; + return { + assets, + htmlBytes: textEncoder.encode(htmlShell(jsFileName)), + jsBytes, + jsFileName, + }; + }, + catch: bundleFailure(`paywall ${basename(paywallAbsPath)}`), + }); + +// ── Component bundling ─────────────────────────────────────────────────────── + +/** Modules a component runtime bundle leaves to the consumer (Studio). */ +const COMPONENT_RUNTIME_EXTERNALS = [ + "react", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "@voidhash/paywalls", + "@voidhash/paywalls/*", +]; + +const componentBuildOptions = (voidhashDir: string): esbuild.BuildOptions => ({ + bundle: true, + define: { "process.env.NODE_ENV": '"production"' }, + external: [...COMPONENT_RUNTIME_EXTERNALS], + format: "esm", + jsx: "automatic", + jsxImportSource: "react", + loader: COMPONENT_ASSET_LOADERS, + logLevel: "silent", + minify: true, + platform: "browser", + plugins: [closedImportsPlugin(voidhashDir)], + target: ["es2020"], + write: false, +}); + +const firstJsOutput = (result: esbuild.BuildResult): Uint8Array => { + const file = (result.outputFiles ?? []).find((f) => f.path.endsWith(".js")); + if (!file) { + throw new Error("esbuild produced no JavaScript output"); + } + return file.contents; +}; + +/** Bundles a component module to a single ESM `runtime.js`. */ +const bundleComponentRuntime = ( + voidhashDir: string, + componentAbsPath: string, +): Effect.Effect => + Effect.tryPromise({ + try: async () => + firstJsOutput( + await esbuild.build({ + ...componentBuildOptions(voidhashDir), + entryPoints: [componentAbsPath], + outdir: "out", + }), + ), + catch: bundleFailure(`component ${basename(componentAbsPath)}`), + }); + +/** + * Bundles a component's custom editor panel: an ESM module whose default + * export is the definition's `panel` element tree. + */ +const bundleComponentPanel = ( + voidhashDir: string, + componentAbsPath: string, +): Effect.Effect => + Effect.tryPromise({ + try: async () => + firstJsOutput( + await esbuild.build({ + ...componentBuildOptions(voidhashDir), + outdir: "out", + stdin: { + contents: `import definition from ${JSON.stringify(componentAbsPath)}; +export default definition.panel; +`, + loader: "ts", + resolveDir: dirname(componentAbsPath), + sourcefile: "voidhash-panel-entry.ts", + }, + }), + ), + catch: bundleFailure(`panel of component ${basename(componentAbsPath)}`), + }); + +// ── Preview tree inspection ────────────────────────────────────────────────── + +/** The one placeholder reason that is NOT a render error (§3). */ +const LEGITIMATE_NULL_REASON = "render returned null"; + +/** + * Collects the reasons of placeholder nodes a §3 preview tree contains that + * were produced by render ERRORS — a thrown render (`"render threw: …"`), an + * unsupported element type, … Placeholders carrying the legitimate + * `"render returned null"` reason are not errors and are skipped. + */ +export const collectRenderErrorPlaceholderReasons = ( + tree: unknown, +): string[] => { + const reasons: string[] = []; + const visit = (node: unknown): void => { + if (typeof node !== "object" || node === null) { + return; + } + if ("root" in node) { + visit(node.root); + } + if ( + "type" in node && + node.type === "placeholder" && + "reason" in node && + typeof node.reason === "string" && + node.reason !== LEGITIMATE_NULL_REASON + ) { + reasons.push(node.reason); + } + if ("children" in node && Array.isArray(node.children)) { + for (const child of node.children) { + visit(child); + } + } + }; + visit(tree); + return reasons; +}; + +// ── Validation helpers ─────────────────────────────────────────────────────── + +const validateIds = ( + kind: "paywall" | "component", + files: ReadonlyArray, +): Effect.Effect => + Effect.gen(function* validateIds() { + const seen = new Map(); + for (const file of files) { + const id = idFromFile(file); + if (!DEPLOY_SLUG_REGEX.test(id)) { + return yield* Effect.fail( + new PaywallBuildError({ + message: + `Invalid ${kind} id "${id}" (${basename(file)}). Ids derive ` + + `from file names and must match ${DEPLOY_SLUG_REGEX}.`, + }), + ); + } + const existing = seen.get(id); + if (existing !== undefined) { + return yield* Effect.fail( + new PaywallBuildError({ + message: `Duplicate ${kind} id "${id}" (${existing} and ${file}).`, + }), + ); + } + seen.set(id, file); + } + }); + +// ── Public API ─────────────────────────────────────────────────────────────── + +export interface BuildPaywallsOptions { + readonly projectRoot: string; + readonly team: string; + readonly project: string; + readonly cliVersion: string; + readonly runtimeVersion: string; + /** Non-fatal warning callback (e.g. `Console.log`). */ + readonly onWarn?: (message: string) => Effect.Effect; +} + +export interface BuildPaywallsResult { + readonly manifest: DeployManifest; + /** Absolute path to the build output directory. */ + readonly outDir: string; + /** Absolute path to the written manifest.json. */ + readonly manifestPath: string; +} + +/** + * Compiles every paywall and component in `.voidhash` into deployable + * artifacts and writes the content-addressed schemaVersion-2 + * {@link DeployManifest} — the exact payload `voidhash-cli deploy` uploads. + * Output lands in {@link BUILD_DIR}: + * + * - `paywalls//` — `index.html`, `bundle.js`, `assets/…` + * - `components//` — `manifest.json`, `previews/.json`, + * `runtime.js` and (when declared) `panel.js` + * - `manifest.json` — the assembled deploy manifest + * + * The build runs a TypeScript gate over all discovered sources first, and + * every bundle enforces the closed-import rules (only `@voidhash/paywalls`, + * React's runtime entries and relative imports within `.voidhash`). + */ +export const buildPaywalls = ({ + projectRoot, + team, + project, + cliVersion, + runtimeVersion, + onWarn, +}: BuildPaywallsOptions): Effect.Effect< + BuildPaywallsResult, + PaywallBuildError +> => + Effect.gen(function* buildPaywalls() { + const warn = (message: string): Effect.Effect => + onWarn ? onWarn(message) : Effect.void; + const voidhashDir = join(projectRoot, ".voidhash"); + const paywallsDir = join(voidhashDir, "paywalls"); + const componentsDir = join(voidhashDir, "components"); + const outDir = join(projectRoot, BUILD_DIR); + + const paywallFiles = listSourceFiles(paywallsDir); + const componentFiles = listSourceFiles(componentsDir); + + if (paywallFiles.length === 0 && componentFiles.length === 0) { + return yield* Effect.fail( + new PaywallBuildError({ + message: `No paywalls or components found in ${voidhashDir}.`, + }), + ); + } + + yield* validateIds("paywall", paywallFiles); + yield* validateIds("component", componentFiles); + + // Typecheck gate: fail fast, before any bundling. + yield* typecheckPaywallSources({ + files: [...paywallFiles, ...componentFiles], + projectRoot, + }).pipe( + Effect.catchTag("PaywallTypecheckError", (e) => + Effect.fail( + new PaywallBuildError({ cause: e.cause, message: e.message }), + ), + ), + ); + + // Clear any previous build so removed paywalls/components don't linger. + yield* Effect.tryPromise({ + try: () => fsp.rm(outDir, { force: true, recursive: true }), + catch: (cause) => + new PaywallBuildError({ cause, message: "Failed to clean build dir" }), + }); + + // Register esbuild so we can `require` paywall/component modules (JSX) to + // read metadata and render preview trees. + const { unregister } = yield* registerTsxLoader(); + + // ── Paywalls ───────────────────────────────────────────────────────────── + + const assetIndex = new Map(); + const paywalls: DeployPaywall[] = []; + + for (const file of paywallFiles) { + const id = idFromFile(file); + const meta = yield* loadPaywallMeta(file); + const built = yield* bundlePaywall(projectRoot, voidhashDir, file); + + const paywallOutDir = join(outDir, "paywalls", id); + const html = yield* writeArtifact( + projectRoot, + join(paywallOutDir, "index.html"), + built.htmlBytes, + ); + const js = yield* writeArtifact( + projectRoot, + join(paywallOutDir, built.jsFileName), + built.jsBytes, + ); + + const referencedAssets: string[] = []; + for (const asset of built.assets) { + const deployAsset = yield* writeArtifact( + projectRoot, + join(paywallOutDir, asset.relName), + asset.bytes, + ); + assetIndex.set(deployAsset.path, deployAsset); + referencedAssets.push(deployAsset.path); + } + referencedAssets.sort(); + + const source = yield* readDeployFile(projectRoot, file); + + paywalls.push({ + artifacts: { html, js }, + assets: referencedAssets, + contentHash: computePaywallContentHash({ + assetSha256s: referencedAssets.map( + (path) => assetIndex.get(path)?.sha256 ?? "", + ), + htmlSha256: html.sha256, + jsSha256: js.sha256, + }), + description: meta.description, + id, + products: meta.products, + source, + title: meta.title, + variables: meta.variables, + }); + } + + // ── Components ─────────────────────────────────────────────────────────── + + const components: DeployComponent[] = []; + + if (componentFiles.length > 0) { + const paywallsLib = yield* requireFromProject( + projectRoot, + "@voidhash/paywalls", + ); + const treeLib = yield* requireFromProject( + projectRoot, + "@voidhash/paywalls/tree", + ); + const react = yield* requireFromProject( + projectRoot, + "react", + ); + + for (const file of componentFiles) { + const id = idFromFile(file); + const definition = yield* loadComponentDefinition(file); + const componentOutDir = join(outDir, "components", id); + + // §2 component manifest. + const manifestJson = yield* Effect.try({ + try: () => paywallsLib.extractComponentManifest(definition), + catch: (cause) => + new PaywallBuildError({ + cause, + message: + `Failed to extract the manifest of component "${id}"` + + (cause instanceof Error ? `: ${cause.message}` : "."), + }), + }); + const manifest = yield* writeArtifact( + projectRoot, + join(componentOutDir, "manifest.json"), + textEncoder.encode(`${JSON.stringify(manifestJson, null, 2)}\n`), + ); + + // §3 preview trees — one per declared state, always including + // "default" (rendered with prop defaults when not declared). + const previewStates: Record = { + default: definition.previews.default ?? {}, + ...definition.previews, + }; + const previews: DeployComponentPreview[] = []; + for (const [state, preview] of Object.entries(previewStates)) { + const tree = yield* Effect.tryPromise({ + try: () => + treeLib.renderToNodeTree( + react.createElement(definition.component, preview.props ?? {}), + { + config: { + products: preview.data?.products ?? [], + variables: preview.data?.variables ?? {}, + }, + state, + }, + ), + catch: (cause) => + new PaywallBuildError({ + cause, + message: `Failed to render preview "${state}" of component "${id}".`, + }), + }); + // A placeholder produced by a render error (thrown render, + // unsupported element) still yields a valid tree — surface it so + // authors don't ship broken previews silently. + for (const reason of collectRenderErrorPlaceholderReasons(tree)) { + yield* warn( + `Component "${id}" preview "${state}" contains a render-error ` + + `placeholder: ${reason}`, + ); + } + + const previewFile = yield* writeArtifact( + projectRoot, + join(componentOutDir, "previews", `${state}.json`), + textEncoder.encode(`${JSON.stringify(tree, null, 2)}\n`), + ); + previews.push({ file: previewFile, state }); + } + + // Runtime bundle (and panel bundle, when declared). + const runtimeBytes = yield* bundleComponentRuntime(voidhashDir, file); + const runtime = yield* writeArtifact( + projectRoot, + join(componentOutDir, "runtime.js"), + runtimeBytes, + ); + + let panel: DeployArtifact | null = null; + if (definition.panel !== undefined && definition.panel !== null) { + const panelBytes = yield* bundleComponentPanel(voidhashDir, file); + panel = yield* writeArtifact( + projectRoot, + join(componentOutDir, "panel.js"), + panelBytes, + ); + } + + const source = yield* readDeployFile(projectRoot, file); + + components.push({ + artifacts: { panel, runtime }, + contentHash: computeComponentContentHash({ + manifestSha256: manifest.sha256, + panelSha256: panel?.sha256 ?? null, + previewSha256s: previews.map((p) => p.file.sha256), + runtimeSha256: runtime.sha256, + }), + id, + manifest, + previews, + source, + title: definition.title, + }); + } + } + + yield* Effect.sync(() => unregister()); + + // ── Manifest ───────────────────────────────────────────────────────────── + + const configFile = ["ts", "js", "cjs", "mjs"] + .map((ext) => join(projectRoot, `voidhash.config.${ext}`)) + .find((p) => existsSync(p)); + if (!configFile) { + return yield* Effect.fail( + new PaywallBuildError({ + message: + "voidhash.config.* not found. Run 'voidhash-cli init' first.", + }), + ); + } + const config = yield* readDeployFile(projectRoot, configFile); + + const manifest: DeployManifest = { + assets: [...assetIndex.values()].sort((a, b) => + a.path.localeCompare(b.path), + ), + cliVersion, + components, + config, + createdAt: new Date().toISOString(), + paywalls, + project, + runtimeVersion, + schemaVersion: DEPLOY_MANIFEST_VERSION, + team, + }; + + // Self-check against the contract schema before writing — a manifest the + // server would reject should never leave the build. + yield* Schema.decodeUnknownEffect(DeployManifestSchema)(manifest).pipe( + Effect.mapError( + (cause) => + new PaywallBuildError({ + cause, + message: `The build produced an invalid deploy manifest: ${cause.message}`, + }), + ), + ); + + const manifestPath = join(outDir, "manifest.json"); + yield* writeFile( + manifestPath, + textEncoder.encode(`${JSON.stringify(manifest, null, 2)}\n`), + ); + + return { manifest, manifestPath, outDir }; + }); diff --git a/apps/cli/src/domain/services/paywall-closed-imports.ts b/apps/cli/src/domain/services/paywall-closed-imports.ts new file mode 100644 index 000000000..e3f814537 --- /dev/null +++ b/apps/cli/src/domain/services/paywall-closed-imports.ts @@ -0,0 +1,121 @@ +/** + * Closed-import enforcement for `.voidhash` sources. Paywalls and components + * may only import the paywalls SDK, React's runtime entries, and each other — + * anything else (react-dom, lodash, app code outside `.voidhash`, …) fails the + * build with an error naming the offending import. + */ +import { realpathSync } from "node:fs"; +import { isAbsolute, resolve, sep } from "node:path"; + +import type * as esbuild from "esbuild"; + +/** Bare specifiers `.voidhash` sources may import. */ +export const ALLOWED_BARE_IMPORTS: ReadonlyArray = [ + "@voidhash/paywalls", + "react", + "react/jsx-runtime", + "react/jsx-dev-runtime", +]; + +const PAYWALLS_PACKAGE = "@voidhash/paywalls"; +/** The Node-only tree renderer never belongs in a shipped bundle. */ +const FORBIDDEN_PAYWALLS_SUBPATH = `${PAYWALLS_PACKAGE}/tree`; + +/** Resolves symlinks (macOS tmp dirs, pnpm) so containment checks compare real paths. */ +const toRealPath = (path: string): string => { + try { + return realpathSync(path); + } catch { + return resolve(path); + } +}; + +const isPathWithin = (parent: string, child: string): boolean => { + const parentPath = toRealPath(parent); + const childPath = toRealPath(child); + return childPath === parentPath || childPath.startsWith(parentPath + sep); +}; + +const isAllowedBareImport = (specifier: string): boolean => { + if (ALLOWED_BARE_IMPORTS.includes(specifier)) { + return true; + } + // Any @voidhash/paywalls subpath except ./tree (dom, panel, …). + return ( + specifier.startsWith(`${PAYWALLS_PACKAGE}/`) && + specifier !== FORBIDDEN_PAYWALLS_SUBPATH + ); +}; + +const disallowedMessage = (specifier: string, importer: string): string => + `Import "${specifier}" (in ${importer}) is not allowed in .voidhash sources. ` + + `Allowed imports: ${ALLOWED_BARE_IMPORTS.join(", ")} ` + + `(any "@voidhash/paywalls/*" subpath except "@voidhash/paywalls/tree"), ` + + "plus relative imports within .voidhash."; + +/** + * An esbuild plugin that rejects any import from a `.voidhash` source file + * other than: + * + * - `@voidhash/paywalls` and its subpaths (except the Node-only `./tree`), + * - `react`, `react/jsx-runtime`, `react/jsx-dev-runtime`, + * - relative/absolute imports that stay within `voidhashDir` (components + * importing components is allowed in P1 — they are bundled together). + * + * Imports made by `node_modules` code (e.g. the SDK importing `react-dom` + * internally) are not constrained — only user-authored sources are. + * + * @param voidhashDir Absolute path to the project's `.voidhash` directory. + */ +export const closedImportsPlugin = (voidhashDir: string): esbuild.Plugin => ({ + name: "voidhash-closed-imports", + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + if (args.kind === "entry-point") { + return null; + } + // Only user-authored sources are constrained. Synthetic stdin entries + // (non-absolute importer) count as user sources. + const fromUserSource = + !isAbsolute(args.importer) || isPathWithin(voidhashDir, args.importer); + if (!fromUserSource) { + return null; + } + + const specifier = args.path; + + if (specifier.startsWith(".")) { + const target = resolve(args.resolveDir, specifier); + if (!isPathWithin(voidhashDir, target)) { + return { + errors: [ + { + text: + `Import "${specifier}" (in ${args.importer}) escapes the ` + + ".voidhash directory. Paywall sources may only import files " + + "within .voidhash.", + }, + ], + }; + } + return null; + } + + if (isAbsolute(specifier)) { + return isPathWithin(voidhashDir, specifier) + ? null + : { + errors: [{ text: disallowedMessage(specifier, args.importer) }], + }; + } + + if (isAllowedBareImport(specifier)) { + return null; + } + + return { + errors: [{ text: disallowedMessage(specifier, args.importer) }], + }; + }); + }, +}); diff --git a/apps/cli/src/domain/services/paywall-deploy-upload.ts b/apps/cli/src/domain/services/paywall-deploy-upload.ts new file mode 100644 index 000000000..b78cef84c --- /dev/null +++ b/apps/cli/src/domain/services/paywall-deploy-upload.ts @@ -0,0 +1,397 @@ +/** + * The deploy upload flow (contract §4): create the deploy from the manifest, + * upload whatever blobs the server is missing, then finalize. Transport + * follows the CLI's API conventions — `api_url` base + `x-api-key` header from + * the user's CLI config. + */ +import { promises as fsp } from "node:fs"; +import { join } from "node:path"; + +import { Data, Effect, Schema } from "effect"; +import { + HttpClient, + HttpClientRequest, + type HttpClientResponse, +} from "effect/unstable/http"; + +import type { DeployManifest } from "../schema/paywall-deploy"; +import { CliConfig } from "./cli-config"; + +export class PaywallDeployUploadError extends Data.TaggedError( + "PaywallDeployUploadError", +)<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** `POST /api/v1/paywall-deploys` response (contract §4.1). */ +const CreateDeployResponseSchema = Schema.Struct({ + deployId: Schema.String, + /** Manifest file hashes the server does not already have for this project. */ + missing: Schema.Array(Schema.String), +}); +export type CreateDeployResponse = typeof CreateDeployResponseSchema.Type; + +const FinalizedPaywallSchema = Schema.Struct({ + id: Schema.String, + paywallId: Schema.String, + releaseId: Schema.String, + version: Schema.Number, + contentHash: Schema.String, + url: Schema.String, +}); +export type FinalizedPaywall = typeof FinalizedPaywallSchema.Type; + +const FinalizedComponentSchema = Schema.Struct({ + id: Schema.String, + componentId: Schema.String, + version: Schema.Number, + contentHash: Schema.String, +}); +export type FinalizedComponent = typeof FinalizedComponentSchema.Type; + +/** `POST /api/v1/paywall-deploys/:deployId/finalize` response (contract §4.3). */ +const FinalizeResponseSchema = Schema.Struct({ + deployId: Schema.String, + status: Schema.String, + paywalls: Schema.Array(FinalizedPaywallSchema), + components: Schema.Array(FinalizedComponentSchema), +}); +export type FinalizeResponse = typeof FinalizeResponseSchema.Type; + +/** + * Maps every file hash in the manifest to its project-root-relative path — + * the lookup used to satisfy the server's `missing` list. + */ +export const collectManifestFiles = ( + manifest: DeployManifest, +): Map => { + const files = new Map(); + const add = ( + file: { readonly path: string; readonly sha256: string } | null, + ): void => { + if (file) { + files.set(file.sha256, file.path); + } + }; + for (const paywall of manifest.paywalls) { + add(paywall.source); + add(paywall.artifacts.html); + add(paywall.artifacts.js); + } + for (const component of manifest.components) { + add(component.source); + add(component.manifest); + for (const preview of component.previews) { + add(preview.file); + } + add(component.artifacts.runtime); + add(component.artifacts.panel); + } + add(manifest.config); + for (const asset of manifest.assets) { + add(asset); + } + return files; +}; + +const tryParseJson = (body: string): unknown => { + try { + return JSON.parse(body); + } catch { + return; + } +}; + +/** + * Extracts the `missing` hash list from a finalize `409` body (contract §4.3: + * `409 { missing: [...] }`). Returns `undefined` when the body carries no + * such list — callers then fall back to the generic failure path. + */ +const readMissingHashes = (body: string): string[] | undefined => { + const parsed = tryParseJson(body); + if (typeof parsed !== "object" || parsed === null || !("missing" in parsed)) { + return; + } + const missing = parsed.missing; + if ( + !Array.isArray(missing) || + missing.length === 0 || + !missing.every((hash): hash is string => typeof hash === "string") + ) { + return; + } + return missing; +}; + +/** Renders a non-2xx response into an actionable message (esp. 422 details). */ +const describeHttpFailure = ( + step: string, + status: number, + body: string, +): string => { + const parsed = tryParseJson(body); + const details = + parsed !== undefined ? JSON.stringify(parsed, null, 2) : body.trim(); + const hint = + status === 400 + ? " The server rejected the manifest — your CLI may be outdated; try upgrading voidhash-cli." + : status === 401 + ? " Authentication failed. Run 'voidhash-cli auth login' and retry." + : status === 403 + ? " Check that the team/project in voidhash.config.ts match a project you have access to." + : status === 409 + ? " The deploy is incomplete (blobs missing server-side). Re-run deploy to retry." + : status === 422 + ? " The server rejected the deploy contents:" + : ""; + return `${step} failed with status ${status}.${hint}${ + details ? `\n${details}` : "" + }`; +}; + +const failHttp = ( + step: string, + response: HttpClientResponse.HttpClientResponse, +): Effect.Effect => + response.text.pipe( + Effect.orElseSucceed(() => ""), + Effect.flatMap((body) => + Effect.fail( + new PaywallDeployUploadError({ + message: describeHttpFailure(step, response.status, body), + }), + ), + ), + ); + +const networkFailure = (step: string) => (cause: unknown) => + new PaywallDeployUploadError({ + cause, + message: `${step} failed: could not reach the Voidhash API.`, + }); + +const decodeJson = ( + step: string, + schema: S, + response: HttpClientResponse.HttpClientResponse, +): Effect.Effect => + response.json.pipe( + Effect.mapError(networkFailure(step)), + Effect.flatMap((json) => + Schema.decodeUnknownEffect(schema)(json).pipe( + Effect.mapError( + (cause) => + new PaywallDeployUploadError({ + cause, + message: `${step} returned an unexpected response shape: ${cause.message}`, + }), + ), + ), + ), + ); + +export interface UploadPaywallDeployOptions { + readonly manifest: DeployManifest; + /** Absolute project root the manifest's relative paths resolve against. */ + readonly projectRoot: string; + /** Progress callback, e.g. `Console.log`. */ + readonly onProgress?: (message: string) => Effect.Effect; +} + +export interface UploadPaywallDeployResult { + readonly deployId: string; + readonly finalize: FinalizeResponse; + /** Blobs actually uploaded this run. */ + readonly uploadedCount: number; + /** Manifest files the server already had. */ + readonly cachedCount: number; +} + +/** + * Runs the full contract-§4 deploy flow against the configured Voidhash API: + * + * 1. `POST /api/v1/paywall-deploys` with the manifest → `{ deployId, missing }` + * 2. `PUT /api/v1/paywall-deploys/:deployId/blobs/:sha256` for each missing blob + * 3. `POST /api/v1/paywall-deploys/:deployId/finalize` → released versions/URLs + * + * Requires a logged-in CLI (an `x-api-key` credential in the CLI config). + */ +export const uploadPaywallDeploy = ({ + manifest, + projectRoot, + onProgress, +}: UploadPaywallDeployOptions): Effect.Effect< + UploadPaywallDeployResult, + PaywallDeployUploadError, + HttpClient.HttpClient | CliConfig +> => + Effect.gen(function* uploadPaywallDeploy() { + const httpClient = yield* HttpClient.HttpClient; + const cliConfig = yield* CliConfig; + + const config = yield* cliConfig.readConfig().pipe( + Effect.mapError( + (cause) => + new PaywallDeployUploadError({ + cause, + message: "Failed to read the CLI config.", + }), + ), + ); + if (!config.api_key) { + return yield* Effect.fail( + new PaywallDeployUploadError({ + message: + "You must be logged in to deploy. Run 'voidhash-cli auth login' first.", + }), + ); + } + const apiKey = config.api_key; + + const send = ( + step: string, + request: HttpClientRequest.HttpClientRequest, + ): Effect.Effect< + HttpClientResponse.HttpClientResponse, + PaywallDeployUploadError + > => + httpClient + .execute( + request.pipe( + HttpClientRequest.prependUrl(config.api_url), + HttpClientRequest.setHeaders({ "x-api-key": apiKey }), + ), + ) + .pipe(Effect.mapError(networkFailure(step))); + + const report = (message: string): Effect.Effect => + onProgress ? onProgress(message) : Effect.void; + + // 1. Create the deploy from the manifest. + const createStep = "Creating the deploy"; + const createResponse = yield* send( + createStep, + HttpClientRequest.post("/api/v1/paywall-deploys").pipe( + HttpClientRequest.bodyJsonUnsafe(manifest), + ), + ); + if (createResponse.status < 200 || createResponse.status >= 300) { + return yield* failHttp(createStep, createResponse); + } + const created = yield* decodeJson( + createStep, + CreateDeployResponseSchema, + createResponse, + ); + + // 2. Upload every blob the server is missing. + const filesByHash = collectManifestFiles(manifest); + + const uploadBlob = ( + sha256: string, + ): Effect.Effect => + Effect.gen(function* uploadBlob() { + const relPath = filesByHash.get(sha256); + if (relPath === undefined) { + return yield* Effect.fail( + new PaywallDeployUploadError({ + message: + `The server requested blob ${sha256}, which is not part of the ` + + "manifest. Re-run the build and deploy again.", + }), + ); + } + const bytes = yield* Effect.tryPromise({ + try: () => fsp.readFile(join(projectRoot, relPath)), + catch: (cause) => + new PaywallDeployUploadError({ + cause, + message: `Failed to read ${relPath} for upload.`, + }), + }); + const uploadStep = `Uploading ${relPath}`; + const uploadResponse = yield* send( + uploadStep, + HttpClientRequest.put( + `/api/v1/paywall-deploys/${created.deployId}/blobs/${sha256}`, + ).pipe( + HttpClientRequest.bodyUint8Array(bytes, "application/octet-stream"), + ), + ); + if (uploadResponse.status < 200 || uploadResponse.status >= 300) { + return yield* failHttp(uploadStep, uploadResponse); + } + }); + + yield* report( + `Uploading ${created.missing.length} blob(s) ` + + `(${filesByHash.size - created.missing.length} already on the server)…`, + ); + + for (const sha256 of created.missing) { + yield* uploadBlob(sha256); + } + + // 3. Finalize — the immutable commit point. A 409 with a `missing` list + // (e.g. a blob lost server-side between create and finalize) is retried + // ONCE after re-uploading exactly those blobs; a second failure surfaces + // the server's readable error, hashes included. + const finalizeStep = "Finalizing the deploy"; + const requestFinalize = (): Effect.Effect< + HttpClientResponse.HttpClientResponse, + PaywallDeployUploadError + > => + send( + finalizeStep, + HttpClientRequest.post( + `/api/v1/paywall-deploys/${created.deployId}/finalize`, + ), + ); + + let finalizeResponse = yield* requestFinalize(); + let retriedUploadCount = 0; + + if (finalizeResponse.status === 409) { + const body = yield* finalizeResponse.text.pipe( + Effect.orElseSucceed(() => ""), + ); + const missingOnFinalize = readMissingHashes(body); + if ( + missingOnFinalize === undefined || + missingOnFinalize.some((sha256) => !filesByHash.has(sha256)) + ) { + return yield* Effect.fail( + new PaywallDeployUploadError({ + message: describeHttpFailure(finalizeStep, 409, body), + }), + ); + } + + yield* report( + `Finalize reported ${missingOnFinalize.length} missing blob(s); ` + + "re-uploading and retrying once…", + ); + for (const sha256 of missingOnFinalize) { + yield* uploadBlob(sha256); + retriedUploadCount += 1; + } + finalizeResponse = yield* requestFinalize(); + } + + if (finalizeResponse.status < 200 || finalizeResponse.status >= 300) { + return yield* failHttp(finalizeStep, finalizeResponse); + } + const finalize = yield* decodeJson( + finalizeStep, + FinalizeResponseSchema, + finalizeResponse, + ); + + return { + cachedCount: filesByHash.size - created.missing.length, + deployId: created.deployId, + finalize, + uploadedCount: created.missing.length + retriedUploadCount, + }; + }); diff --git a/apps/cli/src/domain/services/paywall-typecheck.ts b/apps/cli/src/domain/services/paywall-typecheck.ts new file mode 100644 index 000000000..074954401 --- /dev/null +++ b/apps/cli/src/domain/services/paywall-typecheck.ts @@ -0,0 +1,186 @@ +/** + * The deploy typecheck gate: before anything is bundled, the discovered + * `.voidhash` sources are typechecked with the TypeScript compiler API using + * the project's own `tsconfig.json`, and the build fails listing diagnostics. + */ +import { dirname, join } from "node:path"; + +import { Data, Effect } from "effect"; +import ts from "typescript"; + +export class PaywallTypecheckError extends Data.TaggedError( + "PaywallTypecheckError", +)<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** + * Asset extensions the build's esbuild loaders accept in paywall/component + * sources. The typecheck gate injects matching ambient module declarations so + * `import hero from "./hero.png"` typechecks, and the bundler emits/inlines + * the file. + */ +export const PAYWALL_ASSET_EXTENSIONS = [ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "svg", + "ttf", + "otf", + "woff", + "woff2", +] as const; + +/** Ambient `declare module "*.png" { … }` block per supported asset extension. */ +const ASSET_MODULE_DECLARATIONS = PAYWALL_ASSET_EXTENSIONS.map( + (ext) => + `declare module "*.${ext}" {\n const url: string;\n export default url;\n}\n`, +).join("\n"); + +/** + * Virtual file name (resolved under the project root) for the injected asset + * declarations. Never written to disk — served from memory by the gate's + * compiler host. + */ +const ASSET_DECLARATIONS_FILE_NAME = "__voidhash-asset-modules__.d.ts"; + +/** Options used when the project has no `tsconfig.json` to inherit from. */ +const FALLBACK_OPTIONS: ts.CompilerOptions = { + jsx: ts.JsxEmit.ReactJSX, + lib: ["lib.es2022.d.ts", "lib.dom.d.ts"], + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + strict: true, + target: ts.ScriptTarget.ES2022, +}; + +const formatHost: ts.FormatDiagnosticsHost = { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: ts.sys.getCurrentDirectory, + getNewLine: () => ts.sys.newLine, +}; + +const loadCompilerOptions = ( + projectRoot: string, +): { options: ts.CompilerOptions; configPath: string | undefined } => { + const configPath = ts.findConfigFile( + projectRoot, + ts.sys.fileExists, + "tsconfig.json", + ); + if (!configPath) { + return { configPath: undefined, options: { ...FALLBACK_OPTIONS } }; + } + + const read = ts.readConfigFile(configPath, ts.sys.readFile); + if (read.error) { + throw new Error(ts.formatDiagnostics([read.error], formatHost)); + } + const parsed = ts.parseJsonConfigFileContent( + read.config, + ts.sys, + dirname(configPath), + undefined, + configPath, + ); + // "no inputs were found" (18003) is irrelevant — we supply our own roots. + const configErrors = parsed.errors.filter((e) => e.code !== 18_003); + if (configErrors.length > 0) { + throw new Error(ts.formatDiagnostics(configErrors, formatHost)); + } + return { configPath, options: parsed.options }; +}; + +/** + * Wraps a compiler host so the in-memory asset declaration file exists at + * `assetDeclPath` without ever touching disk. + */ +const withAssetDeclarations = ( + host: ts.CompilerHost, + assetDeclPath: string, +): ts.CompilerHost => { + const getSourceFile = host.getSourceFile.bind(host); + const fileExists = host.fileExists.bind(host); + const readFile = host.readFile.bind(host); + return { + ...host, + fileExists: (fileName) => + fileName === assetDeclPath || fileExists(fileName), + getSourceFile: (fileName, languageVersionOrOptions, ...rest) => + fileName === assetDeclPath + ? ts.createSourceFile( + fileName, + ASSET_MODULE_DECLARATIONS, + languageVersionOrOptions, + true, + ) + : getSourceFile(fileName, languageVersionOrOptions, ...rest), + readFile: (fileName) => + fileName === assetDeclPath ? ASSET_MODULE_DECLARATIONS : readFile(fileName), + }; +}; + +/** + * Typechecks the given `.voidhash` source files with the project's + * `tsconfig.json` (falling back to strict react-jsx defaults when the project + * has none). An in-memory ambient declaration file covering the + * esbuild-supported asset extensions ({@link PAYWALL_ASSET_EXTENSIONS}) is + * injected so asset imports typecheck as string default exports. Fails with a + * {@link PaywallTypecheckError} listing every error-severity diagnostic. + */ +export const typecheckPaywallSources = (options: { + readonly projectRoot: string; + readonly files: ReadonlyArray; +}): Effect.Effect => + Effect.try({ + try: () => { + const { options: compilerOptions } = loadCompilerOptions( + options.projectRoot, + ); + + const finalOptions: ts.CompilerOptions = { + ...compilerOptions, + // The gate only checks — never emit, and never stumble over + // third-party declaration files. + incremental: false, + jsx: compilerOptions.jsx ?? ts.JsxEmit.ReactJSX, + noEmit: true, + skipLibCheck: true, + }; + + const assetDeclPath = join( + options.projectRoot, + ASSET_DECLARATIONS_FILE_NAME, + ); + const program = ts.createProgram({ + host: withAssetDeclarations( + ts.createCompilerHost(finalOptions), + assetDeclPath, + ), + options: finalOptions, + rootNames: [...options.files, assetDeclPath], + }); + + const diagnostics = ts + .getPreEmitDiagnostics(program) + .filter((d) => d.category === ts.DiagnosticCategory.Error); + + if (diagnostics.length > 0) { + throw new Error( + `TypeScript found ${diagnostics.length} error(s) in .voidhash sources:\n\n` + + ts.formatDiagnosticsWithColorAndContext(diagnostics, formatHost), + ); + } + }, + catch: (cause) => + new PaywallTypecheckError({ + cause, + message: + cause instanceof Error + ? cause.message + : "Failed to typecheck .voidhash sources.", + }), + }); diff --git a/apps/cli/src/utils/api-client.ts b/apps/cli/src/utils/api-client.ts index 46ee36824..1bd8e1ea1 100644 --- a/apps/cli/src/utils/api-client.ts +++ b/apps/cli/src/utils/api-client.ts @@ -28,7 +28,7 @@ const make = Effect.gen(function* effect() { ); return HttpClientRequest.setHeaders( - HttpClientRequest.prependUrl(request, "http://localhost:8787"), + HttpClientRequest.prependUrl(request, config.api_url), config.api_key ? { "x-api-key": config.api_key } : {} ); }).pipe(Effect.withSpan("ApiClient.transformRequest")) diff --git a/apps/cli/tests/domain/schema/paywall-deploy.test.ts b/apps/cli/tests/domain/schema/paywall-deploy.test.ts new file mode 100644 index 000000000..aef7ee3f7 --- /dev/null +++ b/apps/cli/tests/domain/schema/paywall-deploy.test.ts @@ -0,0 +1,159 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + DEPLOY_MANIFEST_VERSION, + DeployManifestSchema, +} from "../../../src/domain/schema/paywall-deploy"; + +const decode = Schema.decodeUnknownSync(DeployManifestSchema); + +const hash = (char: string): string => char.repeat(64); + +const file = (path: string, char: string) => ({ + bytes: 1024, + path, + sha256: hash(char), +}); + +const artifact = (path: string, char: string, contentType: string) => ({ + ...file(path, char), + contentType, +}); + +/** A fully-populated, contract-§1-shaped manifest fixture. */ +const validManifest = () => ({ + assets: [ + artifact( + ".voidhash/.build/paywalls/onboarding/assets/hero-AB12CD.png", + "e", + "image/png", + ), + ], + cliVersion: "0.0.1-alpha.1", + components: [ + { + artifacts: { + panel: null, + runtime: artifact( + ".voidhash/.build/components/product-option/runtime.js", + "9", + "text/javascript; charset=utf-8", + ), + }, + contentHash: hash("0"), + id: "product-option", + manifest: artifact( + ".voidhash/.build/components/product-option/manifest.json", + "7", + "application/json", + ), + previews: [ + { + file: artifact( + ".voidhash/.build/components/product-option/previews/default.json", + "8", + "application/json", + ), + state: "default", + }, + ], + source: file(".voidhash/components/product-option.tsx", "6"), + title: "Product Option", + }, + ], + config: file("voidhash.config.ts", "d"), + createdAt: "2026-06-11T10:00:00.000Z", + paywalls: [ + { + artifacts: { + html: artifact( + ".voidhash/.build/paywalls/onboarding/index.html", + "b", + "text/html; charset=utf-8", + ), + js: artifact( + ".voidhash/.build/paywalls/onboarding/bundle.js", + "c", + "text/javascript; charset=utf-8", + ), + }, + assets: [".voidhash/.build/paywalls/onboarding/assets/hero-AB12CD.png"], + contentHash: hash("1"), + description: "Full-screen onboarding paywall.", + id: "onboarding", + products: ["yearly", "monthly"], + source: file(".voidhash/paywalls/onboarding.tsx", "a"), + title: "Onboarding", + variables: { accentColor: "#16a34a", maxRows: 3, showTrial: true }, + }, + ], + project: "dev-proj", + runtimeVersion: "0.0.1-alpha.1", + schemaVersion: DEPLOY_MANIFEST_VERSION, + team: "voidhash-dev-sro", +}); + +describe("DeployManifestSchema", () => { + it("decodes a contract-§1 manifest", () => { + const manifest = decode(validManifest()); + + expect(manifest.schemaVersion).toBe(2); + expect(manifest.paywalls[0]?.id).toBe("onboarding"); + expect(manifest.paywalls[0]?.variables).toEqual({ + accentColor: "#16a34a", + maxRows: 3, + showTrial: true, + }); + expect(manifest.components[0]?.artifacts.panel).toBeNull(); + }); + + it("accepts a component-only manifest and a panel artifact", () => { + const fixture = validManifest(); + fixture.paywalls = []; + fixture.components[0]!.artifacts.panel = artifact( + ".voidhash/.build/components/product-option/panel.js", + "f", + "text/javascript; charset=utf-8", + ) as never; + + const manifest = decode(fixture); + expect(manifest.components[0]?.artifacts.panel?.sha256).toBe(hash("f")); + }); + + it("rejects unknown schema versions", () => { + expect(() => decode({ ...validManifest(), schemaVersion: 1 })).toThrow(); + }); + + it("rejects ids that do not match the slug regex", () => { + const fixture = validManifest(); + fixture.paywalls[0]!.id = "Bad_Id"; + expect(() => decode(fixture)).toThrow(); + }); + + it("rejects non-scalar variable values", () => { + const fixture = validManifest(); + fixture.paywalls[0]!.variables = { + accentColor: { hex: "#16a34a" }, + } as never; + expect(() => decode(fixture)).toThrow(); + }); + + it("rejects malformed sha256 digests", () => { + const fixture = validManifest(); + fixture.paywalls[0]!.contentHash = "not-a-hash"; + expect(() => decode(fixture)).toThrow(); + }); + + it("rejects a manifest with no paywalls and no components", () => { + const fixture = validManifest(); + fixture.paywalls = []; + fixture.components = []; + expect(() => decode(fixture)).toThrow(); + }); + + it("rejects missing required fields", () => { + const { config: _config, ...withoutConfig } = validManifest(); + expect(() => decode(withoutConfig)).toThrow(); + }); +}); diff --git a/apps/cli/tests/domain/services/paywall-build-warnings.test.ts b/apps/cli/tests/domain/services/paywall-build-warnings.test.ts new file mode 100644 index 000000000..7c872b885 --- /dev/null +++ b/apps/cli/tests/domain/services/paywall-build-warnings.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { collectRenderErrorPlaceholderReasons } from "../../../src/domain/services/paywall-build"; + +/** A §3 preview tree wrapper, as emitted by `renderToNodeTree`. */ +const tree = (root: unknown) => ({ root, state: "default", treeVersion: 1 }); + +describe("collectRenderErrorPlaceholderReasons", () => { + it("reports a root placeholder produced by a thrown render", () => { + expect( + collectRenderErrorPlaceholderReasons( + tree({ reason: "render threw: boom", type: "placeholder" }), + ), + ).toEqual(["render threw: boom"]); + }); + + it("skips the legitimate render-returned-null placeholder", () => { + expect( + collectRenderErrorPlaceholderReasons( + tree({ reason: "render returned null", type: "placeholder" }), + ), + ).toEqual([]); + }); + + it("finds nested error placeholders and preserves order", () => { + expect( + collectRenderErrorPlaceholderReasons( + tree({ + children: [ + { style: {}, text: "ok", type: "text" }, + { + children: [ + { + reason: 'unsupported element type "div"', + type: "placeholder", + }, + { reason: "render returned null", type: "placeholder" }, + ], + style: {}, + type: "view", + }, + { reason: "render threw: late", type: "placeholder" }, + ], + style: {}, + type: "view", + }), + ), + ).toEqual(['unsupported element type "div"', "render threw: late"]); + }); + + it("returns nothing for clean trees and non-tree values", () => { + expect( + collectRenderErrorPlaceholderReasons( + tree({ children: [], style: {}, type: "view" }), + ), + ).toEqual([]); + expect(collectRenderErrorPlaceholderReasons(undefined)).toEqual([]); + expect(collectRenderErrorPlaceholderReasons("nonsense")).toEqual([]); + expect(collectRenderErrorPlaceholderReasons({ type: "slot" })).toEqual([]); + }); + + it("ignores placeholder-shaped nodes without a string reason", () => { + expect( + collectRenderErrorPlaceholderReasons(tree({ type: "placeholder" })), + ).toEqual([]); + expect( + collectRenderErrorPlaceholderReasons( + tree({ reason: 42, type: "placeholder" }), + ), + ).toEqual([]); + }); +}); diff --git a/apps/cli/tests/domain/services/paywall-closed-imports.test.ts b/apps/cli/tests/domain/services/paywall-closed-imports.test.ts new file mode 100644 index 000000000..a7173d997 --- /dev/null +++ b/apps/cli/tests/domain/services/paywall-closed-imports.test.ts @@ -0,0 +1,142 @@ +import { promises as fsp } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import * as esbuild from "esbuild"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { closedImportsPlugin } from "../../../src/domain/services/paywall-closed-imports"; + +let projectRoot: string; +let voidhashDir: string; + +/** Bare modules marked external so "allowed" imports need no node_modules. */ +const EXTERNALS = [ + "react", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "@voidhash/paywalls", + "@voidhash/paywalls/*", +]; + +const writeSource = async (relPath: string, contents: string) => { + const abs = join(projectRoot, relPath); + await fsp.mkdir(join(abs, ".."), { recursive: true }); + await fsp.writeFile(abs, contents); + return abs; +}; + +/** Bundles `entry` with the plugin; returns esbuild error texts ([] = ok). */ +const buildErrors = async ( + entry: string, + options: esbuild.BuildOptions = {}, +): Promise => { + try { + await esbuild.build({ + bundle: true, + external: EXTERNALS, + format: "esm", + logLevel: "silent", + plugins: [closedImportsPlugin(voidhashDir)], + write: false, + ...options, + entryPoints: [entry], + }); + return []; + } catch (error) { + return ((error as esbuild.BuildFailure).errors ?? []).map((e) => e.text); + } +}; + +beforeAll(async () => { + projectRoot = await fsp.mkdtemp(join(tmpdir(), "voidhash-closed-imports-")); + voidhashDir = join(projectRoot, ".voidhash"); + await writeSource( + ".voidhash/components/helper.ts", + "export const helper = 1;\n", + ); + await fsp.writeFile( + join(projectRoot, "app-code.ts"), + "export const y = 1;\n", + ); +}); + +afterAll(async () => { + await fsp.rm(projectRoot, { force: true, recursive: true }); +}); + +describe("closedImportsPlugin", () => { + it("allows the allowlist plus relative imports within .voidhash", async () => { + const entry = await writeSource( + ".voidhash/components/allowed.ts", + [ + 'import "react";', + 'import "react/jsx-runtime";', + 'import "react/jsx-dev-runtime";', + 'import "@voidhash/paywalls";', + 'import "@voidhash/paywalls/dom";', + 'import "@voidhash/paywalls/panel";', + 'import { helper } from "./helper";', + "export const ok = helper;", + ].join("\n"), + ); + + expect(await buildErrors(entry)).toEqual([]); + }); + + it("rejects react-dom, naming the importing file", async () => { + const entry = await writeSource( + ".voidhash/components/uses-react-dom.ts", + 'import "react-dom";\nexport {};\n', + ); + + const errors = await buildErrors(entry, { + external: [...EXTERNALS, "react-dom"], + }); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('"react-dom"'); + expect(errors[0]).toContain("uses-react-dom.ts"); + }); + + it("rejects arbitrary packages", async () => { + const entry = await writeSource( + ".voidhash/components/uses-lodash.ts", + 'import "lodash";\nexport {};\n', + ); + + const errors = await buildErrors(entry); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('"lodash"'); + }); + + it("rejects the Node-only @voidhash/paywalls/tree entry", async () => { + const entry = await writeSource( + ".voidhash/components/uses-tree.ts", + 'import "@voidhash/paywalls/tree";\nexport {};\n', + ); + + const errors = await buildErrors(entry); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('"@voidhash/paywalls/tree"'); + }); + + it("rejects relative imports escaping .voidhash", async () => { + const entry = await writeSource( + ".voidhash/components/escapes.ts", + 'import "../../app-code";\nexport {};\n', + ); + + const errors = await buildErrors(entry); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("escapes the .voidhash directory"); + }); + + it("does not constrain imports made outside .voidhash (node_modules)", async () => { + const entry = join(projectRoot, "vendor-entry.ts"); + await fsp.writeFile(entry, 'import "react-dom";\nexport {};\n'); + + expect( + await buildErrors(entry, { external: [...EXTERNALS, "react-dom"] }), + ).toEqual([]); + }); +}); diff --git a/apps/cli/tests/domain/services/paywall-content-hash.test.ts b/apps/cli/tests/domain/services/paywall-content-hash.test.ts new file mode 100644 index 000000000..057161425 --- /dev/null +++ b/apps/cli/tests/domain/services/paywall-content-hash.test.ts @@ -0,0 +1,106 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + computeComponentContentHash, + computePaywallContentHash, + sha256Hex, +} from "../../../src/domain/services/paywall-build"; + +const digest = (input: string): string => + createHash("sha256").update(input).digest("hex"); + +const h = sha256Hex("html"); +const j = sha256Hex("js"); +const m = sha256Hex("manifest"); +const r = sha256Hex("runtime"); +const p = sha256Hex("panel"); +const a1 = sha256Hex("asset-1"); +const a2 = sha256Hex("asset-2"); + +describe("computePaywallContentHash", () => { + it("hashes html:js:sortedAssets per contract §1.2", () => { + const sorted = [a1, a2].sort(); + expect( + computePaywallContentHash({ + assetSha256s: [a1, a2], + htmlSha256: h, + jsSha256: j, + }), + ).toBe(digest(`${h}:${j}:${sorted.join(":")}`)); + }); + + it("sorts asset hashes before joining", () => { + const forward = computePaywallContentHash({ + assetSha256s: [a1, a2], + htmlSha256: h, + jsSha256: j, + }); + const reversed = computePaywallContentHash({ + assetSha256s: [a2, a1], + htmlSha256: h, + jsSha256: j, + }); + expect(forward).toBe(reversed); + }); + + it("keeps the trailing separator when there are no assets", () => { + expect( + computePaywallContentHash({ + assetSha256s: [], + htmlSha256: h, + jsSha256: j, + }), + ).toBe(digest(`${h}:${j}:`)); + }); +}); + +describe("computeComponentContentHash", () => { + it("hashes manifest:runtime:panel:sortedPreviews per contract §1.2", () => { + const sorted = [a1, a2].sort(); + expect( + computeComponentContentHash({ + manifestSha256: m, + panelSha256: p, + previewSha256s: [a2, a1], + runtimeSha256: r, + }), + ).toBe(digest(`${m}:${r}:${p}:${sorted.join(":")}`)); + }); + + it("uses the empty string for an absent panel", () => { + const expected = digest(`${m}:${r}::${a1}`); + expect( + computeComponentContentHash({ + manifestSha256: m, + panelSha256: null, + previewSha256s: [a1], + runtimeSha256: r, + }), + ).toBe(expected); + expect( + computeComponentContentHash({ + manifestSha256: m, + previewSha256s: [a1], + runtimeSha256: r, + }), + ).toBe(expected); + }); + + it("distinguishes panel-less and panel-bearing builds", () => { + const without = computeComponentContentHash({ + manifestSha256: m, + panelSha256: null, + previewSha256s: [a1], + runtimeSha256: r, + }); + const withPanel = computeComponentContentHash({ + manifestSha256: m, + panelSha256: p, + previewSha256s: [a1], + runtimeSha256: r, + }); + expect(without).not.toBe(withPanel); + }); +}); diff --git a/apps/cli/tests/domain/services/paywall-deploy-upload.test.ts b/apps/cli/tests/domain/services/paywall-deploy-upload.test.ts new file mode 100644 index 000000000..ebe8d11fd --- /dev/null +++ b/apps/cli/tests/domain/services/paywall-deploy-upload.test.ts @@ -0,0 +1,270 @@ +import { createHash } from "node:crypto"; +import { promises as fsp } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Effect, Schema } from "effect"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { + type DeployManifest, + DeployManifestSchema, +} from "../../../src/domain/schema/paywall-deploy"; +import { CliConfig } from "../../../src/domain/services/cli-config"; +import { + type PaywallDeployUploadError, + type UploadPaywallDeployResult, + uploadPaywallDeploy, +} from "../../../src/domain/services/paywall-deploy-upload"; + +let projectRoot: string; +let manifest: DeployManifest; + +const sha256Hex = (data: string): string => + createHash("sha256").update(data).digest("hex"); + +const FILES = { + config: { contents: "export default {};\n", path: "voidhash.config.ts" }, + html: { + contents: "\n", + path: ".voidhash/.build/paywalls/onboarding/index.html", + }, + js: { + contents: "console.log('paywall');\n", + path: ".voidhash/.build/paywalls/onboarding/bundle.js", + }, + source: { + contents: "export default null;\n", + path: ".voidhash/paywalls/onboarding.tsx", + }, +} as const; + +const hashOf = (file: { contents: string }): string => sha256Hex(file.contents); + +const fileEntry = (file: { contents: string; path: string }) => ({ + bytes: file.contents.length, + path: file.path, + sha256: hashOf(file), +}); + +const buildManifest = (): DeployManifest => + Schema.decodeUnknownSync(DeployManifestSchema)({ + assets: [], + cliVersion: "0.0.1", + components: [], + config: fileEntry(FILES.config), + createdAt: "2026-06-11T10:00:00.000Z", + paywalls: [ + { + artifacts: { + html: { + ...fileEntry(FILES.html), + contentType: "text/html; charset=utf-8", + }, + js: { + ...fileEntry(FILES.js), + contentType: "text/javascript; charset=utf-8", + }, + }, + assets: [], + contentHash: "0".repeat(64), + id: "onboarding", + products: [], + source: fileEntry(FILES.source), + title: "Onboarding", + variables: {}, + }, + ], + project: "dev-proj", + runtimeVersion: "0.0.1", + schemaVersion: 2, + team: "voidhash-dev-sro", + }); + +interface RecordedRequest { + readonly method: string; + readonly path: string; +} + +/** Scripted HTTP stub: routes requests, records calls, counts finalizes. */ +const makeStubClient = (options: { + /** `missing` returned by create-deploy. */ + createMissing: ReadonlyArray; + /** Per-attempt finalize responses (status + JSON body), consumed in order. */ + finalizeResponses: ReadonlyArray<{ status: number; body: unknown }>; + requests: RecordedRequest[]; +}): HttpClient.HttpClient => + HttpClient.make((request) => { + const path = new URL(request.url).pathname; + options.requests.push({ method: request.method, path }); + + const respond = (status: number, body: unknown) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }), + ), + ); + + if (request.method === "POST" && path === "/api/v1/paywall-deploys") { + return respond(201, { + deployId: "pw_dep_test", + missing: options.createMissing, + }); + } + if (request.method === "PUT" && path.includes("/blobs/")) { + return respond(200, {}); + } + if (request.method === "POST" && path.endsWith("/finalize")) { + const attempt = options.requests.filter( + (r) => r.method === "POST" && r.path.endsWith("/finalize"), + ).length; + const scripted = + options.finalizeResponses[attempt - 1] ?? + options.finalizeResponses[options.finalizeResponses.length - 1]; + return respond(scripted?.status ?? 500, scripted?.body ?? {}); + } + return respond(404, {}); + }); + +const cliConfigStub: typeof CliConfig.Service = { + readConfig: () => + Effect.succeed({ + api_key: "vh_sk_test", + api_url: "https://api.voidhash.test", + web_url: "https://voidhash.test", + }), + resetConfig: () => Effect.void, + writeToConfig: () => Effect.void, +}; + +const runUpload = ( + client: HttpClient.HttpClient, +): Promise => + Effect.runPromise( + uploadPaywallDeploy({ manifest, projectRoot }).pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService(CliConfig, cliConfigStub), + ), + ); + +const runUploadError = ( + client: HttpClient.HttpClient, +): Promise => + Effect.runPromise( + uploadPaywallDeploy({ manifest, projectRoot }).pipe( + Effect.flip, + Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService(CliConfig, cliConfigStub), + ), + ); + +const readyFinalizeBody = { + components: [], + deployId: "pw_dep_test", + paywalls: [], + status: "ready", +}; + +beforeAll(async () => { + projectRoot = await fsp.mkdtemp(join(tmpdir(), "voidhash-deploy-upload-")); + for (const file of Object.values(FILES)) { + const abs = join(projectRoot, file.path); + await fsp.mkdir(join(abs, ".."), { recursive: true }); + await fsp.writeFile(abs, file.contents); + } + manifest = buildManifest(); +}); + +afterAll(async () => { + await fsp.rm(projectRoot, { force: true, recursive: true }); +}); + +describe("uploadPaywallDeploy finalize-409 retry", () => { + it("uploads the 409 missing blobs and retries finalize once", async () => { + const requests: RecordedRequest[] = []; + const result = await runUpload( + makeStubClient({ + createMissing: [hashOf(FILES.js)], + finalizeResponses: [ + { body: { missing: [hashOf(FILES.html)] }, status: 409 }, + { body: readyFinalizeBody, status: 200 }, + ], + requests, + }), + ); + + expect(result.finalize.status).toBe("ready"); + // One blob from create's missing list + one re-uploaded after the 409. + expect(result.uploadedCount).toBe(2); + const puts = requests.filter((r) => r.method === "PUT"); + expect(puts.map((r) => r.path)).toEqual([ + `/api/v1/paywall-deploys/pw_dep_test/blobs/${hashOf(FILES.js)}`, + `/api/v1/paywall-deploys/pw_dep_test/blobs/${hashOf(FILES.html)}`, + ]); + expect( + requests.filter((r) => r.path.endsWith("/finalize")), + ).toHaveLength(2); + }); + + it("retries at most once and fails readably when finalize stays 409", async () => { + const requests: RecordedRequest[] = []; + const error = await runUploadError( + makeStubClient({ + createMissing: [], + finalizeResponses: [ + { body: { missing: [hashOf(FILES.html)] }, status: 409 }, + { body: { missing: [hashOf(FILES.html)] }, status: 409 }, + ], + requests, + }), + ); + + expect(error._tag).toBe("PaywallDeployUploadError"); + expect(error.message).toContain("Finalizing the deploy failed"); + expect(error.message).toContain(hashOf(FILES.html)); + expect( + requests.filter((r) => r.path.endsWith("/finalize")), + ).toHaveLength(2); + }); + + it("fails without retrying when the 409 carries no usable missing list", async () => { + const requests: RecordedRequest[] = []; + const error = await runUploadError( + makeStubClient({ + createMissing: [], + finalizeResponses: [{ body: { error: "incomplete" }, status: 409 }], + requests, + }), + ); + + expect(error._tag).toBe("PaywallDeployUploadError"); + expect( + requests.filter((r) => r.path.endsWith("/finalize")), + ).toHaveLength(1); + expect(requests.filter((r) => r.method === "PUT")).toHaveLength(0); + }); + + it("fails without retrying when a 409 hash is not part of the manifest", async () => { + const requests: RecordedRequest[] = []; + const error = await runUploadError( + makeStubClient({ + createMissing: [], + finalizeResponses: [ + { body: { missing: ["f".repeat(64)] }, status: 409 }, + ], + requests, + }), + ); + + expect(error._tag).toBe("PaywallDeployUploadError"); + expect(error.message).toContain("f".repeat(64)); + expect( + requests.filter((r) => r.path.endsWith("/finalize")), + ).toHaveLength(1); + }); +}); diff --git a/apps/cli/tests/domain/services/paywall-typecheck.test.ts b/apps/cli/tests/domain/services/paywall-typecheck.test.ts new file mode 100644 index 000000000..b10caaac0 --- /dev/null +++ b/apps/cli/tests/domain/services/paywall-typecheck.test.ts @@ -0,0 +1,102 @@ +import { promises as fsp } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Effect } from "effect"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { + PAYWALL_ASSET_EXTENSIONS, + PaywallTypecheckError, + typecheckPaywallSources, +} from "../../../src/domain/services/paywall-typecheck"; + +let projectRoot: string; + +const writeSource = async (relPath: string, contents: string) => { + const abs = join(projectRoot, relPath); + await fsp.mkdir(join(abs, ".."), { recursive: true }); + await fsp.writeFile(abs, contents); + return abs; +}; + +beforeAll(async () => { + projectRoot = await fsp.mkdtemp(join(tmpdir(), "voidhash-typecheck-")); +}); + +afterAll(async () => { + await fsp.rm(projectRoot, { force: true, recursive: true }); +}); + +describe("typecheckPaywallSources", () => { + it("passes a source importing a .png via the injected asset declarations", async () => { + const entry = await writeSource( + ".voidhash/paywalls/with-asset.ts", + [ + 'import hero from "./hero.png";', + "export const heroUrl: string = hero;", + "", + ].join("\n"), + ); + + await expect( + Effect.runPromise( + typecheckPaywallSources({ files: [entry], projectRoot }), + ), + ).resolves.toBeUndefined(); + }); + + it("covers every esbuild-supported asset extension", async () => { + const imports = PAYWALL_ASSET_EXTENSIONS.map( + (ext, i) => `import asset${i} from "./asset.${ext}";`, + ); + const uses = PAYWALL_ASSET_EXTENSIONS.map( + (_, i) => `export const url${i}: string = asset${i};`, + ); + const entry = await writeSource( + ".voidhash/paywalls/all-assets.ts", + [...imports, ...uses, ""].join("\n"), + ); + + await expect( + Effect.runPromise( + typecheckPaywallSources({ files: [entry], projectRoot }), + ), + ).resolves.toBeUndefined(); + }); + + it("still fails a genuinely type-broken source", async () => { + const entry = await writeSource( + ".voidhash/paywalls/broken.ts", + [ + 'import hero from "./hero.png";', + "export const broken: number = hero;", + "", + ].join("\n"), + ); + + const error = await Effect.runPromise( + typecheckPaywallSources({ files: [entry], projectRoot }).pipe( + Effect.flip, + ), + ); + expect(error).toBeInstanceOf(PaywallTypecheckError); + expect(error.message).toContain("broken.ts"); + }); + + it("still fails an import of an undeclared module kind", async () => { + const entry = await writeSource( + ".voidhash/paywalls/bad-import.ts", + ['import data from "./data.bin";', "export const d = data;", ""].join( + "\n", + ), + ); + + const error = await Effect.runPromise( + typecheckPaywallSources({ files: [entry], projectRoot }).pipe( + Effect.flip, + ), + ); + expect(error).toBeInstanceOf(PaywallTypecheckError); + }); +}); diff --git a/apps/studio/index.html b/apps/studio/index.html new file mode 100644 index 000000000..5c996c28c --- /dev/null +++ b/apps/studio/index.html @@ -0,0 +1,12 @@ + + + + + + Voidhash Studio + + +
+ + + diff --git a/apps/studio/package.json b/apps/studio/package.json new file mode 100644 index 000000000..c78ad798e --- /dev/null +++ b/apps/studio/package.json @@ -0,0 +1,40 @@ +{ + "name": "@voidhash/studio", + "version": "0.0.1-alpha.1", + "private": true, + "description": "Voidhash paywall preview studio (Vite app launched by the CLI).", + "license": "MIT", + "type": "module", + "exports": { + "./package.json": "./package.json", + "./server": { + "import": "./src/server/index.ts" + } + }, + "files": [ + "src", + "index.html", + "vite.config.ts" + ], + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.13", + "@vitejs/plugin-react": "^5.2.0", + "@voidhash/paywalls": "workspace:*", + "react": "catalog:", + "react-dom": "catalog:", + "tailwindcss": "^4.1.13", + "vite": "^7.0.0" + }, + "devDependencies": { + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@voidhash/tsconfig": "workspace:*", + "typescript": "5.6.3" + } +} diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx new file mode 100644 index 000000000..03cc3233a --- /dev/null +++ b/apps/studio/src/App.tsx @@ -0,0 +1,88 @@ +import { type ReactNode, useMemo, useState } from "react"; + +import { PaywallPreview } from "./components/PaywallPreview"; +import { Sidebar } from "./components/Sidebar"; +import { loadProjectContent } from "./voidhash/paywalls"; +import { + createStudioBridge, + DEFAULT_PREVIEW_CONFIG, + type PreviewEvent, +} from "./voidhash/preview-runtime"; + +const projectName = (root: string): string => + root.replace(/\/+$/, "").split("/").pop() || root; + +export const App = (): ReactNode => { + // Recomputed every render so HMR (add/remove/edit paywalls) is reflected. + const content = loadProjectContent(); + const [selectedId, setSelectedId] = useState(null); + const [events, setEvents] = useState>([]); + + const bridge = useMemo( + () => createStudioBridge((event) => setEvents((prev) => [...prev, event])), + [], + ); + + // Resolve the active paywall, falling back to the first available one so the + // preview is never empty when paywalls exist. + const selected = + content.paywalls.find((p) => p.id === selectedId) ?? + content.paywalls[0] ?? + null; + + const handleSelect = (id: string) => { + setSelectedId(id); + setEvents([]); + }; + + return ( +
+
+
+ Voidhash Studio + + {projectName(content.projectRoot)} + +
+ {selected && ( +
+ {selected.title} + + {DEFAULT_PREVIEW_CONFIG.platform} + +
+ )} +
+ +
+
+ {selected ? ( + + ) : ( +
+ Create a paywall in{" "} + + .voidhash/paywalls + {" "} + to preview it here. +
+ )} +
+ + setEvents([])} + onSelect={handleSelect} + paywalls={content.paywalls} + selectedId={selected?.id ?? null} + /> +
+
+ ); +}; diff --git a/apps/studio/src/components/ComponentPreview.tsx b/apps/studio/src/components/ComponentPreview.tsx new file mode 100644 index 000000000..868a92382 --- /dev/null +++ b/apps/studio/src/components/ComponentPreview.tsx @@ -0,0 +1,136 @@ +import { + type ActionMap, + type ComponentPreviewState, + type InferComponentProps, + type PaywallProduct, + type PaywallRuntimeConfig, + PaywallRuntimeProvider, + type PropMap, + type PropSchema, + RendererProvider, +} from "@voidhash/paywalls"; +import { createElement, type ReactNode } from "react"; + +import type { AnyComponentDefinition } from "../voidhash/paywalls"; +import { + DEFAULT_PREVIEW_CONFIG, + SILENT_BRIDGE, +} from "../voidhash/preview-runtime"; +import { PreviewErrorBoundary } from "./PreviewErrorBoundary"; + +const PLACEHOLDER_IMAGE = `data:image/svg+xml,${encodeURIComponent( + '', +)}`; + +/** Best-effort value for a required prop with no fixture and no default. */ +const placeholderValue = ( + schema: PropSchema, + products: ReadonlyArray, +): unknown => { + switch (schema.kind) { + case "string": + return schema.label ?? "Text"; + case "number": + return 0; + case "boolean": + return false; + case "select": + return schema.options?.[0] ?? ""; + case "image": + return PLACEHOLDER_IMAGE; + case "ref": + // Only `ref("product")` exists in Phase 1: fall back to the first mock + // product when the default preview state declares no fixture. + return products[0]; + case "component": + return null; + case "array": + return []; + default: + return; + } +}; + +/** The "default" preview state, or the first declared one as a stand-in. */ +const defaultPreviewState = ( + definition: AnyComponentDefinition, +): ComponentPreviewState | undefined => + definition.previews.default ?? Object.values(definition.previews)[0]; + +/** Mock runtime config, overridden by the preview state's declared data. */ +const previewConfig = ( + state: ComponentPreviewState | undefined, +): PaywallRuntimeConfig => ({ + ...DEFAULT_PREVIEW_CONFIG, + products: state?.data?.products ?? DEFAULT_PREVIEW_CONFIG.products, + variables: state?.data?.variables ?? DEFAULT_PREVIEW_CONFIG.variables, +}); + +/** + * Resolves the props to mount a component preview with: declared preview-state + * fixtures first, then schema defaults, then best-effort placeholders for + * whatever required props remain (optional props stay unset). + */ +const buildPreviewProps = ( + definition: AnyComponentDefinition, + state: ComponentPreviewState | undefined, + products: ReadonlyArray, +): Record => { + const fixtures: Record = state?.props ?? {}; + const props: Record = {}; + for (const [name, builder] of Object.entries(definition.props)) { + if (fixtures[name] !== undefined) { + props[name] = fixtures[name]; + continue; + } + const schema = builder.schema; + if (schema.hasDefault) { + props[name] = schema.defaultValue; + } else if (!schema.optional) { + props[name] = placeholderValue(schema, products); + } + } + return props; +}; + +export interface ComponentPreviewProps { + definition: AnyComponentDefinition; +} + +/** + * A best-effort static preview of a `defineComponent(...)` definition: mounts + * `definition.component` with its default-preview props inside the same + * renderer + runtime providers a real paywall renders under, against the mock + * Studio config (envelopes go to a silent bridge). + */ +export const ComponentPreview = ({ + definition, +}: ComponentPreviewProps): ReactNode => { + const state = defaultPreviewState(definition); + const config = previewConfig(state); + const props = buildPreviewProps(definition, state, config.products); + + return ( +
+ ( +

+ Failed to render: {error.message} +

+ )} + > + + + {createElement( + definition.component, + // Safe: the definition's prop/action generics are erased for + // dynamically discovered components; the props are best-effort + // fixtures built from its own schemas above. + props as InferComponentProps, + )} + + +
+
+ ); +}; diff --git a/apps/studio/src/components/PaywallPreview.tsx b/apps/studio/src/components/PaywallPreview.tsx new file mode 100644 index 000000000..f7e4efcf4 --- /dev/null +++ b/apps/studio/src/components/PaywallPreview.tsx @@ -0,0 +1,36 @@ +import { + type PaywallBridge, + PaywallRenderer, + type PaywallRuntimeConfig, +} from "@voidhash/paywalls"; +import type { ReactNode } from "react"; + +import type { PaywallEntry } from "../voidhash/paywalls"; +import { PhoneFrame } from "./PhoneFrame"; +import { PreviewErrorBoundary } from "./PreviewErrorBoundary"; + +export interface PaywallPreviewProps { + entry: PaywallEntry; + config: PaywallRuntimeConfig; + bridge: PaywallBridge; +} + +/** + * Renders the selected paywall inside the phone frame using the real + * `@voidhash/paywalls` DOM renderer — the same code path that runs on a device. + */ +export const PaywallPreview = ({ + entry, + config, + bridge, +}: PaywallPreviewProps): ReactNode => ( + + + + + +); diff --git a/apps/studio/src/components/PhoneFrame.tsx b/apps/studio/src/components/PhoneFrame.tsx new file mode 100644 index 000000000..626c1796e --- /dev/null +++ b/apps/studio/src/components/PhoneFrame.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from "react"; + +export interface PhoneFrameProps { + children: ReactNode; +} + +/** + * A 9:16 device mock. The screen fills the available height, keeps the phone + * aspect ratio, and clips its content so a paywall renders exactly as it would + * on a real device. The paywall owns the full screen via `absolute inset-0`. + */ +export const PhoneFrame = ({ children }: PhoneFrameProps): ReactNode => ( +
+
+ {/* Notch */} +
+ {/* Screen — the paywall mounts here */} +
+ {children} +
+ {/* Home indicator */} +
+
+
+); diff --git a/apps/studio/src/components/PreviewErrorBoundary.tsx b/apps/studio/src/components/PreviewErrorBoundary.tsx new file mode 100644 index 000000000..23eaef9dd --- /dev/null +++ b/apps/studio/src/components/PreviewErrorBoundary.tsx @@ -0,0 +1,52 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; + +export interface PreviewErrorBoundaryProps { + children: ReactNode; + /** Replaces the default full-size error panel (e.g. a compact card note). */ + fallback?: (error: Error) => ReactNode; +} + +interface PreviewErrorBoundaryState { + error: Error | null; +} + +/** + * Stops a runtime error in author code (a paywall or a component preview) from + * blanking the whole Studio. Key it by the previewed entry's id upstream so + * switching entries clears a previous error. + */ +export class PreviewErrorBoundary extends Component< + PreviewErrorBoundaryProps, + PreviewErrorBoundaryState +> { + state: PreviewErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): PreviewErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + // biome-ignore lint/suspicious/noConsole: surface author errors in the dev console. + console.error("[voidhash-studio] preview render error", error, info); + } + + render(): ReactNode { + const { error } = this.state; + if (error) { + if (this.props.fallback) { + return this.props.fallback(error); + } + return ( +
+

+ This paywall failed to render +

+
+            {error.message}
+          
+
+ ); + } + return this.props.children; + } +} diff --git a/apps/studio/src/components/Sidebar.tsx b/apps/studio/src/components/Sidebar.tsx new file mode 100644 index 000000000..4e87a65f2 --- /dev/null +++ b/apps/studio/src/components/Sidebar.tsx @@ -0,0 +1,206 @@ +import type { PaywallOutboundEnvelope } from "@voidhash/paywalls"; +import type { ReactNode } from "react"; + +import { cn } from "../lib/cn"; +import type { + ComponentEntry, + InvalidEntry, + PaywallEntry, +} from "../voidhash/paywalls"; +import type { PreviewEvent } from "../voidhash/preview-runtime"; +import { ComponentPreview } from "./ComponentPreview"; + +const SectionLabel = ({ children }: { children: ReactNode }): ReactNode => ( +
+ {children} +
+); + +/** A one-line summary of an envelope's payload, or null when it has none. */ +const envelopeDetail = (envelope: PaywallOutboundEnvelope): string | null => { + switch (envelope.type) { + case "ready": + return envelope.payload?.templateVersion ?? null; + case "close": + return envelope.payload?.reason ?? null; + case "purchase": + return envelope.payload.productId; + case "restore": + return envelope.payload?.source ?? null; + case "openExternal": + return envelope.payload.url; + case "event": + return envelope.payload.properties + ? `${envelope.payload.name} ${JSON.stringify(envelope.payload.properties)}` + : envelope.payload.name; + case "log": + return `${envelope.payload.level}: ${envelope.payload.message}`; + default: + return null; + } +}; + +export interface SidebarProps { + paywalls: ReadonlyArray; + components: ReadonlyArray; + invalid: ReadonlyArray; + events: ReadonlyArray; + selectedId: string | null; + onSelect: (id: string) => void; + onClearEvents: () => void; +} + +/** + * The right-hand panel: the list of paywalls (primary navigation), any invalid + * files, the project's reusable components (with a best-effort static + * preview each), and a live log of the bridge envelopes the previewed paywall + * posts. + */ +export const Sidebar = ({ + paywalls, + components, + invalid, + events, + selectedId, + onSelect, + onClearEvents, +}: SidebarProps): ReactNode => ( + +); diff --git a/apps/studio/src/components/ui/button.tsx b/apps/studio/src/components/ui/button.tsx new file mode 100644 index 000000000..7b0fbf708 --- /dev/null +++ b/apps/studio/src/components/ui/button.tsx @@ -0,0 +1,44 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +import { cn } from "../../lib/cn"; + +type Variant = "default" | "secondary" | "ghost" | "outline"; +type Size = "sm" | "md" | "icon"; + +const VARIANTS: Record = { + default: "bg-emerald-600 text-white hover:bg-emerald-500", + secondary: "bg-neutral-800 text-neutral-100 hover:bg-neutral-700", + ghost: "bg-transparent text-neutral-300 hover:bg-neutral-800", + outline: + "border border-neutral-700 bg-transparent text-neutral-200 hover:bg-neutral-800", +}; + +const SIZES: Record = { + sm: "h-8 px-3 text-xs", + md: "h-9 px-4 text-sm", + icon: "h-8 w-8 text-sm", +}; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: Variant; + size?: Size; + children?: ReactNode; +} + +/** Minimal shadcn-style button. */ +export const Button = ({ + variant = "default", + size = "md", + className, + ...props +}: ButtonProps): ReactNode => ( +
+ + +

Click to copy

+
+ + +
+ ); +} diff --git a/packages/ui/components/data-table-skeleton.tsx b/packages/ui/components/data-table-skeleton.tsx new file mode 100644 index 000000000..9d15e7bcf --- /dev/null +++ b/packages/ui/components/data-table-skeleton.tsx @@ -0,0 +1,19 @@ +import { Skeleton } from "./ui/skeleton"; +import { Table, TableCell, TableRow } from "./ui/table"; + +export function DataTableSkeleton() { + return ( +
+ + {Array.from({ length: 10 }).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton + + + + + + ))} +
+
+ ); +} diff --git a/packages/ui/components/error-card.tsx b/packages/ui/components/error-card.tsx new file mode 100644 index 000000000..fd5986e65 --- /dev/null +++ b/packages/ui/components/error-card.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { cn } from "../lib/utils"; +import { Logo } from "./logo"; +import { Button } from "./ui/button"; + +export function ErrorCard({ + title, + description, + onRetry, + className, +}: { + title: string; + description: string; + className?: string; + onRetry: () => void; +}) { + return ( +
+ + + +
+

{title}

+

{description}

+
+ +
+ ); +} diff --git a/packages/ui/components/gradient-avatar.tsx b/packages/ui/components/gradient-avatar.tsx new file mode 100644 index 000000000..89d774e88 --- /dev/null +++ b/packages/ui/components/gradient-avatar.tsx @@ -0,0 +1,239 @@ +import { cn } from "../lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; + +function simpleHash(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.codePointAt(i) ?? 0; + // biome-ignore lint/nursery/noBitwiseOperators: required + hash = (hash << 5) - hash + char; + // biome-ignore lint/style/useShorthandAssign: required + // biome-ignore lint/nursery/noBitwiseOperators: required + hash &= hash; // Convert to 32-bit integer + } + return Math.abs(hash); +} + +/** + * Convert HSL to RGB + * + * @see {@link http://zh.wikipedia.org/wiki/HSL和HSV色彩空间} for further information. + * @param {Number} H Hue ∈ [0, 360) + * @param {Number} S Saturation ∈ [0, 1] + * @param {Number} L Lightness ∈ [0, 1] + * @returns {Array} R, G, B ∈ [0, 255] + */ +const HSL2RGB = (HBase: number, S: number, L: number) => { + const H = HBase / 360; + + const q = L < 0.5 ? L * (1 + S) : L + S - L * S; + const p = 2 * L - q; + + return [H + 1 / 3, H, H - 1 / 3].map((c) => { + let color = c; // To prevent parameter assignment + if (color < 0) { + color++; + } + if (color > 1) { + color--; + } + if (color < 1 / 6) { + color = p + (q - p) * 6 * color; + } else if (color < 0.5) { + color = q; + } else if (color < 2 / 3) { + color = p + (q - p) * 6 * (2 / 3 - color); + } else { + color = p; + } + return Math.round(color * 255); + }); +}; + +/** + * Convert RGB Array to HEX + * + * @param {Array} RGBArray - [R, G, B] + * @returns {String} 6 digits hex starting with # + */ +const RGB2HEX = (RGBArray: number[]) => { + let hex = "#"; + for (const value of RGBArray) { + if (value < 16) { + hex += 0; + } + hex += value.toString(16); + } + return hex; +}; + +class ColorHash { + L: number[]; + S: number[]; + hueRanges: { max: number; min: number }[]; + // hash: (str: string) => number; + + constructor( + options: { + lightness?: number | number[]; + saturation?: number | number[]; + hue?: number | { max: number; min: number } | { max: number; min: number }[]; + hash?: string | ((str: string) => number); + } = {}, + ) { + const [L, S] = [options.lightness, options.saturation].map((param) => { + const paramValue = param !== undefined ? param : [0.35, 0.5, 0.65]; // note that 3 is a prime + return Array.isArray(paramValue) ? paramValue.concat() : [paramValue]; + }); + + // biome-ignore lint/style/noNonNullAssertion: required + this.L = L!; + // biome-ignore lint/style/noNonNullAssertion: required + this.S = S!; + + if (typeof options.hue === "number") { + options.hue = { max: options.hue, min: options.hue }; + } + if (typeof options.hue === "object" && !Array.isArray(options.hue)) { + options.hue = [options.hue]; + } + if (typeof options.hue === "undefined") { + options.hue = []; + } + this.hueRanges = options.hue.map((range) => ({ + max: typeof range.max === "undefined" ? 360 : range.max, + min: typeof range.min === "undefined" ? 0 : range.min, + })); + } + + /** + * Returns the hash in [h, s, l]. + * Note that H ∈ [0, 360); S ∈ [0, 1]; L ∈ [0, 1]; + * + * @param {String} str string to hash + * @returns {Array} [h, s, l] + */ + hsl(str: string): [number, number, number] { + let hash = simpleHash(str); + const hueResolution = 727; + let H: number; + // biome-ignore lint/style/useConst: let me live + let S: number; + // biome-ignore lint/style/useConst: let me live + let L: number; + + if (this.hueRanges.length) { + // biome-ignore lint/style/noNonNullAssertion: required + const range = this.hueRanges[hash % this.hueRanges.length]!; + H = + (((hash / this.hueRanges.length) % hueResolution) * (range.max - range.min)) / + hueResolution + + range.min; + } else { + H = hash % 359; + } + hash = Math.ceil(hash / 360); + // biome-ignore lint/style/noNonNullAssertion: required + S = this.S[hash % this.S.length]!; + hash = Math.ceil(hash / this.S.length); + // biome-ignore lint/style/noNonNullAssertion: required + L = this.L[hash % this.L.length]!; + + // biome-ignore lint/style/noNonNullAssertion: required + return [H, S!, L!]; + } + + /** + * Returns the hash in [r, g, b]. + * Note that R, G, B ∈ [0, 255] + * + * @param {String} str string to hash + * @returns {Array} [r, g, b] + */ + rgb(str: string) { + const hsl = this.hsl(str); + return HSL2RGB.apply(this, hsl); + } + + /** + * Returns the hash in hex + * + * @param {String} str string to hash + * @returns {String} hex with # + */ + hex(str: string) { + const rgb = this.rgb(str); + return RGB2HEX(rgb); + } + + hexPair(str: string) { + const s1Hsl = this.hsl(str); + const s2Hsl = [(s1Hsl[0] + 87) % 360, s1Hsl[1], s1Hsl[2]]; + const rgb1 = HSL2RGB.apply(this, s1Hsl); + const rgb2 = HSL2RGB.apply(this, s2Hsl as [number, number, number]); + const hex1 = RGB2HEX(rgb1); + const hex2 = RGB2HEX(rgb2); + return [hex1, hex2]; + } +} + +const colorHash = new ColorHash({ saturation: 1 }); + +const stringToColours = (s: string): string[] => colorHash.hexPair(s); + +const generateColours = (s: string): [string, string] => { + const s1 = s.slice(0, s.length / 2); + const [c1, c2] = stringToColours(s1); + // biome-ignore lint/style/noNonNullAssertion: required + return [c1!, c2!]; +}; + +const generateDataUrl = (s: string): string => { + const [c1, c2] = generateColours(s ?? "null"); + const size = 256; + const svg = ` + + + + + + + + + + `.trim(); + + return `data:image/svg+xml;base64,${btoa(svg)}`; +}; + +export function GradientAvatar({ + src, + className, + alt, + fallback, + gradientUrl, +}: { + src?: string; + className?: string; + alt: string; + fallback: string; + gradientUrl?: string; +}) { + // Generate gradient data URL if no src or gradientUrl is provided + const avatarSrc = src ?? gradientUrl ?? generateDataUrl(fallback); + + return ( + // Avatars are always circular — force `rounded-full` to win over any + // `rounded-*` a call site passes (the root's `overflow-hidden` clips the + // image/gradient to the circle). + + {src ? ( + + ) : ( + // biome-ignore lint/performance/noImgElement: custom image loading + {alt} + )} + {fallback?.slice(0, 2)} + + ); +} diff --git a/packages/ui/components/info-tooltip.tsx b/packages/ui/components/info-tooltip.tsx new file mode 100644 index 000000000..1e979791e --- /dev/null +++ b/packages/ui/components/info-tooltip.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@voidhash/ui"; +import { InfoIcon } from "lucide-react"; +import { useState } from "react"; +import { useDebouncedCallback } from "use-debounce"; + +export function InfoTooltip({ info }: { info: string }) { + const [showAppUserIdTooltip, setShowAppUserIdTooltip] = useState(false); + const debouncedShowAppUserIdTooltip = useDebouncedCallback( + // function + (value: boolean) => { + setShowAppUserIdTooltip(value); + }, + // delay in ms + 40, + ); + return ( + + debouncedShowAppUserIdTooltip(true)} + onMouseLeave={() => debouncedShowAppUserIdTooltip(false)} + > +
+ +
+
+ +

{info}

+
+
+ ); +} diff --git a/packages/ui/components/logo.tsx b/packages/ui/components/logo.tsx new file mode 100644 index 000000000..fb02201c9 --- /dev/null +++ b/packages/ui/components/logo.tsx @@ -0,0 +1,51 @@ +import type { SVGProps } from "react"; + +import { cn } from "../lib/utils"; + +export const Logo = ({ + className, + variant = "default", + color = "mono", +}: SVGProps & { + variant?: "default" | "short" | "symbol"; + color?: "dual-tone" | "mono"; +}) => { + const symbolColor = color === "dual-tone" ? "#005EFF" : "currentColor"; + + if (variant === "symbol") { + return ( + + Voidhash + + + ); + } + + // Default and short variants use the full logo + return ( + + Voidhash + + + + ); +}; diff --git a/packages/ui/components/page.tsx b/packages/ui/components/page.tsx new file mode 100644 index 000000000..515269003 --- /dev/null +++ b/packages/ui/components/page.tsx @@ -0,0 +1,36 @@ +import { cn } from "@voidhash/ui"; + +export function Page({ children, className }: React.ComponentProps<"div">) { + return
{children}
; +} + +export type PageHeaderProps = { + children?: React.ReactNode; + rightActions?: React.ReactNode; + className?: string; +}; +export function PageHeader({ rightActions, children, className }: PageHeaderProps) { + return ( +
+
{children}
+ {rightActions &&
{rightActions}
} +
+ ); +} + +export type PageTitleProps = { + children?: React.ReactNode; + className?: string; +}; +export function PageHeaderTitle({ children, className }: PageTitleProps) { + return

{children}

; +} + +export function PageSection() {} + +export function PageSectionHeader() {} diff --git a/packages/ui/components/settings-card-skeleton.tsx b/packages/ui/components/settings-card-skeleton.tsx new file mode 100644 index 000000000..d698aac67 --- /dev/null +++ b/packages/ui/components/settings-card-skeleton.tsx @@ -0,0 +1,42 @@ +import { Card, CardContent, CardDescription, CardFooter, CardTitle } from "./ui/card"; +import { Skeleton } from "./ui/skeleton"; + +export function SettingsCardSkeleton({ + description = true, + footer = true, + content = false, + instructions = true, + action = true, +}: { + description?: boolean; + footer?: boolean; + content?: boolean; + instructions?: boolean; + action?: boolean; +}) { + return ( + + + + + + {description && ( + + + + )} + {content && } + + {footer && ( + +
{instructions && }
+ {action && ( +
+ +
+ )} +
+ )} +
+ ); +} diff --git a/packages/ui/components/spinner.tsx b/packages/ui/components/spinner.tsx new file mode 100644 index 000000000..b56af8f88 --- /dev/null +++ b/packages/ui/components/spinner.tsx @@ -0,0 +1,26 @@ +import type React from "react"; + +import { cn } from "../lib/utils"; + +export function Spinner({ + className = "w-6 h-6", + pathClassName, + ...props +}: React.HTMLAttributes & { pathClassName?: string }) { + return ( + // biome-ignore lint/a11y/useSemanticElements: shadcn +
+ + Loading... + + + +
+ ); +} diff --git a/packages/ui/components/theme-provider-tanstack.tsx b/packages/ui/components/theme-provider-tanstack.tsx new file mode 100644 index 000000000..af90e0454 --- /dev/null +++ b/packages/ui/components/theme-provider-tanstack.tsx @@ -0,0 +1,366 @@ +"use client"; + +/* + This file is adapted from next-themes to work with tanstack start. + next-themes can be found at https://github.com/pacocoursey/next-themes under the MIT license. +*/ + +import * as React from "react"; + +interface ValueObject { + [themeName: string]: string; +} + +export interface UseThemeProps { + /** List of all available theme names */ + themes: string[]; + /** Forced theme name for the current page */ + forcedTheme?: string | undefined; + /** Update the theme */ + setTheme: React.Dispatch>; + /** Active theme name */ + theme?: string | undefined; + /** If enableSystem is true, returns the System theme preference ("dark" or "light"), regardless what the active theme is */ + systemTheme?: "dark" | "light" | undefined; +} + +export type Attribute = `data-${string}` | "class"; + +export interface ThemeProviderProps extends React.PropsWithChildren { + /** List of all available theme names */ + themes?: string[] | undefined; + /** Forced theme name for the current page */ + forcedTheme?: string | undefined; + /** Whether to switch between dark and light themes based on prefers-color-scheme */ + enableSystem?: boolean | undefined; + /** Disable all CSS transitions when switching themes */ + disableTransitionOnChange?: boolean | undefined; + /** Whether to indicate to browsers which color scheme is used (dark or light) for built-in UI like inputs and buttons */ + enableColorScheme?: boolean | undefined; + /** Key used to store theme setting in localStorage */ + storageKey?: string | undefined; + /** Default theme name (for v0.0.12 and lower the default was light). If `enableSystem` is false, the default theme is light */ + defaultTheme?: string | undefined; + /** HTML attribute modified based on the active theme. Accepts `class`, `data-*` (meaning any data attribute, `data-mode`, `data-color`, etc.), or an array which could include both */ + attribute?: Attribute | Attribute[] | undefined; + /** Mapping of theme name to HTML attribute value. Object where key is the theme name and value is the attribute value */ + value?: ValueObject | undefined; + /** Nonce string to pass to the inline script for CSP headers */ + nonce?: string | undefined; +} + +const colorSchemes = new Set(["light", "dark"]); +const MEDIA = "(prefers-color-scheme: dark)"; +const isServer = typeof window === "undefined"; +const ThemeContext = React.createContext(undefined); +// biome-ignore lint/suspicious/noEmptyBlockStatements: default value +const defaultContext: UseThemeProps = { setTheme: (_) => {}, themes: [] }; + +export const useTheme = () => React.useContext(ThemeContext) ?? defaultContext; + +export const ThemeProviderTanstack = ( + props: ThemeProviderProps & { suppressHydrationWarning?: boolean }, +): React.ReactNode => { + const context = React.useContext(ThemeContext); + + // Ignore nested context providers, just passthrough children + if (context) { + return props.children; + } + return ; +}; + +const defaultThemes = ["light", "dark"]; + +const Theme = ({ + forcedTheme, + disableTransitionOnChange = false, + enableSystem = true, + enableColorScheme = true, + storageKey = "theme", + themes = defaultThemes, + defaultTheme = enableSystem ? "system" : "light", + attribute = "data-theme", + value, + children, + nonce, +}: ThemeProviderProps) => { + const [theme, setThemeState] = React.useState(() => getTheme(storageKey, defaultTheme)); + const attrs = value ? Object.values(value) : themes; + + // apply selected theme function (light, dark, system) + // biome-ignore lint/correctness/useExhaustiveDependencies: + const applyTheme = React.useCallback((theme: string | undefined) => { + let resolved = theme; + if (!resolved) { + return; + } + + // If theme is system, resolve it before setting theme + if (theme === "system" && enableSystem) { + resolved = getSystemTheme(); + } + + const name = value ? value[resolved] : resolved; + const enable = disableTransitionOnChange ? disableAnimation() : null; + const d = document.documentElement; + + const handleAttribute = (attr: Attribute) => { + if (attr === "class") { + d.classList.remove(...attrs); + if (name) { + d.classList.add(name); + } + } else if (attr.startsWith("data-")) { + if (name) { + d.setAttribute(attr, name); + } else { + d.removeAttribute(attr); + } + } + }; + + if (Array.isArray(attribute)) { + attribute.forEach(handleAttribute); + } else { + handleAttribute(attribute); + } + + if (enableColorScheme) { + const fallback = colorSchemes.has(defaultTheme) ? defaultTheme : null; + const colorScheme = colorSchemes.has(resolved) ? resolved : fallback; + // @ts-expect-error + d.style.colorScheme = colorScheme; + } + + enable?.(); + }, []); + + // Set theme state and save to local storage + // biome-ignore lint/correctness/useExhaustiveDependencies: + const setTheme = React.useCallback( + // biome-ignore lint/suspicious/noExplicitAny: + (value: any) => { + const newTheme = typeof value === "function" ? value(theme) : value; + setThemeState(newTheme); + + // Save to storage + try { + localStorage.setItem(storageKey, newTheme); + } catch { + // Unsupported + } + }, + [theme], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + const handleMediaQuery = React.useCallback( + (e: MediaQueryListEvent | MediaQueryList) => { + getSystemTheme(e); + + if (theme === "system" && enableSystem && !forcedTheme) { + applyTheme("system"); + } + }, + [theme, forcedTheme], + ); + + // Always listen to System preference + React.useEffect(() => { + const media = window.matchMedia(MEDIA); + + // Intentionally use deprecated listener methods to support iOS & old browsers + media.addListener(handleMediaQuery); + handleMediaQuery(media); + + return () => media.removeListener(handleMediaQuery); + }, [handleMediaQuery]); + + // localStorage event handling, allow to sync theme changes between tabs + // biome-ignore lint/correctness/useExhaustiveDependencies: + React.useEffect(() => { + const handleStorage = (e: StorageEvent) => { + if (e.key !== storageKey) { + return; + } + + // If default theme set, use it if localstorage === null (happens on local storage manual deletion) + const theme = e.newValue || defaultTheme; + setTheme(theme); + }; + + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, [setTheme]); + + // Whenever theme or forcedTheme changes, apply it + // biome-ignore lint/correctness/useExhaustiveDependencies: + React.useEffect(() => { + applyTheme(forcedTheme ?? theme); + }, [forcedTheme, theme]); + + const providerValue = React.useMemo( + () => ({ + forcedTheme, + setTheme, + theme, + themes: enableSystem ? [...themes, "system"] : themes, + }), + [theme, setTheme, forcedTheme, enableSystem, themes], + ); + + return ( + + + {children} + + ); +}; + +const ThemeScript = React.memo( + ({ + forcedTheme, + storageKey, + attribute, + enableSystem, + enableColorScheme, + defaultTheme, + value, + themes, + nonce, + }: Omit & { defaultTheme: string }) => { + const scriptArgs = JSON.stringify([ + attribute, + storageKey, + defaultTheme, + forcedTheme, + themes, + value, + enableSystem, + enableColorScheme, + ]).slice(1, -1); + + return ( + "; + const { html } = renderPaywallToHtml(makeRootNode([makeComponentNode("c-1", "hash-1")]), { + componentArtifacts: artifactsWithTree("hash-1", "default", { + type: "text", + style: {}, + text: malicious, + }), + }); + + const open = '", start)); + expect(payload).not.toContain("<"); + const parsed = JSON.parse(payload) as { componentArtifacts: ComponentArtifacts }; + expect(parsed.componentArtifacts.trees["hash-1"]?.default?.root).toMatchObject({ + text: malicious, + }); + }); +}); + +describe("hydration runtime bundle", () => { + // The snapshot carries no gradients, so these markers can only come from the + // embedded hydration runtime. SSR-only assertions once let a stale runtime + // paint outdated styles over correct server output — this greps the runtime + // itself so a bundle missing current style-builder logic fails here. + test("embeds the current style builders in the runtime script", () => { + const { html } = renderPaywallToHtml(makeRootNode([])); + + expect(html).toContain("backgroundGradient"); + expect(html).toContain("linearGradient"); + expect(html).toContain("radialGradient"); + expect(html).toContain("data:image/svg+xml"); + }); + + test("omits the runtime script when hydration is disabled", () => { + const { html } = renderPaywallToHtml(makeRootNode([]), { hydrate: false }); + + expect(html).not.toContain("backgroundGradient"); + }); + + // The global name can only come from the bundled `readInjectedConfig`, so a + // runtime missing the SDK locale source (contract §7.1) fails here. + test("embeds the SDK injected-config locale source in the runtime script", () => { + const { html } = renderPaywallToHtml(makeRootNode([])); + expect(html).toContain("__VOIDHASH_PAYWALL__"); + + const withoutRuntime = renderPaywallToHtml(makeRootNode([]), { hydrate: false }); + expect(withoutRuntime.html).not.toContain("__VOIDHASH_PAYWALL__"); + }); +}); + +// A root node carrying a localization config (defaultLocale) so the Text +// resolver can compute the base-vs-override fallback the same way the decoded +// document snapshot would. +function makeLocalizedRoot(children: SnapshotNode[], defaultLocale = "en"): SnapshotNode { + return { + type: "root", + id: "root", + parentId: null, + pos: "a0", + data: { name: "Paywall", localization: { defaultLocale, locales: [] } }, + children, + } as unknown as SnapshotNode; +} + +function makeLocalizedTextNode( + id: string, + text: string, + localized: { locale: string; overrides: { text?: string } }[], +): SnapshotNode { + return { + type: "text", + id, + parentId: null, + pos: "a0", + data: { name: "Text", text, style: {}, states: [], localVariables: [], linkedVariables: [], localized }, + children: [], + } as unknown as SnapshotNode; +} + +describe("locale-aware text rendering", () => { + const snapshot = makeLocalizedRoot([ + makeLocalizedTextNode("t-1", "Hello", [{ locale: "de", overrides: { text: "Hallo" } }]), + ]); + + test("renders the base text when no locale is given", () => { + expect(renderPaywall(snapshot).html).toContain("Hello"); + expect(renderPaywall(snapshot).html).not.toContain("Hallo"); + }); + + test("renders the localized override for the active locale", () => { + const { html } = renderPaywall(snapshot, { locale: "de" }); + expect(html).toContain("Hallo"); + expect(html).not.toContain(">Hello<"); + }); + + test("falls back to base for a locale with no override", () => { + expect(renderPaywall(snapshot, { locale: "fr" }).html).toContain("Hello"); + }); + + test("embeds the locale in the hydration payload and re-renders it", () => { + const { html } = renderPaywallToHtml(snapshot, { locale: "de" }); + expect(html).toContain('"locale":"de"'); + expect(html).toContain('"snapshot":{"type":"root"'); + // The SSR body already shows the resolved translation. + expect(html).toContain("Hallo"); + }); +}); + +const BASE_IMAGE_URL = "https://example.com/base.png"; +const DE_IMAGE_URL = "https://example.com/de.png"; + +// Encode/decode round-trips through the real node data structs, so `localized` +// entries carry the exact CRDT envelope (`{ id, pos, value }`) a decoded +// document snapshot hands the renderer. +function makeLocalizedImageViewNode(id: string): SnapshotNode { + const data = ViewNode.data.decode( + ViewNode.data.encode({ + style: { + backgroundEnabled: true, + backgroundType: "image", + backgroundImage: { url: BASE_IMAGE_URL, resizeMode: "cover" }, + }, + localized: [ + { + locale: "de", + overrides: { backgroundImage: { url: DE_IMAGE_URL, resizeMode: "contain" } }, + }, + ], + }), + ); + return { type: "view", id, parentId: null, pos: "a0", data, children: [] } as unknown as SnapshotNode; +} + +function makeLocalizedImageScreenNode(id: string): SnapshotNode { + const data = ScreenNode.data.decode( + ScreenNode.data.encode({ + style: { + backgroundEnabled: true, + backgroundType: "image", + backgroundImage: { url: BASE_IMAGE_URL, resizeMode: "cover" }, + }, + localized: [ + { + locale: "de", + overrides: { backgroundImage: { url: DE_IMAGE_URL, resizeMode: "cover" } }, + }, + ], + }), + ); + return { type: "screen", id, parentId: null, pos: "a0", data, children: [] } as unknown as SnapshotNode; +} + +function makeLocalizedPropComponentNode(id: string, contentHash: string): SnapshotNode { + return { + type: "component", + id, + parentId: null, + pos: "a0", + data: { + name: "Component", + componentSlug: "hero-card", + componentVersion: 1, + contentHash, + previewState: "default", + props: [ + { + name: "title", + value: { type: "literal", value: { key: "string", value: "Base title" } }, + localizedValues: [{ locale: "de", value: { key: "string", value: "Deutscher Titel" } }], + }, + ], + actionBindings: [], + }, + children: [], + } as unknown as SnapshotNode; +} + +describe("locale-aware background images (real mimic snapshot)", () => { + const snapshot = makeLocalizedRoot([makeLocalizedImageViewNode("view-1")]); + + test("renders the base image when no locale is given", () => { + const { html } = renderPaywall(snapshot); + expect(html).toContain(BASE_IMAGE_URL); + expect(html).not.toContain(DE_IMAGE_URL); + }); + + test("substitutes the whole localized image (url + resizeMode) for the active locale", () => { + const { html } = renderPaywall(snapshot, { locale: "de" }); + expect(html).toContain(DE_IMAGE_URL); + expect(html).toContain("background-size:contain"); + expect(html).not.toContain(BASE_IMAGE_URL); + }); + + test("falls back to the base image for a locale with no override", () => { + const { html } = renderPaywall(snapshot, { locale: "fr" }); + expect(html).toContain(BASE_IMAGE_URL); + expect(html).not.toContain(DE_IMAGE_URL); + }); + + test("resolves a language-prefix match (de-AT → de) end-to-end", () => { + const { html } = renderPaywall(snapshot, { locale: "de-AT" }); + expect(html).toContain(DE_IMAGE_URL); + expect(html).not.toContain(BASE_IMAGE_URL); + }); + + test("substitutes the localized image on the screen container", () => { + const screenSnapshot = makeLocalizedRoot([makeLocalizedImageScreenNode("screen-1")]); + const { html } = renderPaywall(screenSnapshot, { locale: "de" }); + expect(html).toContain('data-node-id="screen-1"'); + expect(html).toContain(DE_IMAGE_URL); + expect(html).not.toContain(BASE_IMAGE_URL); + }); +}); + +describe("published-artifact locale resolution (renderPaywallToHtml)", () => { + const snapshot = makeLocalizedRoot([ + makeLocalizedTextNode("t-1", "Hello", [{ locale: "de", overrides: { text: "Hallo" } }]), + makeLocalizedImageViewNode("view-1"), + makeLocalizedPropComponentNode("component-1", "hash-1"), + ]); + + test("a forced locale resolves text + image in the SSR body and rides the payload", () => { + const { html } = renderPaywallToHtml(snapshot, { locale: "de" }); + + expect(html).toContain("Hallo"); + expect(html).toContain(DE_IMAGE_URL); + expect(html).toContain('"locale":"de"'); + // Preview trees are fixture-baked per contentHash+state, so localized PROP + // values surface through the component runtime, not the static tree — the + // payload must carry the `localizedValues` entries for it to resolve. + expect(html).toContain('"localizedValues"'); + expect(html).toContain("Deutscher Titel"); + }); + + test("the body-only render without a locale stays base content", () => { + const { html } = renderPaywall(snapshot); + expect(html).toContain("Hello"); + expect(html).toContain(BASE_IMAGE_URL); + expect(html).not.toContain("Hallo"); + expect(html).not.toContain(DE_IMAGE_URL); + }); +}); diff --git a/packages/paywall-renderer-preact/src/render.tsx b/packages/paywall-renderer-preact/src/render.tsx new file mode 100644 index 000000000..857cdbd21 --- /dev/null +++ b/packages/paywall-renderer-preact/src/render.tsx @@ -0,0 +1,136 @@ +import type { RenderResult, SnapshotNode } from "@voidhash/paywall-renderer-web-core"; +import render from "preact-render-to-string"; + +import type { ComponentArtifacts } from "./component-artifacts"; +import { Paywall } from "./components/paywall"; +import { generateDocument, type PaywallMetadata } from "./templates/document-template"; +import { generateRuntimeScript } from "./templates/runtime-script"; + +/** + * Options for body-only paywall rendering. + */ +export interface RenderPaywallOptions { + /** Preview trees for the snapshot's component nodes (missing → placeholder). */ + componentArtifacts?: ComponentArtifacts; + /** + * Locale to resolve localized content against. Omitted (or the document's + * default locale) renders the base props — byte-for-byte the unlocalized + * output. + */ + locale?: string; +} + +export function renderPaywall( + snapshot: SnapshotNode, + options: RenderPaywallOptions = {}, +): RenderResult { + const body = render( + , + ); + const html = wrapInDocument(body); + return { html }; +} + +function wrapInDocument(body: string): string { + return ` + + + + + + +${body} +`; +} + +/** + * Options for HTML rendering with hydration support. + */ +export interface HtmlRenderOptions { + /** Include hydration script (default: true) */ + hydrate?: boolean; + /** Include metadata comments (default: false) */ + debug?: boolean; + /** Metadata to embed in the document */ + metadata?: PaywallMetadata; + /** Preview trees for the snapshot's component nodes (missing → placeholder) */ + componentArtifacts?: ComponentArtifacts; + /** + * Locale to resolve localized content against, threaded into the SSR render + * AND embedded in the hydration payload so the client re-render matches. + * Omitted (or the default locale) renders the base props unchanged. + */ + locale?: string; +} + +/** + * Result of HTML rendering. + */ +export interface HtmlRenderResult { + /** The complete HTML document */ + html: string; + /** Size in bytes */ + size: number; +} + +/** + * Renders a paywall snapshot to a self-contained HTML document. + * + * This generates a complete HTML file that: + * - Contains pre-rendered HTML from SSR + * - Includes the htm/preact runtime for client-side hydration + * - Embeds the snapshot data (and component preview trees, when provided) + * for re-rendering + * - Has no external dependencies + * + * `<` is escaped as `\u003c` in the embedded payload JSON so payload strings + * (e.g. preview-tree text) cannot close the `` out of the embedded block. + const useWrapper = componentArtifacts !== undefined || locale !== undefined; + const payloadJson = JSON.stringify( + useWrapper ? { componentArtifacts, locale, snapshot } : snapshot, + ).replace(/, + root, + ); +} + +// Run when DOM is ready +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mountPaywall); +} else { + mountPaywall(); +} diff --git a/packages/paywall-renderer-preact/src/runtime/runtime-locale.test.ts b/packages/paywall-renderer-preact/src/runtime/runtime-locale.test.ts new file mode 100644 index 000000000..e775699b4 --- /dev/null +++ b/packages/paywall-renderer-preact/src/runtime/runtime-locale.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, test } from "vite-plus/test"; + +import { resolveRuntimeLocale } from "./runtime-locale"; + +// Tests run in a node environment (no `window`), so the SDK-injected config is +// simulated by planting a minimal `window` global with the contract §7.1 shape. +const testGlobal = globalThis as unknown as { window?: unknown }; + +afterEach(() => { + delete testGlobal.window; +}); + +describe("resolveRuntimeLocale", () => { + test("prefers the SDK-injected runtime config locale over the payload locale", () => { + testGlobal.window = { + __VOIDHASH_PAYWALL__: { locale: "de", products: [], variables: {} }, + }; + expect(resolveRuntimeLocale("en")).toBe("de"); + }); + + test("falls back to the payload locale when the injected config carries none", () => { + testGlobal.window = { + __VOIDHASH_PAYWALL__: { products: [], variables: {} }, + }; + expect(resolveRuntimeLocale("cs")).toBe("cs"); + }); + + test("falls back to the payload locale when no config is injected", () => { + expect(resolveRuntimeLocale("cs")).toBe("cs"); + }); + + test("returns undefined (base content) when neither source carries a locale", () => { + expect(resolveRuntimeLocale(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/paywall-renderer-preact/src/runtime/runtime-locale.ts b/packages/paywall-renderer-preact/src/runtime/runtime-locale.ts new file mode 100644 index 000000000..5ba75b98a --- /dev/null +++ b/packages/paywall-renderer-preact/src/runtime/runtime-locale.ts @@ -0,0 +1,14 @@ +import { readInjectedConfig } from "@voidhash/paywalls"; + +/** + * The locale a hydrating published artifact renders with, in precedence order: + * the SDK host's injected runtime config (`window.__VOIDHASH_PAYWALL__.locale`, + * read via `readInjectedConfig`) → the locale embedded in the + * hydration payload → `undefined` (base/default-locale content). Reading the + * injected config at mount keeps a published artifact locale-switchable by the + * SDK with zero server work; the SSR body stays default-locale and hydration + * re-renders with the resolved locale. + */ +export function resolveRuntimeLocale(payloadLocale: string | undefined): string | undefined { + return readInjectedConfig().locale ?? payloadLocale; +} diff --git a/packages/paywall-renderer-preact/src/templates/document-template.ts b/packages/paywall-renderer-preact/src/templates/document-template.ts new file mode 100644 index 000000000..47eba7910 --- /dev/null +++ b/packages/paywall-renderer-preact/src/templates/document-template.ts @@ -0,0 +1,62 @@ +/** + * HTML document template for self-contained paywall output. + */ + +export interface DocumentTemplateOptions { + /** The pre-rendered HTML body content */ + body: string; + /** + * The JSON-serialized hydration payload: a bare snapshot, or + * `{ snapshot, componentArtifacts }` when component preview trees are + * embedded. + */ + payloadJson: string; + /** The runtime script for hydration (if enabled) */ + runtimeScript?: string; + /** Metadata to embed as HTML comment */ + metadata?: PaywallMetadata; +} + +export interface PaywallMetadata { + createdAt: string; + schemaVersion: number; + version: number; + status: string; +} + +/** + * Generates a complete HTML document with the paywall content. + */ +export function generateDocument(options: DocumentTemplateOptions): string { + const { body, payloadJson, runtimeScript, metadata } = options; + + const metadataComment = metadata + ? `\n` + : ""; + + const scriptSection = runtimeScript + ? ` + + ` + : ""; + + return ` +${metadataComment} + + + + + + +
${body}
${scriptSection} + +`; +} diff --git a/packages/paywall-renderer-preact/src/templates/runtime-bundle.generated.ts b/packages/paywall-renderer-preact/src/templates/runtime-bundle.generated.ts new file mode 100644 index 000000000..24fb8af44 --- /dev/null +++ b/packages/paywall-renderer-preact/src/templates/runtime-bundle.generated.ts @@ -0,0 +1,2 @@ +/** Generated by scripts/generate-runtime-bundle.mjs. */ +export const PAYWALL_RUNTIME_BUNDLE = "(()=>{var $n=Object.defineProperty;var wa=(e,t,r)=>t in e?$n(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r;var Pa=(e,t)=>{for(var r in t)$n(e,r,{get:t[r],enumerable:!0})};var p=(e,t,r)=>wa(e,typeof t!=\"symbol\"?t+\"\":t,r);var $t,_,Yn,Va,Ne,Wn,Xn,Jn,Ur,jt,Ct,Zn,Kr,Fr,zr,Qn,Ut={},Ft=[],Na=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,Wt=Array.isArray;function be(e,t){for(var r in t)e[r]=t[r];return e}function Gr(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function _a(e,t,r){var n,o,a,d={};for(a in t)a==\"key\"?n=t[a]:a==\"ref\"?o=t[a]:d[a]=t[a];if(arguments.length>2&&(d.children=arguments.length>3?$t.call(arguments,2):r),typeof e==\"function\"&&e.defaultProps!=null)for(a in e.defaultProps)d[a]===void 0&&(d[a]=e.defaultProps[a]);return Bt(e,d,n,o,null)}function Bt(e,t,r,n,o){var a={type:e,props:t,key:r,ref:n,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:o??++Yn,__i:-1,__u:0};return o==null&&_.vnode!=null&&_.vnode(a),a}function Q(e){return e.children}function Ht(e,t){this.props=e,this.context=t}function tt(e,t){if(t==null)return e.__?tt(e.__,e.__i+1):null;for(var r;tt&&Ne.sort(Jn),e=Ne.shift(),t=Ne.length,ka(e)}finally{Ne.length=zt.__r=0}}function to(e,t,r,n,o,a,d,l,s,u,f){var c,m,y,N,R,w,P,V=n&&n.__k||Ft,L=t.length;for(s=Ia(r,t,V,s,L),c=0;c0?d=e.__k[a]=Bt(d.type,d.props,d.key,d.ref?d.ref:null,d.__v):e.__k[a]=d,s=a+m,d.__=e,d.__b=e.__b+1,l=null,(u=d.__i=Aa(d,r,s,c))!=-1&&(c--,(l=r[u])&&(l.__u|=2)),l==null||l.__v==null?(u==-1&&(o>f?m--:os?m--:m++,d.__u|=4))):e.__k[a]=null;if(c)for(a=0;a(f?1:0)){for(o=r-1,a=r+1;o>=0||a=0?o--:a++])!=null&&(2&u.__u)==0&&l==u.key&&s==u.type)return d}return-1}function Kn(e,t,r){t[0]==\"-\"?e.setProperty(t,r??\"\"):e[t]=r==null?\"\":typeof r!=\"number\"||Na.test(t)?r:r+\"px\"}function Mt(e,t,r,n,o){var a,d;e:if(t==\"style\")if(typeof r==\"string\")e.style.cssText=r;else{if(typeof n==\"string\"&&(e.style.cssText=n=\"\"),n)for(t in n)r&&t in r||Kn(e.style,t,\"\");if(r)for(t in r)n&&r[t]==n[t]||Kn(e.style,t,r[t])}else if(t[0]==\"o\"&&t[1]==\"n\")a=t!=(t=t.replace(Zn,\"$1\")),d=t.toLowerCase(),t=d in e||t==\"onFocusOut\"||t==\"onFocusIn\"?d.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+a]=r,r?n?r[Ct]=n[Ct]:(r[Ct]=Kr,e.addEventListener(t,a?zr:Fr,a)):e.removeEventListener(t,a?zr:Fr,a);else{if(o==\"http://www.w3.org/2000/svg\")t=t.replace(/xlink(H|:h)/,\"h\").replace(/sName$/,\"s\");else if(t!=\"width\"&&t!=\"height\"&&t!=\"href\"&&t!=\"list\"&&t!=\"form\"&&t!=\"tabIndex\"&&t!=\"download\"&&t!=\"rowSpan\"&&t!=\"colSpan\"&&t!=\"role\"&&t!=\"popover\"&&t in e)try{e[t]=r??\"\";break e}catch{}typeof r==\"function\"||(r==null||r===!1&&t[4]!=\"-\"?e.removeAttribute(t):e.setAttribute(t,t==\"popover\"&&r==1?\"\":r))}}function Gn(e){return function(t){if(this.l){var r=this.l[t.type+e];if(t[jt]==null)t[jt]=Kr++;else if(t[jt]0?e:Wt(e)?e.map(oo):e.constructor!==void 0?null:be({},e)}function Ra(e,t,r,n,o,a,d,l,s){var u,f,c,m,y,N,R,w=r.props||Ut,P=t.props,V=t.type;if(V==\"svg\"?o=\"http://www.w3.org/2000/svg\":V==\"math\"?o=\"http://www.w3.org/1998/Math/MathML\":o||(o=\"http://www.w3.org/1999/xhtml\"),a!=null){for(u=0;u``).join(\"\");return`${e.kind===\"radial\"?(()=>{let o=Math.hypot(e.endX-e.startX,e.endY-e.startY);return`${r}`})():`${r}`}`}function rt(e){if(!e.backgroundEnabled)return{backgroundColor:\"transparent\"};let t=e.backgroundType??\"solid\";if(t===\"gradient\"){let r=e.backgroundGradient,n=(r?.stops??[]).map(so).filter(a=>a!==void 0).sort((a,d)=>a.position-d.position);if(!r||n.length===0)return{backgroundColor:\"transparent\"};if(n.length===1)return{backgroundColor:n[0].color};let o=co(r,n);return{backgroundImage:`url(\"data:image/svg+xml,${encodeURIComponent(o)}\")`,backgroundRepeat:\"no-repeat\",backgroundSize:\"100% 100%\"}}if(t===\"image\"){let r=e.backgroundImage?.url??\"\";if(r===\"\")return{backgroundColor:\"transparent\"};let n=e.backgroundImage?.resizeMode??\"cover\";return{backgroundImage:`url(\"${encodeURI(r).replace(/\"/g,\"%22\")}\")`,backgroundPosition:\"center\",backgroundRepeat:\"no-repeat\",backgroundSize:uo[n]}}return{backgroundColor:e.backgroundColor}}function Kt(e){let t=e.backgroundType??\"solid\";if(t===\"gradient\"){let r=e.backgroundGradient,n=(r?.stops??[]).map(so).filter(a=>a!==void 0).sort((a,d)=>a.position-d.position);if(!r||n.length===0)return{backgroundColor:\"transparent\"};if(n.length===1)return{backgroundColor:n[0].color};let o=co(r,n);return{backgroundImage:`url(\"data:image/svg+xml,${encodeURIComponent(o)}\")`,backgroundRepeat:\"no-repeat\",backgroundSize:\"100% 100%\"}}if(t===\"image\"){let r=e.backgroundImage?.url??\"\";if(r===\"\")return{backgroundColor:\"transparent\"};let n=e.backgroundImage?.resizeMode??\"cover\";return{backgroundImage:`url(\"${encodeURI(r).replace(/\"/g,\"%22\")}\")`,backgroundPosition:\"center\",backgroundRepeat:\"no-repeat\",backgroundSize:uo[n]}}return e.backgroundColor===void 0?{}:{backgroundColor:e.backgroundColor}}function g(e){return e===0?\"0\":`${e}px`}function $e(e){return e===\"auto\"||e===void 0?\"auto\":g(e)}function nt(e){let t={alignItems:e.alignItems,boxSizing:\"border-box\",display:e.display,flexDirection:e.flexDirection,gap:g(e.gap??0),height:$e(e.height),justifyContent:e.justifyContent,marginBottom:g(e.marginBottom??0),marginLeft:g(e.marginLeft??0),marginRight:g(e.marginRight??0),marginTop:g(e.marginTop??0),opacity:e.opacity,overflow:e.overflow,paddingBottom:g(e.paddingBottom??0),paddingLeft:g(e.paddingLeft??0),paddingRight:g(e.paddingRight??0),paddingTop:g(e.paddingTop??0),position:e.position??\"relative\",width:$e(e.width)};return e.minWidth!==void 0&&(t.minWidth=g(e.minWidth)),e.maxWidth!==void 0&&(t.maxWidth=g(e.maxWidth)),e.minHeight!==void 0&&(t.minHeight=g(e.minHeight)),e.maxHeight!==void 0&&(t.maxHeight=g(e.maxHeight)),typeof e.left==\"number\"&&(t.left=g(e.left)),typeof e.top==\"number\"&&(t.top=g(e.top)),typeof e.right==\"number\"&&(t.right=g(e.right)),typeof e.bottom==\"number\"&&(t.bottom=g(e.bottom)),Object.assign(t,rt(e)),e.borderEnabled&&(t.borderTopWidth=g(e.borderTopWidth??0),t.borderRightWidth=g(e.borderRightWidth??0),t.borderBottomWidth=g(e.borderBottomWidth??0),t.borderLeftWidth=g(e.borderLeftWidth??0),t.borderColor=e.borderColor,t.borderStyle=e.borderStyle),t.borderTopLeftRadius=g(e.borderTopLeftRadius??0),t.borderTopRightRadius=g(e.borderTopRightRadius??0),t.borderBottomRightRadius=g(e.borderBottomRightRadius??0),t.borderBottomLeftRadius=g(e.borderBottomLeftRadius??0),e.flex!==void 0&&(t.flex=e.flex),e.alignSelf!==\"auto\"&&(t.alignSelf=e.alignSelf),t}function Gt(e,{horizontal:t,showsScrollIndicator:r}){let n=nt(e);return n.minHeight??(n.minHeight=0),n.minWidth??(n.minWidth=0),n.WebkitOverflowScrolling=\"touch\",t?(n.flexDirection=\"row\",n.overflowX=\"auto\",n.overflowY=\"hidden\"):(n.overflowX=\"hidden\",n.overflowY=\"auto\"),r||(n.scrollbarWidth=\"none\"),n}function Yt(e){return{fill:e.fillEnabled?e.fillColor:\"none\",fillRule:e.fillRule,fillOpacity:e.fillOpacity,stroke:e.strokeEnabled?e.strokeColor:\"none\",strokeWidth:e.strokeWidth,strokeOpacity:e.strokeOpacity,strokeLinecap:e.strokeLinecap,strokeLinejoin:e.strokeLinejoin,opacity:e.opacity}}function Xt(e){let t={boxSizing:\"border-box\",height:\"100vh\",overflow:\"hidden\",width:\"100vw\"};return Object.assign(t,rt(e)),t}function Jt(e){return{alignItems:e.alignItems,display:e.display,flexDirection:e.flexDirection,gap:g(e.gap??0),height:\"100vh\",justifyContent:e.justifyContent,paddingBottom:g(e.paddingBottom??0),paddingLeft:g(e.paddingLeft??0),paddingRight:g(e.paddingRight??0),paddingTop:g(e.paddingTop??0),width:\"100vw\"}}function Zt(e){let t={boxSizing:\"border-box\",display:e.display===\"none\"?\"none\":\"block\",height:$e(e.height),marginBottom:g(e.marginBottom??0),marginLeft:g(e.marginLeft??0),marginRight:g(e.marginRight??0),marginTop:g(e.marginTop??0),opacity:e.opacity,position:\"relative\",width:$e(e.width)};return e.minWidth!==void 0&&(t.minWidth=g(e.minWidth)),e.maxWidth!==void 0&&(t.maxWidth=g(e.maxWidth)),e.minHeight!==void 0&&(t.minHeight=g(e.minHeight)),e.maxHeight!==void 0&&(t.maxHeight=g(e.maxHeight)),e.flex!==void 0&&(t.flex=e.flex),e.alignSelf!==\"auto\"&&(t.alignSelf=e.alignSelf),t}function Qt(e){let t={color:e.color,display:e.display,fontSize:g(e.fontSize),fontWeight:e.fontWeight,letterSpacing:g(e.letterSpacing),lineHeight:e.lineHeight,marginBottom:g(e.marginBottom??0),marginLeft:g(e.marginLeft??0),marginRight:g(e.marginRight??0),marginTop:g(e.marginTop??0),opacity:e.opacity,overflow:e.overflow,position:e.position??\"relative\",textAlign:e.textAlign};return e.minWidth!==void 0&&(t.minWidth=g(e.minWidth)),e.maxWidth!==void 0&&(t.maxWidth=g(e.maxWidth)),e.minHeight!==void 0&&(t.minHeight=g(e.minHeight)),e.maxHeight!==void 0&&(t.maxHeight=g(e.maxHeight)),typeof e.left==\"number\"&&(t.left=g(e.left)),typeof e.top==\"number\"&&(t.top=g(e.top)),typeof e.right==\"number\"&&(t.right=g(e.right)),typeof e.bottom==\"number\"&&(t.bottom=g(e.bottom)),e.borderEnabled&&(t.borderTopWidth=g(e.borderTopWidth??0),t.borderRightWidth=g(e.borderRightWidth??0),t.borderBottomWidth=g(e.borderBottomWidth??0),t.borderLeftWidth=g(e.borderLeftWidth??0),t.borderColor=e.borderColor,t.borderStyle=e.borderStyle),e.flex!==void 0&&(t.flex=e.flex),e.alignSelf!==\"auto\"&&(t.alignSelf=e.alignSelf),t}function Jr(e){let t={stores:new Map,parents:new Map};return fo(e,null,t),t}function fo(e,t,r){r.parents.set(e.id,t),po(e,r.stores);for(let n of e.children)fo(n,e.id,r)}function er(e,t,r,n){let o=new Set,a=r;for(;a!==null&&!o.has(a);){if(o.add(a),t(a)?.has(n))return a;a=e.get(a)??null}}function Zr(e,t,r){return{get:n=>{let o=er(e,t,r,n);if(o!==void 0)return t(o)?.get(n)}}}function po(e,t){let r=e.data;if(r!==void 0&&\"localVariables\"in r&&r.localVariables.length>0){let n=new Map,o=new Map;for(let a of r.localVariables){let d=a.value;d?.id===void 0||d.value===void 0||(n.set(d.id,d.value),n.set(a.id,d.value),o.set(a.id,d.id),o.set(d.id,a.id))}n.size>0&&t.set(e.id,{store:n,aliases:o})}for(let n of e.children)po(n,t)}function ho(e){if(e)return typeof e==\"object\"&&e!==null&&\"id\"in e&&\"value\"in e&&!(\"type\"in e)?e.value:e}function tr(e,t){return!e?.value||!Array.isArray(e.value)?!1:e.value.some(r=>{let n=ho(r);return n?qa(n,t):!1})}function qa(e,t){return!e?.value||!Array.isArray(e.value)?!1:e.value.every(r=>{let n=ho(r);return n?Ea(n,t):!1})}function mo(e,t){if(e.type===\"literal\")return e.value;if(!(!e.value||!(\"id\"in e.value)||!e.value.id))return t.get(e.value.id)}function yo(e){switch(e.key){case\"boolean\":return e.value??!1;case\"number\":return e.value??0;case\"string\":return e.value??\"\";case\"product\":return e.value?.productId??\"\";default:return\"\"}}function Ea(e,t){if(!e?.value?.left||!e?.value?.right)return!1;let r=mo(e.value.left,t),n=mo(e.value.right,t);if(r===void 0||n===void 0)return!1;let o=yo(r),a=yo(n);switch(e.type){case\"equals\":return o===a;case\"not-equals\":return o!==a;case\"greater-than\":return o>a;case\"greater-than-or-equal\":return o>=a;case\"less-than\":return o{delete n[d],n[d]=go(d,l)};for(let d of bo){let l=e[d];l!==void 0&&(n[d]=go(d,l))}for(let[d,l]of Object.entries(e))l===void 0||Wa.has(d)||typeof l!=\"string\"&&typeof l!=\"number\"||o(d,l);return(e.borderTopWidth!==void 0||e.borderRightWidth!==void 0||e.borderBottomWidth!==void 0||e.borderLeftWidth!==void 0||e.borderColor!==void 0)&&e.borderStyle===void 0&&(n.borderStyle=\"solid\"),(e.backgroundType===\"gradient\"||e.backgroundType===\"image\")&&(delete n.backgroundColor,Object.assign(n,Kt({backgroundColor:typeof e.backgroundColor==\"string\"?e.backgroundColor:void 0,backgroundType:e.backgroundType,backgroundGradient:e.backgroundGradient,backgroundImage:e.backgroundImage}))),Object.assign(n,on(r)),n}var qt,A,an,vo,ir=0,_o=[],C=_,So=C.__b,To=C.__r,xo=C.diffed,wo=C.__c,Po=C.unmount,Vo=C.__;function ln(e,t){C.__h&&C.__h(A,e,ir||t),ir=0;var r=A.__H||(A.__H={__:[],__h:[]});return e>=r.__.length&&r.__.push({}),r.__[e]}function ko(e){return ir=1,Ya(Ao,e)}function Ya(e,t,r){var n=ln(qt++,2);if(n.t=e,!n.__c&&(n.__=[r?r(t):Ao(void 0,t),function(l){var s=n.__N?n.__N[0]:n.__[0],u=n.t(s,l);s!==u&&(n.__N=[u,n.__[1]],n.__c.setState({}))}],n.__c=A,!A.__f)){var o=function(l,s,u){if(!n.__c.__H)return!0;var f=n.__c.__H.__.filter(function(m){return m.__c});if(f.every(function(m){return!m.__N}))return!a||a.call(this,l,s,u);var c=n.__c.props!==l;return f.some(function(m){if(m.__N){var y=m.__[0];m.__=m.__N,m.__N=void 0,y!==m.__[0]&&(c=!0)}}),a&&a.call(this,l,s,u)||c};A.__f=!0;var a=A.shouldComponentUpdate,d=A.componentWillUpdate;A.componentWillUpdate=function(l,s,u){if(this.__e){var f=a;a=void 0,o(l,s,u),a=f}d&&d.call(this,l,s,u)},A.shouldComponentUpdate=o}return n.__N||n.__}function j(e,t){var r=ln(qt++,7);return Za(r.__H,t)&&(r.__=e(),r.__H=t,r.__h=e),r.__}function ee(e,t){return ir=8,j(function(){return e},t)}function Io(e){var t=A.context[e.__c],r=ln(qt++,9);return r.c=e,t?(r.__==null&&(r.__=!0,t.sub(A)),t.props.value):e.__}function Xa(){for(var e;e=_o.shift();){var t=e.__H;if(e.__P&&t)try{t.__h.some(or),t.__h.some(dn),t.__h=[]}catch(r){t.__h=[],C.__e(r,e.__v)}}}C.__b=function(e){A=null,So&&So(e)},C.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),Vo&&Vo(e,t)},C.__r=function(e){To&&To(e),qt=0;var t=(A=e.__c).__H;t&&(an===A?(t.__h=[],A.__h=[],t.__.some(function(r){r.__N&&(r.__=r.__N),r.u=r.__N=void 0})):(t.__h.some(or),t.__h.some(dn),t.__h=[],qt=0)),an=A},C.diffed=function(e){xo&&xo(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(_o.push(t)!==1&&vo===C.requestAnimationFrame||((vo=C.requestAnimationFrame)||Ja)(Xa)),t.__H.__.some(function(r){r.u&&(r.__H=r.u),r.u=void 0})),an=A=null},C.__c=function(e,t){t.some(function(r){try{r.__h.some(or),r.__h=r.__h.filter(function(n){return!n.__||dn(n)})}catch(n){t.some(function(o){o.__h&&(o.__h=[])}),t=[],C.__e(n,r.__v)}}),wo&&wo(e,t)},C.unmount=function(e){Po&&Po(e);var t,r=e.__c;r&&r.__H&&(r.__H.__.some(function(n){try{or(n)}catch(o){t=o}}),r.__H=void 0,t&&C.__e(t,r.__v))};var No=typeof requestAnimationFrame==\"function\";function Ja(e){var t,r=function(){clearTimeout(n),No&&cancelAnimationFrame(t),setTimeout(e)},n=setTimeout(r,35);No&&(t=requestAnimationFrame(r))}function or(e){var t=A,r=e.__c;typeof r==\"function\"&&(e.__c=void 0,r()),A=t}function dn(e){var t=A;e.__c=e.__(),A=t}function Za(e,t){return!e||e.length!==t.length||t.some(function(r,n){return r!==e[n]})}function Ao(e,t){return typeof t==\"function\"?t(e):t}var Qa=0;function b(e,t,r,n,o,a){t||(t={});var d,l,s=t;if(\"ref\"in s)for(l in s={},t)l==\"ref\"?d=t[l]:s[l]=t[l];var u={type:e,props:s,key:r,ref:d,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:--Qa,__i:-1,__u:0,__source:o,__self:a};if(typeof e==\"function\"&&(d=e.defaultProps))for(l in d)s[l]===void 0&&(s[l]=d[l]);return _.vnode&&_.vnode(u),u}var ed=new Map,Co=lo(null);function Ro(e){let t=JSON.stringify(e);window.ReactNativeWebView?window.ReactNativeWebView.postMessage(t):window.parent.postMessage(e,\"*\")}function qo({snapshot:e,componentArtifacts:t,locale:r,children:n}){let o=j(()=>Jr(e),[e]),a=(e.type===\"root\"?e.data.localization?.defaultLocale:void 0)??\"en\",[d,l]=ko(()=>{let w=new Map;for(let[P,{store:V}]of o.stores)w.set(P,V);return w}),s=j(()=>new Map,[o,d]),u=ee(w=>{let P=s.get(w);if(P)return P;let V=Zr(o.parents,L=>d.get(L),w);return s.set(w,V),V},[s,d,o]),f=ee((w,P,V)=>{l(L=>{let z=er(o.parents,Ve=>L.get(Ve),w,P);if(z===void 0)return L;let Pe=L.get(z);if(!Pe)return L;let Qe=new Map(Pe);Qe.set(P,V);let et=o.stores.get(z)?.aliases?.get(P);et&&Qe.set(et,V);let M=new Map(L);return M.set(z,Qe),M})},[o]),c=ee(()=>{Ro({type:\"paywall:close\"})},[]),m=ee(w=>{Ro({type:\"paywall:purchase\",productId:w})},[]),y=ee((w,P)=>{console.warn(`onSetVariable called directly for \"${w}\" without node context. Use the scoped callback from useInteractions instead.`)},[]),N=j(()=>({onClosePaywall:c,onPurchaseProduct:m,onSetVariable:y}),[c,m,y]),R=j(()=>({getNodeVariables:u,setNodeVariable:f,callbacks:N,componentArtifacts:t,locale:r,defaultLocale:a}),[u,f,N,t,r,a]);return b(Co.Provider,{value:R,children:n})}function te(){let e=Io(Co);return e||{getNodeVariables:()=>ed,setNodeVariable:()=>{},callbacks:{onClosePaywall:()=>{},onPurchaseProduct:()=>{},onSetVariable:()=>{}},componentArtifacts:void 0,locale:void 0,defaultLocale:\"en\"}}var td={alignSelf:\"flex-start\",backgroundColor:\"#18181b\",borderRadius:\"999px\",boxSizing:\"border-box\",color:\"#fafafa\",display:\"inline-flex\",fontFamily:\"inherit\",fontSize:\"11px\",fontWeight:600,lineHeight:\"16px\",padding:\"2px 8px\"};function Eo({props:e}){let t=typeof e.label==\"string\"?e.label:\"New\";return b(\"span\",{style:td,children:t})}var Do={\"sample-badge\":Eo};function Lo(e,t){let r={};for(let n of e){let o=n.value;if(!o?.name)continue;let a=o.value;if(a?.type){if(a.type===\"literal\"){a.value&&(r[o.name]=a.value.value);continue}if(a.type===\"variable-reference\"){if(!a.value||!(\"id\"in a.value)||!a.value.id)continue;let d=t.get(a.value.id);d!==void 0&&(r[o.name]=d.value)}}}return r}var sn={alignItems:\"center\",backgroundColor:\"#f4f4f5\",border:\"1px dashed #d4d4d8\",borderRadius:\"6px\",boxSizing:\"border-box\",color:\"#71717a\",display:\"flex\",fontFamily:\"inherit\",fontSize:\"11px\",justifyContent:\"center\",minHeight:\"40px\",padding:\"8px\",position:\"relative\",textAlign:\"center\"};function ar(e,t,r){return{...nr(e,t,r)}}function rd(e,t){if(!e)return;for(let n of[t,\"default\"]){let o=e[n];if(o)return o}let r=Object.keys(e)[0];return r===void 0?void 0:e[r]}function un({node:e,slot:t,fireAction:r}){switch(e.type){case\"view\":case\"scroll\":return b(\"div\",{style:ar(e.style,e.type,e.motion),children:e.children.map((n,o)=>b(un,{fireAction:r,node:n,slot:t},o))});case\"pressable\":{let n=e.action;return b(\"div\",{onClick:n===void 0?void 0:()=>r(n),style:ar(e.style,\"pressable\",e.motion),children:e.children.map((a,d)=>b(un,{fireAction:r,node:a,slot:t},d))})}case\"text\":return b(\"div\",{style:ar(e.style,\"text\",e.motion),children:e.text});case\"image\":return b(\"img\",{alt:\"\",src:e.src,style:{...ar(e.style,\"image\",e.motion),objectFit:rr(e.resizeMode)??\"cover\"}});case\"slot\":return b(Q,{children:t});case\"placeholder\":return b(\"div\",{style:sn,children:e.reason});default:return null}}function Oo({node:e,children:t}){let{getNodeVariables:r,setNodeVariable:n,callbacks:o,componentArtifacts:a}=te(),d=r(e.id),l=j(()=>({...o,onSetVariable:(m,y)=>{n(e.id,m,y)}}),[o,e.id,n]),s=ee((m,y)=>{let R=e.data.actionBindings.find(w=>w.value?.name===m)?.value?.action;R&&rn(R,y,d,l)},[e.data.actionBindings,d,l]);if(e.data.componentSource===\"builtin\"){let m=Do[e.data.componentSlug];if(!m)return b(\"div\",{\"data-node-id\":e.id,style:sn,children:`Component \"${e.data.componentSlug}\" preview unavailable`});let y=Lo(e.data.props,d);return b(\"div\",{\"data-node-id\":e.id,style:{display:\"contents\"},children:b(m,{fireAction:s,props:y,children:t})})}let u=e.data.componentSource===\"local\"&&e.data.componentPath!==\"\",f=u?a?.localTrees?.[e.data.componentPath]:a?.trees[e.data.contentHash],c=rd(f,e.data.previewState);if(!c){let m=u?e.data.componentPath:e.data.componentSlug;return b(\"div\",{\"data-node-id\":e.id,style:sn,children:`Component \"${m}\" preview unavailable`})}return b(\"div\",{\"data-node-id\":e.id,style:{display:\"contents\"},children:b(un,{fireAction:s,node:c.root,slot:t})})}function $(e,t,r){let{getNodeVariables:n}=te(),o=n(e);return j(()=>Qr(t,r,o),[t,r,o])}function Mo({node:e}){let t=$(e.id,e.data.style,e.data.states),r=Yt(t);return b(\"path\",{d:e.data.d,fill:r.fill,fillOpacity:r.fillOpacity,fillRule:r.fillRule,opacity:r.opacity,stroke:r.stroke,strokeLinecap:r.strokeLinecap,strokeLinejoin:r.strokeLinejoin,strokeOpacity:r.strokeOpacity,strokeWidth:r.strokeWidth,transform:e.data.transform,vectorEffect:\"non-scaling-stroke\"})}var v={InvalidCommand:\"invalid_command\",InvalidPath:\"invalid_path\",TypeMismatch:\"type_mismatch\",MissingField:\"missing_field\",MissingArrayItem:\"missing_array_item\",MissingTreeNode:\"missing_tree_node\",DuplicateArrayItemId:\"duplicate_array_item_id\",DuplicateArrayPos:\"duplicate_array_pos\",DuplicateTreeNodeId:\"duplicate_tree_node_id\",DuplicateTreePos:\"duplicate_tree_pos\",InvalidTreeParent:\"invalid_tree_parent\",TreeCycle:\"tree_cycle\",TreeNodeValueMustBeObject:\"tree_node_value_must_be_object\",ReservedIdentifier:\"reserved_identifier\"},cn=class extends Error{constructor(r,n=\"\"){super(n?`${r}: ${n}`:r);p(this,\"code\");this.name=\"CoreError\",this.code=r}},S=(e,t)=>new cn(e,t);var I=\"$root\";var re=e=>({kind:\"string\",value:e}),ot=e=>({kind:\"number\",value:e}),it=e=>({kind:\"boolean\",value:e}),ne=(e={})=>({kind:\"object\",fields:e}),fn=(e=[])=>({kind:\"array\",items:e}),pn=(e=[])=>({kind:\"tree\",nodes:e}),jo=e=>e?[...e]:[],k=e=>{switch(e.kind){case\"string\":case\"number\":case\"boolean\":return{...e};case\"object\":{let t={};for(let[r,n]of Object.entries(e.fields))t[r]=k(n);return{kind:\"object\",fields:t}}case\"array\":return{kind:\"array\",items:e.items.map(t=>({id:t.id,pos:t.pos,value:k(t.value)}))};case\"tree\":return{kind:\"tree\",nodes:e.nodes.map(t=>({id:t.id,parent:t.parent,pos:t.pos,value:k(t.value)}))}}};var dr=(e,t)=>e.post.pos?1:e.idt.id?1:0,Bo=(e,t)=>e.post.pos?1:e.idt.id?1:0,mn=(e,t)=>e.parentt.parent?1:Bo(e,t);var J=e=>{switch(e.kind){case\"string\":case\"boolean\":return;case\"number\":if(Number.isNaN(e.value)||!Number.isFinite(e.value))throw S(v.InvalidCommand,\"number must be finite\");return;case\"object\":for(let t of Object.values(e.fields))J(t);return;case\"array\":{let t=new Set,r=new Set;for(let n of e.items){if(t.has(n.id))throw S(v.DuplicateArrayItemId,\"duplicate array item id\");if(r.has(n.pos))throw S(v.DuplicateArrayPos,\"duplicate array position\");t.add(n.id),r.add(n.pos),J(n.value)}return}case\"tree\":{let t=new Set,r=new Map,n=new Map;for(let s of e.nodes){if(s.id===I)throw S(v.ReservedIdentifier,\"tree node id uses reserved root identifier\");if(s.value.kind!==\"object\")throw S(v.TreeNodeValueMustBeObject,\"tree node value must be object\");if(t.has(s.id))throw S(v.DuplicateTreeNodeId,\"duplicate tree node id\");t.add(s.id),r.set(s.id,s);let u=n.get(s.parent)??new Set;if(u.has(s.pos))throw S(v.DuplicateTreePos,\"duplicate tree position\");u.add(s.pos),n.set(s.parent,u),J(s.value)}for(let s of e.nodes)if(s.parent!==I&&!r.has(s.parent))throw S(v.InvalidTreeParent,\"tree parent does not exist\");let o=new Set,a=new Set,d=s=>{if(o.has(s))throw S(v.TreeCycle,\"tree contains cycle\");if(a.has(s))return;o.add(s);let u=r.get(s);u&&u.parent!==I&&d(u.parent),o.delete(s),a.add(s)},l=[...r.keys()].sort();for(let s of l)d(s);return}}};var Uo=(e,t)=>{J(e);let r=k(e);for(let n of t)nd(r,{...n,path:jo(n.path)});return J(r),r},nd=(e,t)=>{switch(t.kind){case\"value.set\":od(e,t.path,t.value);return;case\"object.set\":id(e,t.path,t.key,t.value);return;case\"object.delete\":ad(e,t.path,t.key);return;case\"array.insert\":dd(e,t.path,t.item);return;case\"array.move\":ld(e,t.path,t.id,t.pos);return;case\"array.delete\":sd(e,t.path,t.id);return;case\"tree.insert\":ud(e,t.path,t.node);return;case\"tree.move\":cd(e,t.path,t.id,t.parent,t.pos);return;case\"tree.delete\":fd(e,t.path,t.id);return}},od=(e,t,r)=>{if(t.length===0){Ho(e,k(r));return}W(e,t,n=>{Ho(n,k(r))})},id=(e,t,r,n)=>{if(!r)throw S(v.InvalidCommand,\"object.set requires key and value\");W(e,t,o=>{if(o.kind!==\"object\")throw S(v.TypeMismatch,\"object command requires object target\");o.fields[r]=k(n)})},ad=(e,t,r)=>{if(!r)throw S(v.InvalidCommand,\"object.delete requires key\");W(e,t,n=>{if(n.kind!==\"object\")throw S(v.TypeMismatch,\"object command requires object target\");if(!Object.hasOwn(n.fields,r))throw S(v.MissingField,\"object field does not exist\");delete n.fields[r]})},dd=(e,t,r)=>{W(e,t,n=>{if(n.kind!==\"array\")throw S(v.TypeMismatch,\"array command requires array target\");for(let o of n.items){if(o.id===r.id)throw S(v.DuplicateArrayItemId,\"array item id already exists\");if(o.pos===r.pos)throw S(v.DuplicateArrayPos,\"array item position already exists\")}n.items.push({id:r.id,pos:r.pos,value:k(r.value)}),n.items.sort(dr)})},ld=(e,t,r,n)=>{if(!r||!n)throw S(v.InvalidCommand,\"array.move requires id and pos\");W(e,t,o=>{if(o.kind!==\"array\")throw S(v.TypeMismatch,\"array command requires array target\");let a=-1;for(let d=0;d{if(!r)throw S(v.InvalidCommand,\"array.delete requires id\");W(e,t,n=>{if(n.kind!==\"array\")throw S(v.TypeMismatch,\"array command requires array target\");let o=Fo(n.items,r);if(o===-1)throw S(v.MissingArrayItem,\"array item does not exist\");n.items.splice(o,1)})},ud=(e,t,r)=>{W(e,t,n=>{if(n.kind!==\"tree\")throw S(v.TypeMismatch,\"tree command requires tree target\");if(r.id===I)throw S(v.ReservedIdentifier,\"tree node id uses reserved root identifier\");if(r.value.kind!==\"object\")throw S(v.TreeNodeValueMustBeObject,\"tree node value must be object\");if(Et(n.nodes,r.id)!==-1)throw S(v.DuplicateTreeNodeId,\"tree node id already exists\");if(!zo(n.nodes,r.parent))throw S(v.InvalidTreeParent,\"tree parent does not exist\");if($o(n.nodes,r.parent,r.pos,\"\"))throw S(v.DuplicateTreePos,\"tree sibling position already exists\");n.nodes.push({id:r.id,parent:r.parent,pos:r.pos,value:k(r.value)}),n.nodes.sort(mn)})},cd=(e,t,r,n,o)=>{if(!r||!n||!o)throw S(v.InvalidCommand,\"tree.move requires id, parent, and pos\");W(e,t,a=>{if(a.kind!==\"tree\")throw S(v.TypeMismatch,\"tree command requires tree target\");let d=Et(a.nodes,r);if(d===-1)throw S(v.MissingTreeNode,\"tree node does not exist\");if(!zo(a.nodes,n))throw S(v.InvalidTreeParent,\"tree parent does not exist\");if(n===r||pd(a.nodes,n,r))throw S(v.TreeCycle,\"tree move would create cycle\");if($o(a.nodes,n,o,r))throw S(v.DuplicateTreePos,\"tree sibling position already exists\");a.nodes[d]={...a.nodes[d],parent:n,pos:o},a.nodes.sort(mn)})},fd=(e,t,r)=>{if(!r)throw S(v.InvalidCommand,\"tree.delete requires id\");W(e,t,n=>{if(n.kind!==\"tree\")throw S(v.TypeMismatch,\"tree command requires tree target\");if(Et(n.nodes,r)===-1)throw S(v.MissingTreeNode,\"tree node does not exist\");let o=new Set([r]),a=!0;for(;a;){a=!1;for(let l of n.nodes)o.has(l.parent)&&!o.has(l.id)&&(o.add(l.id),a=!0)}let d=n.nodes.filter(l=>!o.has(l.id));n.nodes.splice(0,n.nodes.length,...d)})},W=(e,t,r)=>{if(t.length===0){r(e);return}let n=t[0],o=t.slice(1);switch(n.kind){case\"field\":if(e.kind!==\"object\")throw S(v.TypeMismatch,\"field path requires object\");if(!Object.hasOwn(e.fields,n.key))throw S(v.MissingField,\"field does not exist\");W(e.fields[n.key],o,r);return;case\"item\":if(e.kind!==\"array\")throw S(v.TypeMismatch,\"item path requires array\");{let a=Fo(e.items,n.id);if(a===-1)throw S(v.MissingArrayItem,\"array item does not exist\");W(e.items[a].value,o,r)}return;case\"node\":if(e.kind!==\"tree\")throw S(v.TypeMismatch,\"node path requires tree\");{let a=Et(e.nodes,n.id);if(a===-1)throw S(v.MissingTreeNode,\"tree node does not exist\");W(e.nodes[a].value,o,r)}return}},Ho=(e,t)=>{for(let r of Object.keys(e))delete e[r];Object.assign(e,t)},Fo=(e,t)=>e.findIndex(r=>r.id===t),Et=(e,t)=>e.findIndex(r=>r.id===t),zo=(e,t)=>t===I||Et(e,t)!==-1,$o=(e,t,r,n)=>e.some(o=>o.parent===t&&o.id!==n&&o.pos===r),pd=(e,t,r)=>{if(t===I)return!1;let n=new Map(e.map(a=>[a.id,a])),o=t;for(;;){let a=n.get(o);if(!a)return!1;if(a.parent===r)return!0;if(a.parent===I)return!1;o=a.parent}};var yn=class{next(){let t=globalThis.crypto;if(t?.getRandomValues){let r=new Uint32Array(2);t.getRandomValues(r);let n=r[0]>>>5,o=r[1]>>>6;return(n*67108864+o)/9007199254740992}return Math.random()}},hn=class{constructor(t){p(this,\"charset\");p(this,\"random\");p(this,\"useJitter\");this.charset=t?.charset??Go(),this.random=t?.random,this.useJitter=t?.useJitter??!0}between(t,r){return this.useJitter?gd(t,r,this.charset,this.random??new yn):Yo(t,r,this.charset)}},md=e=>{if(e.chars.length<7)throw new Error(\"charSet must be at least 7 characters long\");if([...e.chars].sort().join(\"\")!==e.chars)throw new Error(\"charSet must be sorted\");let r={},n={};for(let s=0;syd;var We=()=>new hn({charset:Go()});var hd=e=>`${e.firstPositive}${e.byCode[0]}`,Wo=(e,t)=>{lr(e,t)},Yo=(e,t,r)=>{if(e!==void 0&&Wo(e,r),t!==void 0&&Wo(t,r),e===void 0&&t===void 0)return hd(r);if(e===void 0)return bd(lr(t,r),r);if(t===void 0)return Xo(lr(e,r),r);if(e>=t)throw new Error(`${e} >= ${t}`);return vd(e,t,r)};var gd=(e,t,r,n)=>{let o=Yo(e,t,r),a=Td(o,t,r);return a>0?Sd(o,a,r,n):Jo(o,r,n)};var lr=(e,t)=>{let r=Zo(e,t),n=vn(r,t);if(n>e.length)throw new Error(`invalid order key length: ${e}`);return e.slice(0,n)},Xo=(e,t)=>{Qo(e,t);let[r,n]=ei(e,t),o=t.byCode[t.length-1];return[...n].some(a=>a!==o)?`${r}${oi(n,t)}`:ti(xd(r,t),\"lower\",t)},bd=(e,t)=>{Qo(e,t);let[r,n]=ei(e,t),o=t.byCode[0];return[...n].some(a=>a!==o)?`${r}${gn(n,t,!1)}`:ti(wd(r,t),\"upper\",t)},vd=(e,t,r)=>{let[n,o]=ur(e,t,\"end\",r.first),a=bn(n,o,r);a===1&&(n=`${n}${r.first}`,a=r.length);let d=ri(Math.floor(a/2),r);return Sn(n,d,r)},Jo=(e,t,r)=>{let n=r.next();if(n<0||n>=1)throw new Error(`random value out of range: ${n}`);return Sn(e,ri(Math.floor(n*t.jitterRange),t),t)},Sd=(e,t,r,n)=>Jo(`${e}${r.first.repeat(t)}`,r,n),Ko=(e,t)=>{let r=t.jitterRange-e;for(let[n,o]of Object.entries(t.paddingDict))if(o>r)return Number(n);return 0},Td=(e,t,r)=>{let n=lr(e,r),o=Xo(n,r),a=0;if(t!==void 0){let l=bn(e,t,r);l{if(e.length===0)return\"\";let r=0;if(e[0]===t.mostPositive)for(;r=e.length?e:e.slice(0,r+1)},sr=(e,t,r)=>{if(e.length===0)return 0;let n=e[0];if(n>r.mostPositive||n{if(e.length===0)throw new Error(\"head cannot be empty\");let r=e[0];if(r>t.mostPositive||r=t.firstPositive?_e(r,t.firstPositive,t)+2:_e(r,t.firstNegative,t)+2},Qo=(e,t)=>{if(vn(e,t)!==e.length)throw new Error(`invalid integer length: ${e}`)},ei=(e,t)=>{let r=Zo(e,t);return[r,e.slice(r.length)]},xd=(e,t)=>{let r=e>=t.firstPositive,n=oi(e,t),o=e.at(-1)===t.mostPositive,a=n.at(-1)===t.mostPositive;return r&&a?`${n}${t.mostNegative}`:!r&&o?e.slice(0,-1):n},wd=(e,t)=>{let r=e>=t.firstPositive,n=e.at(-1)===t.mostNegative;return r&&n?gn(e.slice(0,-1),t,!1):!r&&n?`${e}${t.mostPositive}`:gn(e,t,!1)},ti=(e,t,r)=>{let n=vn(e,r),o=t===\"upper\"?r.last:r.first;return`${e}${o.repeat(n-e.length)}`},_e=(e,t,r)=>{let n=r.byChar[e],o=r.byChar[t];if(n===void 0||o===void 0)throw new Error(\"invalid character in distance calculation\");return Math.abs(n-o)},ur=(e,t,r,n)=>{let o=Math.max(e.length,t.length);return r===\"start\"?[n.repeat(o-e.length)+e,n.repeat(o-t.length)+t]:[e+n.repeat(o-e.length),t+n.repeat(o-t.length)]},ri=(e,t)=>{if(e===0)return t.byCode[0];let r=\"\",n=e;for(;n>0;)r=`${t.byCode[n%t.length]}${r}`,n=Math.floor(n/t.length);return r},Pd=(e,t)=>{let r=0;for(let n=0;n{let[n,o]=ur(e,t,\"start\",r.first),a=[],d=0;for(let l=n.length-1;l>=0;l-=1){let s=r.byChar[n[l]]+r.byChar[o[l]]+d;d=Math.floor(s/r.length),a.push(r.byCode[s%r.length])}return d>0&&a.push(r.byCode[d]),a.reverse().join(\"\")},ni=(e,t,r,n)=>{let[o,a]=ur(e,t,\"start\",r.first),d=[],l=0;for(let u=o.length-1;u>=0;u-=1){let f=r.byChar[o[u]],c=r.byChar[a[u]]+l;f0)throw new Error(\"subtraction result is negative\");let s=d.reverse().join(\"\");return n?s.replace(new RegExp(`^${r.first}+`),\"\")||r.first:s},oi=(e,t)=>Sn(e,t.byCode[1],t),gn=(e,t,r)=>ni(e,t.byCode[1],t,r),bn=(e,t,r)=>{let[n,o]=ur(e,t,\"end\",r.first),[a,d]=n>o?[o,n]:[n,o];return Pd(ni(d,a,r,!0),r)};function Vd(e,t){let r={};for(let n=0;n<100;n+=1){let o=Tn(t,n);if(r[n]=o,o>e)break}return r}function Tn(e,t){let r=1;for(let n=0;nnew cr(e,t,r),ii=e=>e instanceof cr;var B=(e,t)=>x(T.InvalidSchema,t,{valuePath:[],schemaPath:e}),Z=(e,t,r)=>{if(typeof e!=\"object\"||e===null||Array.isArray(e))throw B(t,r);return e},ve=(e,t,r)=>{if(typeof e!=\"string\")throw B(t,r);return e},xn=(e,t)=>{if(typeof e!=\"number\"||Number.isNaN(e)||!Number.isFinite(e))throw B(t,\"validator value must be a finite number\");return e};var _d=(e,t)=>{if(e!==void 0){if(typeof e!=\"string\")throw B(t,\"value must be a string\");return e}},ai=(e,t)=>{if(e!==void 0){if(!Array.isArray(e))throw B(t,\"string validators must be an array\");return e.map((r,n)=>kd(r,[...t,n]))}},di=(e,t)=>{if(e!==void 0){if(!Array.isArray(e))throw B(t,\"number validators must be an array\");return e.map((r,n)=>Id(r,[...t,n]))}},li=(e,t)=>{if(e!==void 0){if(!Array.isArray(e))throw B(t,\"array validators must be an array\");return e.map((r,n)=>Ad(r,[...t,n]))}},kd=(e,t)=>{let r=Z(e,t,\"string validator must be an object\"),n=ve(r.kind,[...t,\"kind\"],\"validator kind must be a string\");switch(n){case\"minLength\":case\"maxLength\":case\"length\":return{kind:n,value:xn(r.value,[...t,\"value\"])};case\"regex\":return{kind:n,pattern:ve(r.pattern,[...t,\"pattern\"],\"regex pattern must be a string\"),flags:_d(r.flags,[...t,\"flags\"])};case\"email\":case\"url\":return{kind:n};default:throw B(t,`unknown string validator ${n}`)}},Id=(e,t)=>{let r=Z(e,t,\"number validator must be an object\"),n=ve(r.kind,[...t,\"kind\"],\"validator kind must be a string\");switch(n){case\"min\":case\"max\":return{kind:n,value:xn(r.value,[...t,\"value\"])};case\"positive\":case\"negative\":case\"int\":return{kind:n};default:throw B(t,`unknown number validator ${n}`)}},Ad=(e,t)=>{let r=Z(e,t,\"array validator must be an object\"),n=ve(r.kind,[...t,\"kind\"],\"validator kind must be a string\");switch(n){case\"minLength\":case\"maxLength\":return{kind:n,value:xn(r.value,[...t,\"value\"])};default:throw B(t,`unknown array validator ${n}`)}};var Dt=(e,t,r,n,o)=>{if(r.kind!==t)throw x(T.TypeMismatch,`expected ${e}, got ${r.kind}`,{valuePath:n,schemaPath:o})},fr=(e,t,r,n)=>(Dt(e,\"string\",t,r,n),t),pr=(e,t,r,n)=>(Dt(e,\"number\",t,r,n),t),mr=(e,t,r,n)=>(Dt(e,\"boolean\",t,r,n),t),yr=(e,t,r,n)=>(Dt(e,\"object\",t,r,n),t),si=(e,t,r,n)=>(Dt(e,\"array\",t,r,n),t);var O=(e,t,r)=>x(T.ValidatorFailed,e,{valuePath:t,schemaPath:r}),q=e=>e===void 0?e:k(e),E=e=>e?{required:!0}:{};var ui={kind:\"array\",parse:(e,t,r,n)=>r.normalizeDefault({kind:t.kind,required:t.required,element:r.parseSchema(e.element,[...n,\"element\"]),validators:li(e.validators,[...n,\"validators\"])},e.default,n),serialize:(e,t)=>({kind:\"array\",element:t(e.element),...E(e.required),...e.default!==void 0?{default:q(e.default)}:{},...e.validators&&e.validators.length>0?{validators:e.validators.map(r=>({...r}))}:{}}),validate:(e,t,r,n,o)=>{let d=si(e.kind,t,n,o).items.map(l=>{let s=r.validate(e.element,l.value,[...n,{kind:\"item\",id:l.id}],[...o,\"element\"]);if(s===void 0)throw x(T.TypeMismatch,\"array item cannot sanitize to undefined\",{valuePath:[...n,{kind:\"item\",id:l.id}],schemaPath:o});return{id:l.id,pos:l.pos,value:s}});for(let l of e.validators??[])switch(l.kind){case\"minLength\":if(d.lengthl.value)throw O(\"array longer than maxLength\",n,o);break}return{kind:\"array\",items:d}},materializeDefault:(e,t,r,n)=>{if(e.required)throw x(T.MissingRequired,\"required value has no default\",{valuePath:r,schemaPath:n})}};var ci={kind:\"boolean\",parse:(e,t,r,n)=>r.normalizeDefault({kind:t.kind,required:t.required},e.default,n),serialize:e=>({kind:\"boolean\",...E(e.required),...e.default!==void 0?{default:q(e.default)}:{}}),validate:(e,t,r,n,o)=>k(mr(\"boolean\",t,n,o)),materializeDefault:(e,t,r,n)=>{if(e.required)throw x(T.MissingRequired,\"required value has no default\",{valuePath:r,schemaPath:n})}};var fi={kind:\"either\",parse:(e,t,r,n)=>{if(!Array.isArray(e.variants))throw x(T.InvalidSchema,\"either variants must be an array\",{schemaPath:[...n,\"variants\"]});return r.normalizeDefault({kind:t.kind,required:t.required,variants:e.variants.map((o,a)=>r.parseSchema(o,[...n,\"variants\",a]))},e.default,n)},serialize:(e,t)=>({kind:\"either\",variants:e.variants.map(r=>t(r)),...E(e.required),...e.default!==void 0?{default:q(e.default)}:{}}),validate:(e,t,r,n,o)=>{let a=[];for(let d=0;d{if(e.required)throw x(T.MissingRequired,\"required value has no default\",{valuePath:r,schemaPath:n})}};var pi={kind:\"literal\",parse:(e,t,r,n)=>{let o=e.value;if(typeof o!=\"string\"&&typeof o!=\"number\"&&typeof o!=\"boolean\")throw B([...n,\"value\"],\"literal value must be string, number, or boolean\");return r.normalizeDefault({kind:t.kind,required:t.required,value:o},e.default,n)},serialize:e=>({kind:\"literal\",value:e.value,...E(e.required),...e.default!==void 0?{default:q(e.default)}:{}}),validate:(e,t,r,n,o)=>{let a=typeof e.value==\"string\"?\"string\":typeof e.value==\"number\"?\"number\":\"boolean\",d=a===\"string\"?fr(e.kind,t,n,o):a===\"number\"?pr(e.kind,t,n,o):mr(e.kind,t,n,o);if(d.value!==e.value)throw x(T.LiteralMismatch,\"literal value does not match schema\",{valuePath:n,schemaPath:o});return k(d)},materializeDefault:(e,t,r,n)=>{if(e.required)throw x(T.MissingRequired,\"required value has no default\",{valuePath:r,schemaPath:n})}};var mi={kind:\"number\",parse:(e,t,r,n)=>r.normalizeDefault({kind:t.kind,required:t.required,validators:di(e.validators,[...n,\"validators\"])},e.default,n),serialize:e=>({kind:\"number\",...E(e.required),...e.default!==void 0?{default:q(e.default)}:{},...e.validators&&e.validators.length>0?{validators:e.validators.map(t=>({...t}))}:{}}),validate:(e,t,r,n,o)=>{let a=pr(e.kind,t,n,o);for(let d of e.validators??[])switch(d.kind){case\"min\":if(a.valued.value)throw O(\"number larger than max\",n,o);break;case\"positive\":if(a.value<=0)throw O(\"number is not positive\",n,o);break;case\"negative\":if(a.value>=0)throw O(\"number is not negative\",n,o);break;case\"int\":if(!Number.isInteger(a.value))throw O(\"number is not an integer\",n,o);break}return k(a)},materializeDefault:(e,t,r,n)=>{if(e.required)throw x(T.MissingRequired,\"required value has no default\",{valuePath:r,schemaPath:n})}};var yi={kind:\"object\",parse:(e,t,r,n)=>{let o=Z(e.fields,[...n,\"fields\"],\"object schema fields must be an object\"),a={};for(let[d,l]of Object.entries(o))a[d]=r.parseSchema(l,[...n,\"fields\",d]);return r.normalizeDefault({kind:t.kind,required:t.required,fields:a},e.default,n)},serialize:(e,t)=>{let r={};for(let[n,o]of Object.entries(e.fields))r[n]=t(o);return{kind:\"object\",fields:r,...E(e.required),...e.default!==void 0?{default:q(e.default)}:{}}},validate:(e,t,r,n,o)=>{let a=yr(e.kind,t,n,o),d={};for(let[l,s]of Object.entries(e.fields)){let u=r.validate(s,a.fields[l],[...n,{kind:\"field\",key:l}],[...o,\"fields\",l]);u!==void 0&&(d[l]=u)}return ne(d)},materializeDefault:(e,t,r,n)=>{let o={},a=!1;for(let[d,l]of Object.entries(e.fields)){let s;try{s=t.materializeDefault(l,[...r,{kind:\"field\",key:d}],[...n,\"fields\",d])}catch(u){if(!e.required&&ii(u)&&u.code===T.MissingRequired)return;throw u}s!==void 0&&(o[d]=s,a=!0)}if(a)return ne(o);if(e.required)throw x(T.MissingRequired,\"required object has no defaultable fields\",{valuePath:r,schemaPath:n})}};var hi={kind:\"string\",parse:(e,t,r,n)=>r.normalizeDefault({kind:t.kind,required:t.required,validators:ai(e.validators,[...n,\"validators\"])},e.default,n),serialize:e=>({kind:\"string\",...E(e.required),...e.default!==void 0?{default:q(e.default)}:{},...e.validators&&e.validators.length>0?{validators:e.validators.map(t=>({...t}))}:{}}),validate:(e,t,r,n,o)=>{let a=fr(e.kind,t,n,o);for(let d of e.validators??[])switch(d.kind){case\"minLength\":if(a.value.lengthd.value)throw O(\"string longer than maxLength\",n,o);break;case\"length\":if(a.value.length!==d.value)throw O(\"string length mismatch\",n,o);break;case\"regex\":if(!new RegExp(d.pattern,d.flags).test(a.value))throw O(\"string does not match regex\",n,o);break;case\"email\":if(!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(a.value))throw O(\"invalid email\",n,o);break;case\"url\":try{new URL(a.value)}catch{throw O(\"invalid url\",n,o)}break}return k(a)},materializeDefault:(e,t,r,n)=>{if(e.required)throw x(T.MissingRequired,\"required value has no default\",{valuePath:r,schemaPath:n})}};var gi={kind:\"tree\",parse:(e,t,r,n)=>{let o=ve(e.discriminator,[...n,\"discriminator\"],\"tree discriminator must be a string\"),a=e.roots;if(!Array.isArray(a)||!a.every(s=>typeof s==\"string\"))throw x(T.InvalidSchema,\"tree roots must be a string array\",{schemaPath:[...n,\"roots\"]});let d=Z(e.variants,[...n,\"variants\"],\"tree variants must be an object\"),l={};for(let[s,u]of Object.entries(d)){let f=Z(u,[...n,\"variants\",s],\"tree variant must be an object\"),c=r.parseSchema(f.schema,[...n,\"variants\",s,\"schema\"]);if(c.kind!==\"object\")throw x(T.InvalidSchema,\"tree variant schema must be an object schema\",{schemaPath:[...n,\"variants\",s,\"schema\"]});let m=c.fields[o];if(m?.kind!==\"literal\"||m.value!==s)throw x(T.InvalidSchema,\"tree discriminator field must be a matching literal\",{schemaPath:[...n,\"variants\",s,\"schema\",\"fields\",o]});let y=f.children;if(!Array.isArray(y)||!y.every(N=>typeof N==\"string\"))throw x(T.InvalidSchema,\"tree variant children must be a string array\",{schemaPath:[...n,\"variants\",s,\"children\"]});l[s]={schema:c,children:[...y]}}for(let s of a)if(!Object.hasOwn(l,s))throw x(T.InvalidSchema,`unknown tree root variant ${s}`,{schemaPath:[...n,\"roots\"]});for(let[s,u]of Object.entries(l))for(let f of u.children)if(!Object.hasOwn(l,f))throw x(T.InvalidSchema,`unknown tree child variant ${f}`,{schemaPath:[...n,\"variants\",s,\"children\"]});return r.normalizeDefault({kind:t.kind,required:t.required,discriminator:o,roots:[...a],variants:l},e.default,n)},serialize:(e,t)=>{let r={};for(let[n,o]of Object.entries(e.variants))r[n]={schema:t(o.schema),children:[...o.children]};return{kind:\"tree\",discriminator:e.discriminator,roots:[...e.roots],variants:r,...E(e.required),...e.default!==void 0?{default:q(e.default)}:{}}},validate:(e,t,r,n,o)=>{let a=(()=>{if(t.kind!==\"tree\")throw x(T.TypeMismatch,`expected ${e.kind}, got ${t.kind}`,{valuePath:n,schemaPath:o});return t})(),d=new Map,l=a.nodes.map(s=>{let u=s.value.fields[e.discriminator];if(u===void 0||u.kind!==\"string\")throw x(T.TreeUnknownVariant,\"tree node discriminator is missing or invalid\",{valuePath:[...n,{kind:\"node\",id:s.id},{kind:\"field\",key:e.discriminator}],schemaPath:[...o,\"discriminator\"]});let f=e.variants[u.value];if(!f)throw x(T.TreeUnknownVariant,`unknown tree variant ${u.value}`,{valuePath:[...n,{kind:\"node\",id:s.id},{kind:\"field\",key:e.discriminator}],schemaPath:[...o,\"variants\"]});let c=r.validate(f.schema,s.value,[...n,{kind:\"node\",id:s.id}],[...o,\"variants\",u.value,\"schema\"]);return d.set(s.id,u.value),{id:s.id,parent:s.parent,pos:s.pos,value:c}});for(let s of l){let u=d.get(s.id);if(s.parent===I){if(!e.roots.includes(u))throw x(T.TreeInvalidRootType,`tree root type ${u} is not allowed`,{valuePath:[...n,{kind:\"node\",id:s.id}],schemaPath:[...o,\"roots\"]});continue}let f=d.get(s.parent);if(!f)throw x(T.TreeUnknownVariant,\"tree parent variant is missing\",{valuePath:[...n,{kind:\"node\",id:s.id}],schemaPath:o});if(!e.variants[f].children.includes(u))throw x(T.TreeInvalidChildType,`tree child type ${u} is not allowed under ${f}`,{valuePath:[...n,{kind:\"node\",id:s.id}],schemaPath:[...o,\"variants\",f,\"children\"]})}return{kind:\"tree\",nodes:l}},materializeDefault:(e,t,r,n)=>{if(e.required)throw x(T.MissingRequired,\"required value has no default\",{valuePath:r,schemaPath:n})}};var bi={kind:\"union\",parse:(e,t,r,n)=>{let o=ve(e.discriminator,[...n,\"discriminator\"],\"union discriminator must be a string\"),a=Z(e.variants,[...n,\"variants\"],\"union variants must be an object\"),d={};for(let[l,s]of Object.entries(a)){let u=r.parseSchema(s,[...n,\"variants\",l]);if(u.kind!==\"object\")throw x(T.InvalidSchema,\"union variants must be object schemas\",{schemaPath:[...n,\"variants\",l]});let f=u.fields[o];if(f?.kind!==\"literal\"||f.value!==l)throw x(T.InvalidSchema,\"union discriminator field must be a matching literal\",{schemaPath:[...n,\"variants\",l,\"fields\",o]});d[l]=u}return r.normalizeDefault({kind:t.kind,required:t.required,discriminator:o,variants:d},e.default,n)},serialize:(e,t)=>{let r={};for(let[n,o]of Object.entries(e.variants))r[n]=t(o);return{kind:\"union\",discriminator:e.discriminator,variants:r,...E(e.required),...e.default!==void 0?{default:q(e.default)}:{}}},validate:(e,t,r,n,o)=>{let a=yr(e.kind,t,n,o),d=a.fields[e.discriminator];if(d===void 0||d.kind!==\"string\")throw x(T.UnionNoMatch,\"union discriminator is missing or invalid\",{valuePath:[...n,{kind:\"field\",key:e.discriminator}],schemaPath:[...o,\"discriminator\"]});let l=e.variants[d.value];if(!l)throw x(T.UnionNoMatch,`unknown union discriminator ${d.value}`,{valuePath:[...n,{kind:\"field\",key:e.discriminator}],schemaPath:[...o,\"variants\"]});return r.validate(l,a,n,[...o,\"variants\",d.value])},materializeDefault:(e,t,r,n)=>{if(e.required)throw x(T.MissingRequired,\"required value has no default\",{valuePath:r,schemaPath:n})}};var Rd={string:hi,number:mi,boolean:ci,literal:pi,object:yi,array:ui,union:bi,either:fi,tree:gi};var at=e=>Rd[e];var wn=(e,t,r)=>vi(e,t,r),vi=(e,t,r)=>\"default\"in e&&e.default!==void 0?k(e.default):at(e.kind).materializeDefault(e,Cd,t,r),Cd={materializeDefault:vi};var Pn=(e,t)=>(t!==void 0&&J(t),Si(e,t,[],[])),Si=(e,t,r,n)=>t===void 0?wn(e,r,n):at(e.kind).validate(e,t,qd,r,n),qd={validate:Si,materializeDefault:wn};var i={};Pa(i,{Array:()=>zd,ArrayPrimitive:()=>wr,Boolean:()=>Bd,BooleanPrimitive:()=>Sr,Either:()=>$d,EitherPrimitive:()=>Pr,Lazy:()=>Qd,LazyPrimitive:()=>Ir,Literal:()=>Hd,LiteralPrimitive:()=>Tr,Number:()=>jd,NumberPrimitive:()=>vr,PrimitiveError:()=>h,String:()=>Md,StringPrimitive:()=>br,Struct:()=>Fd,StructPrimitive:()=>xr,Tree:()=>Jd,TreeNode:()=>Yd,TreeNodePrimitive:()=>Nr,TreeNodeSelf:()=>Kd,TreePrimitive:()=>kr,Union:()=>Wd,UnionPrimitive:()=>Vr,commands:()=>Od,expectDefined:()=>Ed,hasOwn:()=>Dd,randomUuid:()=>dt,shortId:()=>Ke});var h=class extends Error{constructor(t){super(t),this.name=\"PrimitiveError\"}},Ed=(e,t)=>{if(e===void 0)throw new h(t);return e},Dd=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),dt=()=>{let e=globalThis.crypto?.randomUUID?.();if(typeof e==\"string\")return e;throw new h(\"crypto.randomUUID is not available in this runtime\")},Ti=\"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\",Ld=10,Ke=(e=Ld)=>{let t=globalThis.crypto?.getRandomValues?.bind(globalThis.crypto);if(!t)throw new h(\"crypto.getRandomValues is not available in this runtime\");let r=Ti.length,n=Math.floor(256/r)*r,o=\"\",a=new Uint8Array(e);for(;o.length{J(t);let n=Pn(e.schema,t);if(n===void 0)throw new Error(\"Primitive root snapshot cannot be undefined\");let o=[],a=n,d=We(),l={current:()=>a,emit:s=>{s.length!==0&&(o.push(...s),a=Uo(n,o))},generator:{nextArrayItemId:()=>dt(),nextTreeNodeId:()=>Ke(),between:(s,u)=>d.between(s,u)}};return r(e.createProxy(l,[])),o};var D=(e,t)=>{let r=e;for(let n of t){if(r===void 0)return;switch(n.kind){case\"field\":if(r.kind!==\"object\")return;r=r.fields[n.key];break;case\"item\":if(r.kind!==\"array\")return;r=r.items.find(o=>o.id===n.id)?.value;break;case\"node\":if(r.kind!==\"tree\")return;r=r.nodes.find(o=>o.id===n.id)?.value;break}}return r},hr=(e,t)=>{let r=D(e,t);return r?.kind===\"object\"?r:void 0},H=(e,t)=>{let r=D(e,t);return r?.kind===\"array\"?r:void 0},K=(e,t)=>{let r=D(e,t);return r?.kind===\"tree\"?r:void 0},Se=e=>e?[...e.items].sort((t,r)=>t.pos.localeCompare(r.pos)):[],gr=(e,t)=>e?.items.find(r=>r.id===t),Vn=(e,t)=>{let r=Se(e),n=Lt(t,r.length);return n===void 0?void 0:r[n]},Nn=(e,t)=>e?.nodes.find(r=>r.id===t),ke=(e,t)=>e?e.nodes.filter(r=>r.parent===t).sort((r,n)=>r.pos.localeCompare(n.pos)):[];var Lt=(e,t)=>{let r=e<0?t+e:e;if(!(r<0||r>=t))return r},lt=(e,t)=>{if(t<0||t>e.length)throw new h(`Index ${t} is out of range`);return{lower:t===0?void 0:e[t-1],upper:t===e.length?void 0:e[t]}};var _n=(e,t,r)=>{if(!e||t===I)return!1;let n=new Map(e.nodes.map(a=>[a.id,a])),o=t;for(;;){let a=n.get(o);if(!a)return!1;if(a.parent===r)return!0;if(a.parent===I)return!1;o=a.parent}},kn=(e,t)=>[...e,{kind:\"node\",id:t}];var br=class e{constructor(t){p(this,\"_tag\",\"StringPrimitive\");p(this,\"_Input\");p(this,\"_Snapshot\");p(this,\"_Proxy\");p(this,\"state\");this.state=t}get schema(){return{kind:\"string\",...this.state.required?{required:!0}:{},...this.state.defaultValue!==void 0?{default:re(this.state.defaultValue)}:{},...this.state.validators.length>0?{validators:this.state.validators}:{}}}required(){return new e({...this.state,required:!0})}default(t){return new e({...this.state,defaultValue:t})}optional(t){return new e({...this.state,required:!1,defaultValue:t?void 0:this.state.defaultValue})}min(t){return new e({...this.state,validators:[...this.state.validators,{kind:\"minLength\",value:t}]})}max(t){return new e({...this.state,validators:[...this.state.validators,{kind:\"maxLength\",value:t}]})}length(t){return new e({...this.state,validators:[...this.state.validators,{kind:\"length\",value:t}]})}regex(t,r){return new e({...this.state,validators:[...this.state.validators,{kind:\"regex\",pattern:t.source,flags:t.flags}]})}email(){return new e({...this.state,validators:[...this.state.validators,{kind:\"email\"}]})}url(){return new e({...this.state,validators:[...this.state.validators,{kind:\"url\"}]})}encode(t){let r=this.encodeOptional(t);if(r===void 0)throw new h(\"String value is undefined\");return r}encodeOptional(t){if(t===void 0){if(this.state.defaultValue!==void 0)return re(this.state.defaultValue);if(this.state.required)throw new h(\"String value is required\");return}if(typeof t!=\"string\")throw new h(\"Expected string input\");return re(t)}decode(t){return t?.kind===\"string\"?t.value:void 0}createProxy(t,r){return{get:()=>this.decode(D(t.current(),r)),set:n=>{t.emit([{kind:\"value.set\",path:r,value:this.encode(n)}])},update:n=>{t.emit([{kind:\"value.set\",path:r,value:this.encode(n)}])}}}},Md=()=>new br({required:!1,defaultValue:void 0,validators:[]});var vr=class e{constructor(t){p(this,\"_tag\",\"NumberPrimitive\");p(this,\"_Input\");p(this,\"_Snapshot\");p(this,\"_Proxy\");p(this,\"state\");this.state=t}get schema(){return{kind:\"number\",...this.state.required?{required:!0}:{},...this.state.defaultValue!==void 0?{default:ot(this.state.defaultValue)}:{},...this.state.validators.length>0?{validators:this.state.validators}:{}}}required(){return new e({...this.state,required:!0})}default(t){return new e({...this.state,defaultValue:t})}optional(t){return new e({...this.state,required:!1,defaultValue:t?void 0:this.state.defaultValue})}min(t){return new e({...this.state,validators:[...this.state.validators,{kind:\"min\",value:t}]})}max(t){return new e({...this.state,validators:[...this.state.validators,{kind:\"max\",value:t}]})}positive(){return new e({...this.state,validators:[...this.state.validators,{kind:\"positive\"}]})}negative(){return new e({...this.state,validators:[...this.state.validators,{kind:\"negative\"}]})}int(){return new e({...this.state,validators:[...this.state.validators,{kind:\"int\"}]})}encode(t){let r=this.encodeOptional(t);if(r===void 0)throw new h(\"Number value is undefined\");return r}encodeOptional(t){if(t===void 0){if(this.state.defaultValue!==void 0)return ot(this.state.defaultValue);if(this.state.required)throw new h(\"Number value is required\");return}if(typeof t!=\"number\"||globalThis.Number.isNaN(t)||!globalThis.Number.isFinite(t))throw new h(\"Expected finite number input\");return ot(t)}decode(t){return t?.kind===\"number\"?t.value:void 0}createProxy(t,r){return{get:()=>this.decode(D(t.current(),r)),set:n=>{t.emit([{kind:\"value.set\",path:r,value:this.encode(n)}])},update:n=>{t.emit([{kind:\"value.set\",path:r,value:this.encode(n)}])}}}},jd=()=>new vr({required:!1,defaultValue:void 0,validators:[]});var Sr=class e{constructor(t){p(this,\"_tag\",\"BooleanPrimitive\");p(this,\"_Input\");p(this,\"_Snapshot\");p(this,\"_Proxy\");p(this,\"state\");this.state=t}get schema(){return{kind:\"boolean\",...this.state.required?{required:!0}:{},...this.state.defaultValue!==void 0?{default:it(this.state.defaultValue)}:{}}}required(){return new e({...this.state,required:!0})}default(t){return new e({...this.state,defaultValue:t})}optional(t){return new e({...this.state,required:!1,defaultValue:t?void 0:this.state.defaultValue})}encode(t){let r=this.encodeOptional(t);if(r===void 0)throw new h(\"Boolean value is undefined\");return r}encodeOptional(t){if(t===void 0){if(this.state.defaultValue!==void 0)return it(this.state.defaultValue);if(this.state.required)throw new h(\"Boolean value is required\");return}if(typeof t!=\"boolean\")throw new h(\"Expected boolean input\");return it(t)}decode(t){return t?.kind===\"boolean\"?t.value:void 0}createProxy(t,r){return{get:()=>this.decode(D(t.current(),r)),set:n=>{t.emit([{kind:\"value.set\",path:r,value:this.encode(n)}])},update:n=>{t.emit([{kind:\"value.set\",path:r,value:this.encode(n)}])}}}},Bd=()=>new Sr({required:!1,defaultValue:void 0});var In=e=>{switch(typeof e){case\"string\":return re(e);case\"number\":return ot(e);case\"boolean\":return it(e);default:throw new h(`Unsupported scalar value type: ${typeof e}`)}};var Tr=class e{constructor(t){p(this,\"_tag\",\"LiteralPrimitive\");p(this,\"_Input\");p(this,\"_Snapshot\");p(this,\"_Proxy\");p(this,\"state\");this.state=t}get literal(){return this.state.literal}get schema(){return{kind:\"literal\",value:this.state.literal,...this.state.required?{required:!0}:{},...this.state.defaultValue!==void 0?{default:In(this.state.defaultValue)}:{}}}required(){return new e({...this.state,required:!0})}default(t){if(t!==this.state.literal)throw new h(\"Literal default must equal the literal value\");return new e({...this.state,defaultValue:t})}optional(t){return new e({...this.state,required:!1,defaultValue:t?void 0:this.state.defaultValue})}encode(t){let r=this.encodeOptional(t);if(r===void 0)throw new h(\"Literal value is undefined\");return r}encodeOptional(t){let r=t===void 0?this.state.defaultValue:t;if(r===void 0){if(this.state.required)throw new h(\"Literal value is required\");return}if(r!==this.state.literal)throw new h(\"Input does not match the literal value\");return In(this.state.literal)}decode(t){if(t!==void 0&&\"value\"in t&&t.value===this.state.literal)return this.state.literal}createProxy(t,r){return{get:()=>this.decode(D(t.current(),r)),set:n=>{t.emit([{kind:\"value.set\",path:r,value:this.encode(n)}])},update:n=>{t.emit([{kind:\"value.set\",path:r,value:this.encode(n)}])}}}},Hd=e=>new Tr({required:!1,defaultValue:void 0,literal:e});var xr=class e{constructor(t){p(this,\"_tag\",\"StructPrimitive\");p(this,\"_Input\");p(this,\"_Snapshot\");p(this,\"_Proxy\");p(this,\"state\");this.state=t}get fields(){return this.state.fields}get schema(){let t={};for(let[r,n]of Object.entries(this.state.fields))t[r]=n.schema;return{kind:\"object\",fields:t,...this.state.required?{required:!0}:{},...this.state.defaultValue!==void 0?{default:this.encode(this.state.defaultValue)}:{}}}required(){return new e({...this.state,required:!0})}default(t){return new e({...this.state,defaultValue:t})}extend(t){return new e({required:this.state.required,defaultValue:void 0,fields:{...this.state.fields,...t}})}partial(t){let r=t?.stripDefaults??!1,n={};for(let[o,a]of Object.entries(this.state.fields))n[o]=xi(a,r);return new e({required:this.state.required,defaultValue:void 0,fields:n})}optional(t){let r=t?Object.fromEntries(Object.entries(this.state.fields).map(([n,o])=>[n,xi(o,!0)])):this.state.fields;return new e({required:!1,defaultValue:t?void 0:this.state.defaultValue,fields:r})}encode(t){let r=this.encodeOptional(t);if(r===void 0)throw new h(\"Struct value is undefined\");return r}encodeOptional(t){if(t===void 0){if(this.state.defaultValue!==void 0)return this.encode(this.state.defaultValue);if(this.state.required)throw new h(\"Struct value is required\");return}if(typeof t!=\"object\"||t===null||Array.isArray(t))throw new h(\"Expected object input\");let r=t,n={};for(let[o,a]of Object.entries(this.state.fields)){let d=a.encodeOptional(r[o]);d!==void 0&&(n[o]=d)}return ne(n)}decode(t){if(t?.kind!==\"object\")return;let r={};for(let[n,o]of Object.entries(this.state.fields)){let a=o.decode(t.fields[n]);a!==void 0&&(r[n]=a)}return r}createProxy(t,r){let n={get:()=>this.decode(D(t.current(),r)),set:o=>{t.emit([{kind:\"value.set\",path:r,value:this.encode(o)}])},update:o=>{Ud(this.state.fields,t,r,o)}};for(let[o,a]of Object.entries(this.state.fields))n[o]=a.createProxy(t,[...r,{kind:\"field\",key:o}]);return n}},Ud=(e,t,r,n)=>{for(let[o,a]of Object.entries(n)){let d=e[o];if(d){if(a===void 0){hr(t.current(),r)?.fields[o]!==void 0&&t.emit([{kind:\"object.delete\",path:r,key:o}]);continue}if(d._tag===\"StructPrimitive\"&&typeof a==\"object\"&&a!==null&&!Array.isArray(a)){d.createProxy(t,[...r,{kind:\"field\",key:o}]).update(a);continue}t.emit([{kind:\"object.set\",path:r,key:o,value:d.encode(a)}])}}},xi=(e,t)=>{let r=e.optional;return typeof r==\"function\"?r.call(e,t):e},Fd=e=>new xr({required:!1,defaultValue:void 0,fields:e});var wr=class e{constructor(t){p(this,\"_tag\",\"ArrayPrimitive\");p(this,\"_Input\");p(this,\"_Snapshot\");p(this,\"_Proxy\");p(this,\"state\");p(this,\"encodedDefaultCache\");this.state=t}encodedDefault(){return this.encodedDefaultCache===void 0&&(this.encodedDefaultCache={value:this.state.defaultValue===void 0?void 0:this.encodeEntries(this.state.defaultValue)}),this.encodedDefaultCache.value}get element(){return this.state.element}get schema(){let t=this.encodedDefault();return{kind:\"array\",element:this.state.element.schema,...this.state.required?{required:!0}:{},...t!==void 0?{default:k(t)}:{},...this.state.validators.length>0?{validators:this.state.validators}:{}}}required(){return new e({...this.state,required:!0})}default(t){return new e({...this.state,defaultValue:t})}minLength(t){return new e({...this.state,validators:[...this.state.validators,{kind:\"minLength\",value:t}]})}maxLength(t){return new e({...this.state,validators:[...this.state.validators,{kind:\"maxLength\",value:t}]})}encode(t){let r=this.encodeOptional(t);if(r===void 0)throw new h(\"Array value is undefined\");return r}encodeOptional(t){if(t===void 0){let r=this.encodedDefault();if(r!==void 0)return k(r);if(this.state.required)throw new h(\"Array value is required\");return}if(!globalThis.Array.isArray(t))throw new h(\"Expected array input\");return this.encodeEntries(t)}decode(t){if(t?.kind===\"array\")return Se(t).map(r=>({id:r.id,pos:r.pos,value:this.state.element.decode(r.value)}))}createProxy(t,r){let n=d=>({get id(){return d},get pos(){return gr(H(t.current(),r),d)?.pos??\"\"},get value(){return a.state.element.createProxy(t,[...r,{kind:\"item\",id:d}])},get:()=>{let l=gr(H(t.current(),r),d);return this.state.element.decode(l?.value)},remove:()=>{t.emit([{kind:\"array.delete\",path:r,id:d}])},move:l=>{wi(t,r,d,l)},moveToPos:l=>{t.emit([{kind:\"array.move\",path:r,id:d,pos:l}])}}),o=()=>Se(H(t.current(),r)).map(d=>n(d.id)),a=this;return{get length(){return Se(H(t.current(),r)).length},get:()=>this.decode(H(t.current(),r)),set:d=>{t.emit([{kind:\"value.set\",path:r,value:this.encode(d)}])},push:d=>{let l=Se(H(t.current(),r)),s=l.length===0?void 0:l[l.length-1]?.pos,u=t.generator.nextArrayItemId(r),f=t.generator.between(s,void 0);return t.emit([{kind:\"array.insert\",path:r,item:{id:u,pos:f,value:this.state.element.encode(d)}}]),n(u)},insertAt:(d,l)=>{let s=Se(H(t.current(),r)),u=lt(s.map(m=>m.pos),d),f=t.generator.nextArrayItemId(r),c=t.generator.between(u.lower,u.upper);return t.emit([{kind:\"array.insert\",path:r,item:{id:f,pos:c,value:this.state.element.encode(l)}}]),n(f)},insertAtPos:(d,l)=>{let s=t.generator.nextArrayItemId(r);return t.emit([{kind:\"array.insert\",path:r,item:{id:s,pos:d,value:this.state.element.encode(l)}}]),n(s)},remove:d=>{t.emit([{kind:\"array.delete\",path:r,id:d}])},move:(d,l)=>{wi(t,r,d,l)},moveToPos:(d,l)=>{t.emit([{kind:\"array.move\",path:r,id:d,pos:l}])},at:d=>{let l=Vn(H(t.current(),r),d);return l?n(l.id):void 0},first:()=>{let d=Vn(H(t.current(),r),0);return d?n(d.id):void 0},last:()=>{let d=Se(H(t.current(),r)),l=d[d.length-1];return l?n(l.id):void 0},findById:d=>{let l=gr(H(t.current(),r),d);return l?n(l.id):void 0},find:d=>o().find((s,u,f)=>d(s,u,f)),findIndex:d=>o().findIndex((s,u,f)=>d(s,u,f)),map:d=>o().map((s,u,f)=>d(s,u,f)),filter:d=>o().filter((s,u,f)=>d(s,u,f)),some:d=>o().some((s,u,f)=>d(s,u,f)),every:d=>o().every((s,u,f)=>d(s,u,f)),forEach:d=>{o().forEach((s,u,f)=>d(s,u,f))},[Symbol.iterator]:()=>o()[Symbol.iterator]()}}optional(t){return new e({...this.state,required:!1,defaultValue:t?void 0:this.state.defaultValue})}encodeEntries(t){let r=We(),n=new Set,o,a=t.map(d=>{let l=r.between(o,void 0);o=l;let s=dt();for(;n.has(s);)s=dt();return n.add(s),{id:s,pos:l,value:this.state.element.encode(d)}});return fn(a)}},wi=(e,t,r,n)=>{let o=Se(H(e.current(),t));if(o.findIndex(c=>c.id===r)===-1)throw new h(`Array item \"${r}\" does not exist`);let d=o.filter(c=>c.id!==r),l=Lt(n,d.length+1);if(l===void 0&&n!==d.length)throw new h(`Index ${n} is out of range`);let s=l??d.length,u=lt(d.map(c=>c.pos),s),f=e.generator.between(u.lower,u.upper);e.emit([{kind:\"array.move\",path:t,id:r,pos:f}])},zd=e=>new wr({required:!1,defaultValue:void 0,element:e,validators:[]});var Pr=class e{constructor(t){p(this,\"_tag\",\"EitherPrimitive\");p(this,\"_Input\");p(this,\"_Snapshot\");p(this,\"_Proxy\");p(this,\"state\");if(this.state=t,t.variants.length===0)throw new h(\"Either requires at least one variant\")}get schema(){return{kind:\"either\",variants:this.state.variants.map(t=>t.schema),...this.state.required?{required:!0}:{},...this.state.defaultValue!==void 0?{default:this.encode(this.state.defaultValue)}:{}}}required(){return new e({...this.state,required:!0})}default(t){return new e({...this.state,defaultValue:t})}encode(t){let r=this.encodeOptional(t);if(r===void 0)throw new h(\"Either value is undefined\");return r}encodeOptional(t){let r=t===void 0?this.state.defaultValue:t;if(r===void 0){if(this.state.required)throw new h(\"Either value is required\");return}for(let n of this.state.variants)try{let o=n.encodeOptional(r);if(o!==void 0)return o}catch{}throw new h(\"Either input did not match any variant\")}decode(t){for(let r of this.state.variants){let n=r.decode(t);if(n!==void 0)return n}}createProxy(t,r){return{get:()=>this.decode(D(t.current(),r)),set:n=>{t.emit([{kind:\"value.set\",path:r,value:this.encode(n)}])},update:n=>{t.emit([{kind:\"value.set\",path:r,value:this.encode(n)}])}}}optional(t){return new e({...this.state,required:!1,defaultValue:t?void 0:this.state.defaultValue})}},$d=(...e)=>new Pr({required:!1,defaultValue:void 0,variants:e});var Vr=class e{constructor(t){p(this,\"_tag\",\"UnionPrimitive\");p(this,\"_Input\");p(this,\"_Snapshot\");p(this,\"_Proxy\");p(this,\"state\");this.state=t}get discriminator(){return this.state.discriminator}get variants(){return this.state.variants}get schema(){let t={};for(let[r,n]of Object.entries(this.state.variants)){let o=n.schema;if(o.kind!==\"object\")throw new h(\"Union variants must compile to object schemas\");let a={...o.fields,[this.state.discriminator]:{kind:\"literal\",value:r}};t[r]={kind:\"object\",fields:a}}return{kind:\"union\",discriminator:this.state.discriminator,variants:t,...this.state.required?{required:!0}:{},...this.state.defaultValue!==void 0?{default:this.encode(this.state.defaultValue)}:{}}}required(){return new e({...this.state,required:!0})}default(t){return new e({...this.state,defaultValue:t})}encode(t){let r=this.encodeOptional(t);if(r===void 0)throw new h(\"Union value is undefined\");return r}encodeOptional(t){let r=t===void 0?this.state.defaultValue:t;if(r===void 0){if(this.state.required)throw new h(\"Union value is required\");return}if(typeof r!=\"object\"||r===null||Array.isArray(r))throw new h(\"Expected union input object\");let n=this.resolveVariantKey(r),o=this.state.variants[n];if(!o)throw new h(`Unknown union variant \"${String(n)}\"`);let a=o.encode(r);if(a.kind!==\"object\")throw new h(\"Union variant must encode to an object value\");return ne({...a.fields,[this.state.discriminator]:re(String(n))})}decode(t){if(t?.kind!==\"object\")return;let r=this.resolveVariantKeyFromObject(t);if(!r)return;let o=this.state.variants[r]?.decode(t);if(o!==void 0)return{...o,[this.state.discriminator]:r}}createProxy(t,r){return{get:()=>this.decode(D(t.current(),r)),set:n=>{t.emit([{kind:\"value.set\",path:r,value:this.encode(n)}])},as:n=>{let o=this.state.variants[String(n)];if(!o)throw new h(`Unknown union variant \"${String(n)}\"`);return o.createProxy(t,r)},match:n=>{let o=hr(t.current(),r),a=o?this.resolveVariantKeyFromObject(o):void 0;if(!a)return;let d=n[a];if(d)return d(this.state.variants[a].createProxy(t,r))}}}optional(t){return new e({...this.state,required:!1,defaultValue:t?void 0:this.state.defaultValue})}resolveVariantKey(t){let r=t[this.state.discriminator];if(typeof r==\"string\"&&Object.prototype.hasOwnProperty.call(this.state.variants,r))return r;let n=Object.entries(this.state.variants).filter(([,o])=>{let a=Object.keys(o.fields);return Object.keys(t).every(d=>d===this.state.discriminator||a.includes(d))}).map(([o])=>o);if(n.length===1)return n[0];throw new h(\"Unable to resolve union variant from input\")}resolveVariantKeyFromObject(t){let r=t.fields[this.state.discriminator];if(r?.kind===\"string\"&&Object.prototype.hasOwnProperty.call(this.state.variants,r.value))return r.value}},Wd=(e,t)=>new Vr({required:!1,defaultValue:void 0,discriminator:t?.discriminator??\"type\",variants:e});var Pi=Symbol.for(\"TreeNode.Self\"),Kd={_tag:\"TreeNodeSelf\",_brand:Pi},Gd=e=>typeof e==\"object\"&&e!==null&&\"_brand\"in e&&e._brand===Pi,Nr=class{constructor(t,r,n){p(this,\"_tag\",\"TreeNodePrimitive\");p(this,\"_Type\");p(this,\"_Data\");p(this,\"_Children\");p(this,\"TSetInput\");p(this,\"type\");p(this,\"data\");p(this,\"childrenInput\");p(this,\"resolvedChildren\");this.type=t,this.data=r,this.childrenInput=n}get children(){if(this.resolvedChildren===void 0){let t=typeof this.childrenInput==\"function\"?this.childrenInput():this.childrenInput;this.resolvedChildren=t.map(r=>Gd(r)?this:r)}return this.resolvedChildren}isChildAllowed(t){return this.children.some(r=>r.type===t)}},Yd=(e,t)=>new Nr(e,t.data,t.children);var kr=class e{constructor(t){p(this,\"_tag\",\"TreePrimitive\");p(this,\"_Input\");p(this,\"_Snapshot\");p(this,\"_Proxy\");p(this,\"state\");this.state=t}get schema(){let t=Xd(this.state.root);return{kind:\"tree\",discriminator:\"type\",roots:[this.state.root.type],variants:t,...this.state.required?{required:!0}:{},...this.state.encodedDefault!==void 0?{default:k(this.state.encodedDefault)}:{}}}required(){return new e({...this.state,required:!0})}default(t){return new e({...this.state,defaultValue:t,encodedDefault:this.encodeRoots(t)})}encode(t){let r=this.encodeOptional(t);if(r===void 0)throw new h(\"Tree value is undefined\");return r}encodeOptional(t){if(t===void 0){if(this.state.encodedDefault!==void 0)return k(this.state.encodedDefault);if(this.state.required)throw new h(\"Tree value is required\");return}if(!Array.isArray(t))throw new h(\"Expected tree root input array\");return this.encodeRoots(t)}encodeRoots(t){let r=[],n=_r(this.state.root),o=new Set([I]),a,d=We(),l=(s,u,f,c)=>{let m=n.get(s.type);if(!m)throw new h(`Unknown tree node type \"${s.type}\"`);if(!f.some(w=>w.type===m.type))throw new h(`Node type \"${s.type}\" is not allowed here`);let y=s.id??Zd(o);if(o.has(y))throw new h(`Duplicate tree node id \"${y}\" in encoded input`);o.add(y);let N=d.between(c,void 0);r.push({id:y,parent:u,pos:N,value:An(m,s)});let R;for(let w of s.children??[])R=l(w,y,m.children,R);return N};for(let s of t)a=l(s,I,[this.state.root],a);return pn(r)}decode(t){if(t?.kind!==\"tree\")return;let r=_r(this.state.root);return _i(t,I,r)}createProxy(t,r){let n=_r(this.state.root);return{get:()=>this.decode(K(t.current(),r)),set:o=>{t.emit([{kind:\"value.set\",path:r,value:this.encode(o)}])},findByIdAcrossTree:o=>{let a=K(t.current(),r),d=Nn(a,o);if(!d)return;let l=d.value.fields.type,s=n.get(l?.kind===\"string\"?l.value:\"\");return s?Ge(t,r,n,s,o):void 0},children:Ni(t,r,n,I,[this.state.root])}}optional(t){return new e({...this.state,required:!1,defaultValue:t?void 0:this.state.defaultValue,encodedDefault:t?void 0:this.state.encodedDefault})}},Ni=(e,t,r,n,o)=>{let a=()=>ke(K(e.current(),t),n).filter(l=>o.some(s=>s.type===oe(l))).map(l=>Ge(e,t,r,r.get(oe(l)),l.id)),d={__parentId:n,get length(){return a().length},at:l=>{let s=ke(K(e.current(),t),n).filter(c=>o.some(m=>m.type===oe(c))),u=Lt(l,s.length),f=u===void 0?void 0:s[u];return f?Ge(e,t,r,r.get(oe(f)),f.id):void 0},first:()=>d.at(0),last:()=>{let l=a();return l[l.length-1]},findById:l=>{let s=ke(K(e.current(),t),n).find(f=>f.id===l);if(!s)return;let u=r.get(oe(s));return u?Ge(e,t,r,u,s.id):void 0},insertAt:(l,s)=>{let u=K(e.current(),t),f=ke(u,n).filter(w=>o.some(P=>P.type===oe(w))),c=lt(f.map(w=>w.pos),l),m=Vi(o,s.type),y=s.id??e.generator.nextTreeNodeId(t),N=e.generator.between(c.lower,c.upper),R={id:y,parent:n,pos:N,value:An(m,s)};return e.emit([{kind:\"tree.insert\",path:t,node:R}]),Ge(e,t,r,m,y)},insertLast:l=>{let s=ke(K(e.current(),t),n).filter(u=>o.some(f=>f.type===oe(u)));return d.insertAt(s.length,l)},insertAtPos:(l,s)=>{let u=Vi(o,s.type),f=s.id??e.generator.nextTreeNodeId(t),c={id:f,parent:n,pos:l,value:An(u,s)};return e.emit([{kind:\"tree.insert\",path:t,node:c}]),Ge(e,t,r,u,f)},find:l=>a().find((u,f,c)=>l(u,f,c)),findIndex:l=>a().findIndex((u,f,c)=>l(u,f,c)),map:l=>a().map((u,f,c)=>l(u,f,c)),filter:l=>a().filter((u,f,c)=>l(u,f,c)),some:l=>a().some((u,f,c)=>l(u,f,c)),every:l=>a().every((u,f,c)=>l(u,f,c)),forEach:l=>{a().forEach((u,f,c)=>l(u,f,c))},[Symbol.iterator]:()=>a()[Symbol.iterator]()};return d},Ge=(e,t,r,n,o)=>{let a=()=>Nn(K(e.current(),t),o),d=()=>{let l=a();return l?r.get(oe(l))??n:n};return{get id(){return o},get type(){return d().type},get pos(){return a()?.pos??\"\"},get parentId(){let l=a()?.parent;return l===I?null:l??null},get data(){return d().data.createProxy(e,kn(t,o))},get children(){return Ni(e,t,r,o,d().children)},get:()=>{let l=a();if(l)return ki(l,K(e.current(),t),r)},update:l=>{d().data.createProxy(e,kn(t,o)).update(l)},remove:()=>{e.emit([{kind:\"tree.delete\",path:t,id:o}])},moveTo:(l,s)=>{let u=l.__parentId;if(!u)throw new h(\"Target children collection is not movable\");let f=K(e.current(),t);if(_n(f,u,o))throw new h(\"Tree move would create a cycle\");let c=ke(f,u).filter(N=>N.id!==o),m=lt(c.map(N=>N.pos),s),y=e.generator.between(m.lower,m.upper);e.emit([{kind:\"tree.move\",path:t,id:o,parent:u,pos:y}])},moveToPos:(l,s)=>{let u=K(e.current(),t);if(_n(u,l,o))throw new h(\"Tree move would create a cycle\");e.emit([{kind:\"tree.move\",path:t,id:o,parent:l,pos:s}])},as:l=>{if(d().type!==l.type)throw new h(`Expected node type \"${l.type}\", got \"${d().type}\"`);return Ge(e,t,r,l,o)}}},Xd=e=>{let t=_r(e),r={};for(let n of t.values()){let o=n.data.schema;if(o.kind!==\"object\")throw new h(\"Tree node data must compile to an object schema\");r[n.type]={schema:{kind:\"object\",fields:{...o.fields,type:{kind:\"literal\",value:n.type}}},children:n.children.map(a=>a.type)}}return r},_r=e=>{let t=new Map,r=n=>{let o=t.get(n.type);if(o&&o!==n)throw new h(`Duplicate tree node type \"${n.type}\"`);if(!o){t.set(n.type,n);for(let a of n.children)r(a)}};return r(e),t},An=(e,t)=>{let r=e.data.encode(t);if(r.kind!==\"object\")throw new h(\"Tree node data must encode to an object\");return ne({...r.fields,type:re(e.type)})},oe=e=>{let t=e.value.fields.type;if(t?.kind!==\"string\")throw new h(`Tree node \"${e.id}\" is missing its type discriminator`);return t.value},_i=(e,t,r)=>ke(e,t).map(n=>ki(n,e,r)),ki=(e,t,r)=>{let n=r.get(oe(e));if(!n)throw new h(`Unknown tree node type \"${oe(e)}\"`);return{id:e.id,type:n.type,parentId:e.parent===I?null:e.parent,pos:e.pos,data:n.data.decode(e.value),children:_i(t,e.id,r)}},Vi=(e,t)=>{let r=e.find(n=>n.type===t);if(!r)throw new h(`Tree node type \"${t}\" is not allowed in this collection`);return r},Jd=e=>new kr({required:!1,defaultValue:void 0,encodedDefault:void 0,root:e.root}),Zd=e=>{let t=Ke();for(;e.has(t);)t=Ke();return t};var Ir=class{constructor(t){p(this,\"_tag\",\"LazyPrimitive\");p(this,\"_Input\");p(this,\"_Snapshot\");p(this,\"_Proxy\");p(this,\"thunk\");p(this,\"resolved\");this.thunk=t}get schema(){return this.resolve().schema}encode(t){return this.resolve().encode(t)}encodeOptional(t){return this.resolve().encodeOptional(t)}decode(t){return this.resolve().decode(t)}createProxy(t,r){return this.resolve().createProxy(t,r)}resolve(){return this.resolved===void 0&&(this.resolved=this.thunk()),this.resolved}},Qd=e=>new Ir(e);var Ii=i.Struct({type:i.Literal(\"click\").required()}),Ai=i.Union({click:Ii}),Ar=i.Union({boolean:i.Struct({key:i.Literal(\"boolean\").required(),value:i.Boolean().required()}),number:i.Struct({key:i.Literal(\"number\").required(),value:i.Number().required()}),string:i.Struct({key:i.Literal(\"string\").required(),value:i.String().required()}),product:i.Struct({key:i.Literal(\"product\").required(),value:i.Struct({productId:i.String()}).required()})},{discriminator:\"key\"}),Ri=i.Union({literal:i.Struct({type:i.Literal(\"literal\").required(),value:Ar.required()}),\"variable-reference\":i.Struct({type:i.Literal(\"variable-reference\").required(),value:i.Struct({id:i.String().required()}).required()})}),Ci=i.Union({literal:i.Struct({type:i.Literal(\"literal\").required(),productId:i.String().required()}),\"variable-reference\":i.Struct({type:i.Literal(\"variable-reference\").required(),variableId:i.String().required()})}),qi=i.Struct({type:i.Literal(\"none\").required()}),Ei=i.Struct({type:i.Literal(\"set-variable\").required(),payload:i.Struct({variableId:i.String().required(),newValue:Ri.required()}).required()}),Di=i.Struct({type:i.Literal(\"close-paywall\").required()}),Li=i.Struct({type:i.Literal(\"purchase-product\").required(),payload:Ci.required()}),Rr=i.Union({\"close-paywall\":Di,none:qi,\"purchase-product\":Li,\"set-variable\":Ei}),Oi=i.Struct({id:i.String().required(),trigger:Ai.required(),action:Rr.required()}),Ot=i.Array(Oi).default([]);var Mi=i.Struct({key:i.Literal(\"string\").required(),value:i.String().default(\"\")}),ji=i.Struct({key:i.Literal(\"number\").required(),value:i.Number().default(0)}),Bi=i.Struct({key:i.Literal(\"boolean\").required(),value:i.Boolean().default(!1)}),Hi=i.Struct({key:i.Literal(\"product\").required(),value:i.Struct({productId:i.String()}).default({})}),Cr=i.Union({boolean:Bi,number:ji,product:Hi,string:Mi},{discriminator:\"key\"}),Ui=i.Struct({id:i.String().required(),name:i.String().required(),value:Cr.required()}),qr=i.Struct({id:i.String().required()});var Er=i.Union({boolean:i.Struct({key:i.Literal(\"boolean\").required(),value:i.Boolean().required()}),\"boolean-array\":i.Struct({key:i.Literal(\"boolean-array\").required(),value:i.Array(i.Boolean()).required()}),number:i.Struct({key:i.Literal(\"number\").required(),value:i.Number().required()}),\"number-array\":i.Struct({key:i.Literal(\"number-array\").required(),value:i.Array(i.Number()).required()}),product:i.Struct({key:i.Literal(\"product\").required(),value:i.Struct({productId:i.String()}).required()}),string:i.Struct({key:i.Literal(\"string\").required(),value:i.String().required()}),\"string-array\":i.Struct({key:i.Literal(\"string-array\").required(),value:i.Array(i.String()).required()})},{discriminator:\"key\"}),Rn=i.Union({literal:i.Struct({type:i.Literal(\"literal\").required(),value:Er.required()}),\"variable-reference\":i.Struct({type:i.Literal(\"variable-reference\").required(),value:qr.required()})}),Fi=i.Union({\"action-payload\":i.Struct({type:i.Literal(\"action-payload\").required(),value:i.Struct({field:i.String().required()}).required()}),literal:i.Struct({type:i.Literal(\"literal\").required(),value:Ar.required()}),\"variable-reference\":i.Struct({type:i.Literal(\"variable-reference\").required(),value:i.Struct({id:i.String().required()}).required()})}),zi=i.Union({\"action-payload\":i.Struct({type:i.Literal(\"action-payload\").required(),field:i.String().required()}),literal:i.Struct({type:i.Literal(\"literal\").required(),productId:i.String().required()}),\"variable-reference\":i.Struct({type:i.Literal(\"variable-reference\").required(),variableId:i.String().required()})}),Cn=i.Union({\"close-paywall\":i.Struct({type:i.Literal(\"close-paywall\").required()}),none:i.Struct({type:i.Literal(\"none\").required()}),\"purchase-product\":i.Struct({type:i.Literal(\"purchase-product\").required(),payload:zi.required()}),\"set-variable\":i.Struct({type:i.Literal(\"set-variable\").required(),payload:i.Struct({variableId:i.String().required(),newValue:Fi.required()}).required()})});var U=i.Array(Ui).default([]),F=i.Array(i.Struct({name:i.String().required(),nodeId:i.String().required()})).default([]);var el=i.Struct({path:i.String().required(),source:i.String().default(\"\")}).required(),qn=i.TreeNode(\"codeComponent\",{children:[],data:el});var G=i.Union({literal:i.Struct({type:i.Literal(\"literal\").required(),value:Cr.required()}),\"variable-reference\":i.Struct({type:i.Literal(\"variable-reference\").required(),value:qr.required()})}),tl=i.Struct({type:i.Literal(\"equals\").required(),value:i.Struct({left:G.required(),right:G.required()}).required()}),rl=i.Struct({type:i.Literal(\"not-equals\").required(),value:i.Struct({left:G.required(),right:G.required()}).required()}),nl=i.Struct({type:i.Literal(\"greater-than\").required(),value:i.Struct({left:G.required(),right:G.required()}).required()}),ol=i.Struct({type:i.Literal(\"greater-than-or-equal\").required(),value:i.Struct({left:G.required(),right:G.required()}).required()}),il=i.Struct({type:i.Literal(\"less-than\").required(),value:i.Struct({left:G.required(),right:G.required()}).required()}),al=i.Struct({type:i.Literal(\"less-than-or-equal\").required(),value:i.Struct({left:G.required(),right:G.required()}).required()}),dl=i.Union({equals:tl,\"greater-than\":nl,\"greater-than-or-equal\":ol,\"less-than\":il,\"less-than-or-equal\":al,\"not-equals\":rl}),ll=i.Struct({type:i.Literal(\"and\").required(),value:i.Array(dl).minLength(1).required()}),$i=i.Struct({type:i.Literal(\"or\").required(),value:i.Array(ll).minLength(1).required()}),sl=i.Struct({action:Rr.required(),interactionId:i.String().required()});function Ie(e){return i.Struct({condition:$i.required(),id:i.String().required(),name:i.String().required(),overrides:i.Struct({style:e}).default({})})}function Dr(e){return i.Struct({condition:$i.required(),id:i.String().required(),name:i.String().required(),overrides:i.Struct({actions:i.Array(sl).default([]),style:e}).default({})})}var Ye=/^rgba\\(\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*,\\s*(\\d*\\.?\\d+)\\s*\\)$/i,st=i.Number().default(0),ut=i.Number().default(0),ct=i.Number().default(0),ft=i.Number().default(0),ie=i.Number().default(0),ae=i.Number().default(0),de=i.Number().default(0),le=i.Number().default(0),pt=i.Number().default(0),mt=i.Either(i.Literal(\"flex-start\"),i.Literal(\"center\"),i.Literal(\"flex-end\"),i.Literal(\"space-between\"),i.Literal(\"space-around\"),i.Literal(\"space-evenly\")).default(\"flex-start\"),yt=i.Either(i.Literal(\"flex-start\"),i.Literal(\"center\"),i.Literal(\"flex-end\"),i.Literal(\"stretch\"),i.Literal(\"baseline\")).default(\"stretch\"),ht=i.Either(i.Literal(\"row\"),i.Literal(\"column\")).default(\"column\"),se=i.Number(),ue=i.Number().default(0),ce=i.Number().default(1),fe=i.Either(i.Number(),i.Literal(\"auto\")).default(\"auto\"),pe=i.Either(i.Literal(\"auto\"),i.Literal(\"flex-start\"),i.Literal(\"center\"),i.Literal(\"flex-end\"),i.Literal(\"stretch\"),i.Literal(\"baseline\")).default(\"auto\"),gt=i.Either(i.Number(),i.Literal(\"auto\")).default(\"auto\"),bt=i.Either(i.Number(),i.Literal(\"auto\")).default(\"auto\"),me=i.Number(),ye=i.Number(),he=i.Number(),ge=i.Number(),Wi=i.Number().default(0),Ki=i.Number().default(0),vt=i.Either(i.Literal(\"absolute\"),i.Literal(\"relative\")).default(\"relative\"),St=i.Either(i.Number(),i.Literal(\"auto\")).default(\"auto\"),Tt=i.Either(i.Number(),i.Literal(\"auto\")).default(\"auto\"),xt=i.Either(i.Number(),i.Literal(\"auto\")).default(\"auto\"),wt=i.Either(i.Number(),i.Literal(\"auto\")).default(\"auto\"),Pt=i.String().default(\"rgba(255, 255, 255, 1)\").regex(Ye,\"Invalid RGBA color format\"),Vt=i.Boolean().default(!1),Nt=i.Either(i.Literal(\"solid\"),i.Literal(\"gradient\"),i.Literal(\"image\")).default(\"solid\"),_t=i.Struct({kind:i.Either(i.Literal(\"linear\"),i.Literal(\"radial\")).default(\"linear\"),startX:i.Number().default(.5),startY:i.Number().default(0),endX:i.Number().default(.5),endY:i.Number().default(1),stops:i.Array(i.Struct({color:i.String().default(\"rgba(255, 255, 255, 1)\").regex(Ye,\"Invalid RGBA color format\"),position:i.Number().default(0)})).default([{color:\"rgba(255, 255, 255, 1)\",position:0},{color:\"rgba(255, 255, 255, 0)\",position:1}])}).default({}),Te=i.Struct({url:i.String().default(\"\"),resizeMode:i.Either(i.Literal(\"cover\"),i.Literal(\"contain\"),i.Literal(\"stretch\"),i.Literal(\"center\")).default(\"cover\")}).default({}),Ae=i.Number().default(0),Re=i.Number().default(0),Ce=i.Number().default(0),qe=i.Number().default(0),Ee=i.String().default(\"rgba(0, 0, 0, 1)\").regex(Ye,\"Invalid RGBA color format\"),De=i.Either(i.Literal(\"solid\"),i.Literal(\"dashed\"),i.Literal(\"dotted\")).default(\"solid\"),Lr=i.Number().default(0),Or=i.Number().default(0),Mr=i.Number().default(0),jr=i.Number().default(0),Le=i.Boolean().default(!1),Y=i.Number().default(1),Oe=i.Either(i.Literal(\"visible\"),i.Literal(\"hidden\"),i.Literal(\"scroll\")).default(\"visible\"),Me=i.Number().default(0),X=i.Either(i.Literal(\"flex\"),i.Literal(\"none\")).default(\"flex\"),je=i.Boolean().default(!1),Be=i.String().default(\"rgba(0, 0, 0, 1)\").regex(Ye,\"Invalid RGBA color format\"),He=i.Number().default(0),Ue=i.Number().default(0),Fe=i.Number().default(0),ze=i.Number().default(1),Gi=i.Number().default(16).min(1),Yi=i.Either(i.Literal(\"100\"),i.Literal(\"200\"),i.Literal(\"300\"),i.Literal(\"400\"),i.Literal(\"500\"),i.Literal(\"600\"),i.Literal(\"700\"),i.Literal(\"800\"),i.Literal(\"900\")).default(\"400\"),Xi=i.String().default(\"rgba(0, 0, 0, 1)\").regex(Ye,\"Invalid RGBA color format\"),Ji=i.Either(i.Literal(\"left\"),i.Literal(\"center\"),i.Literal(\"right\"),i.Literal(\"justify\")).default(\"left\"),Zi=i.Number().default(1.5),Qi=i.Number().default(0),kt=i.Boolean().default(!1),It=i.Boolean().default(!1),ea=i.String().default(\"rgba(255, 255, 255, 1)\").regex(Ye,\"Invalid RGBA color format\"),ta=i.Boolean().default(!1),ra=i.Either(i.Literal(\"nonzero\"),i.Literal(\"evenodd\")).default(\"nonzero\"),na=i.Number().default(1),oa=i.String().default(\"rgba(0, 0, 0, 1)\").regex(Ye,\"Invalid RGBA color format\"),ia=i.Boolean().default(!1),aa=i.Number().default(0),da=i.Number().default(1),la=i.Either(i.Literal(\"butt\"),i.Literal(\"round\"),i.Literal(\"square\")).default(\"butt\"),sa=i.Either(i.Literal(\"miter\"),i.Literal(\"round\"),i.Literal(\"bevel\")).default(\"miter\");var En=i.Struct({fillColor:ea,fillEnabled:ta,fillRule:ra,fillOpacity:na,strokeColor:oa,strokeEnabled:ia,strokeWidth:aa,strokeOpacity:da,strokeLinecap:la,strokeLinejoin:sa,opacity:Y,display:X}).default({}),ul=Ie(En.partial({stripDefaults:!0}).default({})),cl=i.Array(ul).default([]),Dn=i.TreeNode(\"path\",{children:[],data:i.Struct({linkedVariables:F,localVariables:U,name:i.String().default(\"Path\"),states:cl,d:i.String().default(\"\"),transform:i.String(),style:En}).required()});var fl=i.Either(i.Literal(\"none\"),i.Literal(\"xMidYMid meet\"),i.Literal(\"xMidYMid slice\")).default(\"xMidYMid meet\"),pl=i.Struct({minX:i.Number().default(0),minY:i.Number().default(0),width:i.Number().default(24),height:i.Number().default(24)}).default({}),Ln=i.Struct({width:gt,height:bt,minWidth:me,maxWidth:ye,minHeight:he,maxHeight:ge,marginTop:ie,marginRight:ae,marginBottom:de,marginLeft:le,opacity:Y,display:X,flex:se,flexGrow:ue,flexShrink:ce,flexBasis:fe,alignSelf:pe.default(\"auto\"),preserveAspectRatio:fl}).default({}),ml=Ie(Ln.partial({stripDefaults:!0}).default({})),yl=i.Array(ml).default([]),xe=i.TreeNode(\"shape\",{children:[Dn],data:i.Struct({linkedVariables:F,localVariables:U,name:i.String().default(\"Shape\"),states:yl,svgSource:i.String(),viewBox:pl,style:Ln}).required()});var On=i.Struct({marginTop:ie,marginRight:ae,marginBottom:de,marginLeft:le,minWidth:me,maxWidth:ye,minHeight:he,maxHeight:ge,fontSize:Gi,fontWeight:Yi,color:Xi,textAlign:Ji,lineHeight:Zi,letterSpacing:Qi,borderTopWidth:Ae,borderRightWidth:Re,borderBottomWidth:Ce,borderLeftWidth:qe,borderColor:Ee,borderStyle:De,borderEnabled:Le,opacity:Y,overflow:Oe,zIndex:Me,position:vt,left:St,top:Tt,right:xt,bottom:wt,display:X,shadowEnabled:je,shadowColor:Be,shadowOffsetX:He,shadowOffsetY:Ue,shadowRadius:Fe,shadowOpacity:ze,flex:se,flexGrow:ue,flexShrink:ce,flexBasis:fe,alignSelf:pe}).default({}),hl=Ie(On.partial({stripDefaults:!0}).default({})),gl=i.Array(hl).default([]),bl=i.Array(i.Struct({locale:i.String().required(),overrides:i.Struct({text:i.String()}).default({})})).default([]),we=i.TreeNode(\"text\",{children:[],data:i.Struct({linkedVariables:F,localVariables:U,localized:bl,name:i.String().default(\"Text\"),states:gl,style:On,text:i.String().default(\"New Text\")}).required()});var Mn=i.Struct({paddingTop:st,paddingRight:ut,paddingBottom:ct,paddingLeft:ft,marginTop:ie,marginRight:ae,marginBottom:de,marginLeft:le,gap:pt,justifyContent:mt,alignItems:yt,flexDirection:ht,width:gt,height:bt,minWidth:me,maxWidth:ye,minHeight:he,maxHeight:ge,backgroundColor:Pt,backgroundEnabled:Vt,backgroundType:Nt,backgroundGradient:_t,backgroundImage:Te,borderTopWidth:Ae,borderRightWidth:Re,borderBottomWidth:Ce,borderLeftWidth:qe,borderColor:Ee,borderStyle:De,borderEnabled:Le,borderTopLeftRadius:Lr,borderTopRightRadius:Or,borderBottomRightRadius:Mr,borderBottomLeftRadius:jr,opacity:Y,overflow:Oe,zIndex:Me,position:vt,left:St,top:Tt,right:xt,bottom:wt,display:X,shadowEnabled:je,shadowColor:Be,shadowOffsetX:He,shadowOffsetY:Ue,shadowRadius:Fe,shadowOpacity:ze,safeAreaTop:kt,safeAreaBottom:It,flex:se,flexGrow:ue,flexShrink:ce,flexBasis:fe,alignSelf:pe.default(\"auto\")}).default({}),vl=Dr(Mn.partial({stripDefaults:!0}).default({})),Sl=i.Array(vl).default([]),Tl=i.Array(i.Struct({locale:i.String().required(),overrides:i.Struct({backgroundImage:Te}).partial({stripDefaults:!0}).default({})})).default([]),Xe=i.TreeNode(\"view\",{children:()=>[i.TreeNodeSelf,Je,we,xe,Ze],data:i.Struct({interactions:Ot,linkedVariables:F,localVariables:U,localized:Tl,name:i.String().default(\"View\"),states:Sl,style:Mn}).required()});var jn=i.Struct({paddingTop:st,paddingRight:ut,paddingBottom:ct,paddingLeft:ft,marginTop:ie,marginRight:ae,marginBottom:de,marginLeft:le,gap:pt,justifyContent:mt,alignItems:yt,flexDirection:ht,width:gt,height:bt,minWidth:me,maxWidth:ye,minHeight:he,maxHeight:ge,backgroundColor:Pt,backgroundEnabled:Vt,backgroundType:Nt,backgroundGradient:_t,backgroundImage:Te,borderTopWidth:Ae,borderRightWidth:Re,borderBottomWidth:Ce,borderLeftWidth:qe,borderColor:Ee,borderStyle:De,borderEnabled:Le,borderTopLeftRadius:Lr,borderTopRightRadius:Or,borderBottomRightRadius:Mr,borderBottomLeftRadius:jr,opacity:Y,overflow:Oe,zIndex:Me,position:vt,left:St,top:Tt,right:xt,bottom:wt,display:X,shadowEnabled:je,shadowColor:Be,shadowOffsetX:He,shadowOffsetY:Ue,shadowRadius:Fe,shadowOpacity:ze,safeAreaTop:kt,safeAreaBottom:It,flex:se,flexGrow:ue,flexShrink:ce,flexBasis:fe,alignSelf:pe.default(\"auto\")}).default({}),xl=Dr(jn.partial({stripDefaults:!0}).default({})),wl=i.Array(xl).default([]),Pl=i.Array(i.Struct({locale:i.String().required(),overrides:i.Struct({backgroundImage:Te}).partial({stripDefaults:!0}).default({})})).default([]),Vl=i.Struct({horizontal:i.Boolean().default(!1),interactions:Ot,linkedVariables:F,localVariables:U,localized:Pl,name:i.String().default(\"ScrollView\"),showsScrollIndicator:i.Boolean().default(!0),states:wl,style:jn}).required(),Je=i.TreeNode(\"scrollView\",{children:()=>[Xe,i.TreeNodeSelf,we,xe,Ze],data:Vl});var Nl=i.Struct({actionBindings:i.Array(i.Struct({action:Cn.required(),name:i.String().required()})).default([]),componentSource:i.String().default(\"catalog\"),componentPath:i.String().default(\"\"),componentSlug:i.String().default(\"\"),componentVersion:i.Number().default(0),contentHash:i.String().default(\"\"),name:i.String().default(\"Component\"),previewState:i.String().default(\"default\"),props:i.Array(i.Struct({name:i.String().required(),value:Rn.required(),localizedValues:i.Array(i.Struct({locale:i.String().required(),value:Er.required()})).default([])})).default([])}).required(),Ze=i.TreeNode(\"component\",{children:()=>[Xe,Je,we,xe,i.TreeNodeSelf],data:Nl});var Bn=i.TreeNode(\"library\",{children:()=>[qn],data:i.Struct({}).required()});var Hn=i.Struct({x:Wi,y:Ki,width:i.Number().default(375),height:i.Number().default(812),minWidth:me,maxWidth:ye,minHeight:he,maxHeight:ge,paddingTop:st,paddingRight:ut,paddingBottom:ct,paddingLeft:ft,marginTop:ie,marginRight:ae,marginBottom:de,marginLeft:le,gap:pt,justifyContent:mt,alignItems:yt,flexDirection:ht,backgroundColor:Pt.default(\"rgba(255, 255, 255, 1)\"),backgroundEnabled:Vt.default(!0),backgroundType:Nt,backgroundGradient:_t,backgroundImage:Te,borderTopWidth:Ae,borderRightWidth:Re,borderBottomWidth:Ce,borderLeftWidth:qe,borderColor:Ee,borderStyle:De,borderEnabled:Le,opacity:Y,overflow:Oe,zIndex:Me,display:X,shadowEnabled:je,shadowColor:Be,shadowOffsetX:He,shadowOffsetY:Ue,shadowRadius:Fe,shadowOpacity:ze,safeAreaTop:kt,safeAreaBottom:It,flex:se,flexGrow:ue,flexShrink:ce,flexBasis:fe,alignSelf:pe}).default({}),_l=Ie(Hn.partial({stripDefaults:!0}).default({})),kl=i.Array(_l).default([]),Il=i.Array(i.Struct({locale:i.String().required(),overrides:i.Struct({backgroundImage:Te}).partial({stripDefaults:!0}).default({})})).default([]),Un=i.TreeNode(\"screen\",{children:()=>[Xe,Je,we,xe,Ze],data:i.Struct({linkedVariables:F,localVariables:U,localized:Il,name:i.String().default(\"Screen\"),states:kl,style:Hn}).required()});var Al=i.Struct({defaultLocale:i.String().default(\"en\"),locales:i.Array(i.Struct({tag:i.String().required()})).default([])}).default({}),Fn=i.TreeNode(\"root\",{children:()=>[Un,Bn],data:i.Struct({localization:Al,name:i.String()}).required()});var My=i.Tree({root:Fn});function Br(e){return e!==null&&typeof e==\"object\"&&\"id\"in e&&\"pos\"in e&&\"value\"in e?e.value:e}function zn(e){let t=e.indexOf(\"-\");return t===-1?e:e.slice(0,t)}function ua(e,t,r){let n=e.text,o=e.localized;if(t==null||t===r||o===void 0||o.length===0)return n;let a=t.toLowerCase(),d=zn(a),l;for(let s of o){let u=Br(s);if(u===void 0)continue;let f=u.overrides?.text;if(f===void 0||f===\"\")continue;let c=u.locale.toLowerCase();if(c===a)return f;l===void 0&&c===d&&(l=f)}return l??n}function ca(e,t,r){let n=e.style.backgroundImage,o=e.localized;if(t==null||t===r||o===void 0||o.length===0)return n;let a=t.toLowerCase(),d=zn(a),l;for(let s of o){let u=Br(s);if(u===void 0)continue;let f=u.overrides?.backgroundImage;if(f===void 0)continue;let c=u.locale.toLowerCase();if(c===a)return f;l===void 0&&c===d&&(l=f)}return l??n}var eh=i.Struct({cursor:i.Struct({x:i.Number().required(),y:i.Number().required()}),name:i.String(),selectedNodeIds:i.Array(i.String()).default([]),user:i.Struct({color:i.String().required(),name:i.String().required()}).required()});function At(e){let{locale:t,defaultLocale:r}=te();return ca(e,t,r)}function fa({node:e,children:t}){let r=$(e.id,e.data.style,e.data.states),n=At(e.data),o=Xt(n===e.data.style.backgroundImage?r:{...r,backgroundImage:n}),a=Jt(r);return b(\"div\",{\"data-node-id\":e.id,style:o,children:b(\"div\",{style:a,children:t})})}function Hr(e,t,r){let{getNodeVariables:n,setNodeVariable:o,callbacks:a}=te(),d=n(e),l=j(()=>({...a,onSetVariable:(f,c)=>{o(e,f,c)}}),[a,e,o]),s=j(()=>t.filter(f=>f.value?.trigger?.type===\"click\"),[t]),u=ee(()=>{for(let f of s){let c=f.value;if(!c?.action)continue;let m=f.id??c.id;if(!m)continue;let N=en(m,r,d)??c.action;tn(N,d,l)}},[s,r,d,l]);if(s.length!==0)return u}function pa({node:e,children:t}){let r=$(e.id,e.data.style,e.data.states),n=Hr(e.id,e.data.interactions,e.data.states),o=At(e.data),a=Gt(o===e.data.style.backgroundImage?r:{...r,backgroundImage:o},{horizontal:e.data.horizontal,showsScrollIndicator:e.data.showsScrollIndicator});return b(\"div\",{\"data-node-id\":e.id,onClick:n,style:{...a,...n?{cursor:\"pointer\"}:{}},children:t})}function ma({node:e,children:t}){let r=$(e.id,e.data.style,e.data.states),n=Zt(r),o=e.data.viewBox,a=`${o.minX} ${o.minY} ${o.width} ${o.height}`;return b(\"div\",{\"data-node-id\":e.id,style:n,children:b(\"svg\",{height:\"100%\",preserveAspectRatio:r.preserveAspectRatio,style:{display:\"block\"},viewBox:a,width:\"100%\",children:t})})}function ya({node:e}){let t=$(e.id,e.data.style,e.data.states),r=Qt(t),{locale:n,defaultLocale:o}=te();return b(\"span\",{\"data-node-id\":e.id,style:r,children:ua(e.data,n,o)})}function ha({node:e,children:t}){let r=$(e.id,e.data.style,e.data.states),n=Hr(e.id,e.data.interactions,e.data.states),o=At(e.data),a=nt(o===e.data.style.backgroundImage?r:{...r,backgroundImage:o});return b(\"div\",{\"data-node-id\":e.id,onClick:n,style:{...a,...n?{cursor:\"pointer\"}:{}},children:t})}function ga({snapshot:e,componentArtifacts:t,locale:r}){return b(qo,{componentArtifacts:t,locale:r,snapshot:e,children:b(ba,{node:e})})}var Rl={alignItems:\"center\",backgroundColor:\"#f4f4f5\",border:\"1px dashed #d4d4d8\",borderRadius:\"6px\",boxSizing:\"border-box\",color:\"#71717a\",display:\"flex\",fontSize:\"11px\",justifyContent:\"center\",minHeight:\"40px\",padding:\"8px\",textAlign:\"center\"};function Cl({node:e}){return b(\"div\",{style:Rl,children:`Unsupported node type: ${String(e.type)}`})}function ba({node:e}){let t=(e.children??[]).map(r=>b(ba,{node:r},r.id));switch(e.type){case\"root\":return b(Q,{children:t});case\"screen\":return b(fa,{node:e,children:t});case\"view\":return b(ha,{node:e,children:t});case\"scrollView\":return b(pa,{node:e,children:t});case\"text\":return b(ya,{node:e});case\"shape\":return b(ma,{node:e,children:t});case\"path\":return b(Mo,{node:e});case\"component\":return b(Oo,{node:e,children:t});case\"library\":case\"codeComponent\":return null;default:return b(Cl,{node:e})}}var ql=\"__VOIDHASH_PAYWALL__\",va={products:[],variables:{}},El=e=>({products:e?.products??[],variables:e?.variables??{},locale:e?.locale,platform:e?.platform,defaultSelectedProductId:e?.defaultSelectedProductId}),Sa=()=>{if(typeof window>\"u\")return va;let e=window[ql];return e?El(e):va};function Ta(e){return Sa().locale??e}function Dl(e){let t;try{t=JSON.parse(e)}catch{return}if(!(typeof t!=\"object\"||t===null)){if(\"type\"in t)return{componentArtifacts:void 0,locale:void 0,snapshot:t};if(\"snapshot\"in t){let r=t;return{componentArtifacts:r.componentArtifacts,locale:r.locale,snapshot:r.snapshot}}}}function xa(){let e=document.getElementById(\"__PAYWALL_DATA__\");if(!e?.textContent)return;let t=document.getElementById(\"paywall-root\");if(!t)return;let r=Dl(e.textContent);r&&ao(b(ga,{componentArtifacts:r.componentArtifacts,locale:Ta(r.locale),snapshot:r.snapshot}),t)}document.readyState===\"loading\"?document.addEventListener(\"DOMContentLoaded\",xa):xa();})();\n"; diff --git a/packages/paywall-renderer-preact/src/templates/runtime-bundle.ts b/packages/paywall-renderer-preact/src/templates/runtime-bundle.ts new file mode 100644 index 000000000..1bcdeb8a4 --- /dev/null +++ b/packages/paywall-renderer-preact/src/templates/runtime-bundle.ts @@ -0,0 +1,21 @@ +/** + * Client-side hydration runtime bundle. + * + * The paywall runtime Vite plugin (exported from + * `@voidhash/paywall-renderer-preact/vite-plugin`) replaces this module + * at build time with the esbuild-bundled IIFE of `src/runtime/hydrate.tsx`, + * while non-Vite server runtimes use the generated fallback checked by the + * package test command. + */ + +import { PAYWALL_RUNTIME_BUNDLE } from "./runtime-bundle.generated.ts"; + +/** + * Returns the bundled hydration runtime. + * + * Returns the current generated fallback when a consuming pipeline does not + * run the Vite transform. + */ +export function getRuntimeBundle(): string { + return PAYWALL_RUNTIME_BUNDLE; +} diff --git a/packages/paywall-renderer-preact/src/templates/runtime-script.ts b/packages/paywall-renderer-preact/src/templates/runtime-script.ts new file mode 100644 index 000000000..b23aa302d --- /dev/null +++ b/packages/paywall-renderer-preact/src/templates/runtime-script.ts @@ -0,0 +1,21 @@ +/** + * Generates the client-side runtime script for paywall hydration. + * + * This module returns the Preact hydration runtime that is embedded in the + * HTML output. The bundle is compiled from src/runtime/hydrate.tsx at build + * time by the paywall runtime Vite plugin (see ../vite-plugin.ts) and + * includes all necessary components and style builders. + */ + +import { getRuntimeBundle } from "./runtime-bundle"; + +/** + * Returns the complete runtime script for client-side hydration. + * + * The script: + * 1. Reads the serialized paywall data from __PAYWALL_DATA__ script tag + * 2. Hydrates the pre-rendered HTML with interactive Preact components + */ +export function generateRuntimeScript(): string { + return getRuntimeBundle(); +} diff --git a/packages/paywall-renderer-preact/src/vite-plugin.ts b/packages/paywall-renderer-preact/src/vite-plugin.ts new file mode 100644 index 000000000..6f0842a0e --- /dev/null +++ b/packages/paywall-renderer-preact/src/vite-plugin.ts @@ -0,0 +1,80 @@ +/** + * Vite plugin that compiles the paywall hydration runtime at build time. + * + * Replaces the `src/templates/runtime-bundle.ts` placeholder module with an + * esbuild-bundled IIFE of `src/runtime/hydrate.tsx`, so the runtime embedded + * in paywall HTML is always built from current source — including the style + * builders in `@voidhash/paywall-renderer-web-core`. Every source file + * that feeds the bundle is registered as a watch file, so edits invalidate + * the module in dev/watch mode. + * + * Every pipeline that evaluates `renderPaywallToHtml` with hydration (app + * builds, dev servers, vitest) must register this plugin; without it the + * placeholder throws instead of hydrating with a stale runtime. + */ + +import * as esbuild from "esbuild"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Plugin } from "vite"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const entryPoint = path.join(packageRoot, "src/runtime/hydrate.tsx"); +const placeholderModule = path.join(packageRoot, "src/templates/runtime-bundle.ts"); + +interface RuntimeBundle { + code: string; + watchFiles: readonly string[]; +} + +async function buildRuntimeBundle(): Promise { + const result = await esbuild.build({ + absWorkingDir: packageRoot, + entryPoints: [entryPoint], + bundle: true, + format: "iife", + minify: true, + target: "es2020", + write: false, + metafile: true, + jsx: "automatic", + jsxImportSource: "preact", + sourcemap: false, + treeShaking: true, + define: { + "process.env.NODE_ENV": '"production"', + }, + }); + + const bundle = result.outputFiles[0].text; + const watchFiles = Object.keys(result.metafile.inputs) + .filter((input) => !input.includes("node_modules")) + .map((input) => path.resolve(packageRoot, input)); + + return { + code: `export function getRuntimeBundle() {\n return ${JSON.stringify(bundle)};\n}\n`, + watchFiles, + }; +} + +/** + * Creates the plugin instance. Register it in the Vite config of every + * pipeline that consumes `@voidhash/paywall-renderer-preact`. + */ +export function paywallRuntimeBundlePlugin(): Plugin { + return { + name: "paywall-runtime-bundle", + enforce: "pre", + async transform(_code, id) { + const [file] = id.split("?"); + if (path.normalize(file) !== placeholderModule) { + return null; + } + const { code, watchFiles } = await buildRuntimeBundle(); + for (const watchFile of watchFiles) { + this.addWatchFile(watchFile); + } + return { code, map: null }; + }, + }; +} diff --git a/packages/paywall-renderer-preact/sst-env.d.ts b/packages/paywall-renderer-preact/sst-env.d.ts new file mode 100644 index 000000000..eec65b9bd --- /dev/null +++ b/packages/paywall-renderer-preact/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst"; +export {}; diff --git a/packages/paywall-renderer-preact/tsconfig.json b/packages/paywall-renderer-preact/tsconfig.json new file mode 100644 index 000000000..5fceee333 --- /dev/null +++ b/packages/paywall-renderer-preact/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@voidhash/tsconfig/typescript-6.json", + "include": ["src"], + "exclude": ["**/node_modules/**"], + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact" + } +} diff --git a/packages/paywall-renderer-preact/vite.config.ts b/packages/paywall-renderer-preact/vite.config.ts new file mode 100644 index 000000000..4cd4e1351 --- /dev/null +++ b/packages/paywall-renderer-preact/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; + +import { paywallRuntimeBundlePlugin } from "./src/vite-plugin"; + +export default defineConfig({ + plugins: [paywallRuntimeBundlePlugin()], +}); diff --git a/packages/paywall-renderer-web-core/LICENSE.md b/packages/paywall-renderer-web-core/LICENSE.md new file mode 100644 index 000000000..be3f7b28e --- /dev/null +++ b/packages/paywall-renderer-web-core/LICENSE.md @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/paywall-renderer-web-core/README.md b/packages/paywall-renderer-web-core/README.md new file mode 100644 index 000000000..4576431cd --- /dev/null +++ b/packages/paywall-renderer-web-core/README.md @@ -0,0 +1,13 @@ +# @voidhash/paywall-renderer-web-core + +Platform-neutral snapshot evaluation, action resolution, variable handling, preview-tree contracts, and CSS style builders shared by Voidhash paywall renderers. + +The package contains no UI runtime. Renderer packages consume its typed snapshot model and deterministic helpers to produce platform-specific output. + +```typescript +import { + buildTextStyles, + resolveStyle, + type SnapshotNode, +} from "@voidhash/paywall-renderer-web-core"; +``` diff --git a/packages/paywall-renderer-web-core/package.json b/packages/paywall-renderer-web-core/package.json new file mode 100644 index 000000000..587582904 --- /dev/null +++ b/packages/paywall-renderer-web-core/package.json @@ -0,0 +1,42 @@ +{ + "name": "@voidhash/paywall-renderer-web-core", + "version": "0.0.1-alpha.1", + "private": true, + "description": "Platform-neutral snapshot evaluation and web-style utilities for Voidhash paywall renderers.", + "keywords": [ + "paywalls", + "renderer", + "voidhash" + ], + "homepage": "https://voidhash.com/docs", + "bugs": { + "url": "https://github.com/voidhashcom/voidhash/issues" + }, + "license": "AGPL-3.0-only", + "author": "Voidhash (https://voidhash.com)", + "repository": { + "type": "git", + "url": "https://github.com/voidhashcom/voidhash", + "directory": "packages/paywall-renderer-web-core" + }, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "typecheck-go": "tsgo --noEmit", + "test": "vp test run" + }, + "dependencies": { + "@voidhash/mimic-schema": "workspace:*", + "@voidhash/paywalls": "workspace:*", + "csstype": "^3.1.3" + }, + "devDependencies": { + "@voidhash/tsconfig": "workspace:*", + "typescript": "6.0.3", + "vite-plus": "0.1.23", + "vitest": "^3.2.7" + } +} diff --git a/packages/paywall-renderer-web-core/src/actions.test.ts b/packages/paywall-renderer-web-core/src/actions.test.ts new file mode 100644 index 000000000..d43d29c59 --- /dev/null +++ b/packages/paywall-renderer-web-core/src/actions.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, test, vi } from "vite-plus/test"; + +import { type ActionCallbacks, executeAction, executeComponentBoundAction } from "./actions"; +import type { Action, ComponentBoundAction } from "./snapshot-types"; +import type { VariableStore } from "./variables"; + +function makeCallbacks(): ActionCallbacks & { + closePaywall: ReturnType; + purchaseProduct: ReturnType; + setVariable: ReturnType; +} { + const closePaywall = vi.fn(); + const purchaseProduct = vi.fn(); + const setVariable = vi.fn(); + return { + onClosePaywall: closePaywall, + onPurchaseProduct: purchaseProduct, + onSetVariable: setVariable, + closePaywall, + purchaseProduct, + setVariable, + }; +} + +describe("executeAction", () => { + const emptyVars: VariableStore = new Map(); + + test("none action does nothing", () => { + const cbs = makeCallbacks(); + const action: Action = { type: "none" }; + executeAction(action, emptyVars, cbs); + expect(cbs.closePaywall).not.toHaveBeenCalled(); + expect(cbs.purchaseProduct).not.toHaveBeenCalled(); + expect(cbs.setVariable).not.toHaveBeenCalled(); + }); + + test("close-paywall calls onClosePaywall", () => { + const cbs = makeCallbacks(); + const action: Action = { type: "close-paywall" }; + executeAction(action, emptyVars, cbs); + expect(cbs.closePaywall).toHaveBeenCalledOnce(); + }); + + test("set-variable with literal value", () => { + const cbs = makeCallbacks(); + const action: Action = { + type: "set-variable", + payload: { + variableId: "var-1", + newValue: { + type: "literal", + value: { key: "boolean", value: true }, + }, + }, + }; + executeAction(action, emptyVars, cbs); + expect(cbs.setVariable).toHaveBeenCalledWith("var-1", { + key: "boolean", + value: true, + }); + }); + + test("set-variable with variable reference", () => { + const cbs = makeCallbacks(); + const variables: VariableStore = new Map([["source-var", { key: "number", value: 42 }]]); + const action: Action = { + type: "set-variable", + payload: { + variableId: "target-var", + newValue: { + type: "variable-reference", + value: { id: "source-var" }, + }, + }, + }; + executeAction(action, variables, cbs); + expect(cbs.setVariable).toHaveBeenCalledWith("target-var", { + key: "number", + value: 42, + }); + }); + + test("set-variable with missing reference does nothing", () => { + const cbs = makeCallbacks(); + const action: Action = { + type: "set-variable", + payload: { + variableId: "target-var", + newValue: { + type: "variable-reference", + value: { id: "missing" }, + }, + }, + }; + executeAction(action, emptyVars, cbs); + expect(cbs.setVariable).not.toHaveBeenCalled(); + }); + + test("purchase-product with literal product id", () => { + const cbs = makeCallbacks(); + const action: Action = { + type: "purchase-product", + payload: { + type: "literal", + productId: "product-123", + }, + }; + executeAction(action, emptyVars, cbs); + expect(cbs.purchaseProduct).toHaveBeenCalledWith("product-123"); + }); + + test("purchase-product with variable reference", () => { + const cbs = makeCallbacks(); + const variables: VariableStore = new Map([ + ["product-var", { key: "product", value: { productId: "product-456" } }], + ]); + const action: Action = { + type: "purchase-product", + payload: { + type: "variable-reference", + variableId: "product-var", + }, + }; + executeAction(action, variables, cbs); + expect(cbs.purchaseProduct).toHaveBeenCalledWith("product-456"); + }); + + test("purchase-product with missing variable reference does nothing", () => { + const cbs = makeCallbacks(); + const action: Action = { + type: "purchase-product", + payload: { + type: "variable-reference", + variableId: "missing", + }, + }; + executeAction(action, emptyVars, cbs); + expect(cbs.purchaseProduct).not.toHaveBeenCalled(); + }); + + test("purchase-product with an unset product variable (absent productId) does nothing", () => { + const cbs = makeCallbacks(); + const variables: VariableStore = new Map([["product-var", { key: "product", value: {} }]]); + const action: Action = { + type: "purchase-product", + payload: { + type: "variable-reference", + variableId: "product-var", + }, + }; + executeAction(action, variables, cbs); + expect(cbs.purchaseProduct).not.toHaveBeenCalled(); + }); + + test("purchase-product with a literal source without productId does nothing", () => { + const cbs = makeCallbacks(); + const legacyAction = { + type: "purchase-product", + payload: { type: "literal" }, + } as unknown as Action; + executeAction(legacyAction, emptyVars, cbs); + expect(cbs.purchaseProduct).not.toHaveBeenCalled(); + }); +}); + +describe("executeComponentBoundAction", () => { + const emptyVars: VariableStore = new Map(); + + function setVariableAction( + newValue: NonNullable< + Extract["payload"] + >["newValue"], + variableId = "target-var", + ): ComponentBoundAction { + return { type: "set-variable", payload: { newValue, variableId } }; + } + + test("none action does nothing", () => { + const cbs = makeCallbacks(); + executeComponentBoundAction({ type: "none" }, { productId: "p" }, emptyVars, cbs); + expect(cbs.closePaywall).not.toHaveBeenCalled(); + expect(cbs.purchaseProduct).not.toHaveBeenCalled(); + expect(cbs.setVariable).not.toHaveBeenCalled(); + }); + + test("close-paywall calls onClosePaywall", () => { + const cbs = makeCallbacks(); + executeComponentBoundAction({ type: "close-paywall" }, undefined, emptyVars, cbs); + expect(cbs.closePaywall).toHaveBeenCalledOnce(); + }); + + test("set-variable with literal value behaves like executeAction", () => { + const cbs = makeCallbacks(); + const action = setVariableAction({ + type: "literal", + value: { key: "boolean", value: true }, + }); + executeComponentBoundAction(action, undefined, emptyVars, cbs); + expect(cbs.setVariable).toHaveBeenCalledWith("target-var", { key: "boolean", value: true }); + }); + + test("set-variable with variable reference resolves through the reader", () => { + const cbs = makeCallbacks(); + const variables: VariableStore = new Map([["source-var", { key: "number", value: 42 }]]); + const action = setVariableAction({ type: "variable-reference", value: { id: "source-var" } }); + executeComponentBoundAction(action, undefined, variables, cbs); + expect(cbs.setVariable).toHaveBeenCalledWith("target-var", { key: "number", value: 42 }); + }); + + test("set-variable with missing variable reference does nothing", () => { + const cbs = makeCallbacks(); + const action = setVariableAction({ type: "variable-reference", value: { id: "missing" } }); + executeComponentBoundAction(action, undefined, emptyVars, cbs); + expect(cbs.setVariable).not.toHaveBeenCalled(); + }); + + test("set-variable from payload field matching the target scalar type", () => { + const cbs = makeCallbacks(); + const variables: VariableStore = new Map([["target-var", { key: "string", value: "old" }]]); + const action = setVariableAction({ type: "action-payload", value: { field: "label" } }); + executeComponentBoundAction(action, { label: "new" }, variables, cbs); + expect(cbs.setVariable).toHaveBeenCalledWith("target-var", { key: "string", value: "new" }); + }); + + test("set-variable from payload field coerces scalars into a product variable", () => { + const cbs = makeCallbacks(); + // Absent productId is the unset state (the former null). + const variables: VariableStore = new Map([["target-var", { key: "product", value: {} }]]); + const action = setVariableAction({ type: "action-payload", value: { field: "productId" } }); + executeComponentBoundAction(action, { productId: "yearly" }, variables, cbs); + expect(cbs.setVariable).toHaveBeenCalledWith("target-var", { + key: "product", + value: { productId: "yearly" }, + }); + executeComponentBoundAction(action, { productId: 42 }, variables, cbs); + expect(cbs.setVariable).toHaveBeenCalledWith("target-var", { + key: "product", + value: { productId: "42" }, + }); + executeComponentBoundAction(action, { productId: true }, variables, cbs); + expect(cbs.setVariable).toHaveBeenCalledWith("target-var", { + key: "product", + value: { productId: "true" }, + }); + executeComponentBoundAction(action, { productId: { nested: true } }, variables, cbs); + executeComponentBoundAction(action, {}, variables, cbs); + expect(cbs.setVariable).toHaveBeenCalledTimes(3); + }); + + test("set-variable from payload field skips on type mismatch", () => { + const cbs = makeCallbacks(); + const variables: VariableStore = new Map([["target-var", { key: "number", value: 1 }]]); + const action = setVariableAction({ type: "action-payload", value: { field: "label" } }); + executeComponentBoundAction(action, { label: "not-a-number" }, variables, cbs); + expect(cbs.setVariable).not.toHaveBeenCalled(); + }); + + test("set-variable from payload field skips when the field is absent", () => { + const cbs = makeCallbacks(); + const variables: VariableStore = new Map([["target-var", { key: "string", value: "old" }]]); + const action = setVariableAction({ type: "action-payload", value: { field: "missing" } }); + executeComponentBoundAction(action, { label: "new" }, variables, cbs); + executeComponentBoundAction(action, undefined, variables, cbs); + expect(cbs.setVariable).not.toHaveBeenCalled(); + }); + + test("set-variable from payload field skips when the target variable is unknown", () => { + const cbs = makeCallbacks(); + const action = setVariableAction({ type: "action-payload", value: { field: "label" } }); + executeComponentBoundAction(action, { label: "new" }, emptyVars, cbs); + expect(cbs.setVariable).not.toHaveBeenCalled(); + }); + + test("set-variable boolean and number payload fields pass when typeof matches", () => { + const cbs = makeCallbacks(); + const variables: VariableStore = new Map([ + ["bool-var", { key: "boolean", value: false }], + ["num-var", { key: "number", value: 0 }], + ]); + executeComponentBoundAction( + setVariableAction({ type: "action-payload", value: { field: "flag" } }, "bool-var"), + { flag: true }, + variables, + cbs, + ); + executeComponentBoundAction( + setVariableAction({ type: "action-payload", value: { field: "count" } }, "num-var"), + { count: 7 }, + variables, + cbs, + ); + expect(cbs.setVariable).toHaveBeenCalledWith("bool-var", { key: "boolean", value: true }); + expect(cbs.setVariable).toHaveBeenCalledWith("num-var", { key: "number", value: 7 }); + }); + + test("purchase-product with literal product id", () => { + const cbs = makeCallbacks(); + const action: ComponentBoundAction = { + type: "purchase-product", + payload: { type: "literal", productId: "product-123" }, + }; + executeComponentBoundAction(action, undefined, emptyVars, cbs); + expect(cbs.purchaseProduct).toHaveBeenCalledWith("product-123"); + }); + + test("purchase-product with product variable reference", () => { + const cbs = makeCallbacks(); + const variables: VariableStore = new Map([ + ["product-var", { key: "product", value: { productId: "product-456" } }], + ]); + const action: ComponentBoundAction = { + type: "purchase-product", + payload: { type: "variable-reference", variableId: "product-var" }, + }; + executeComponentBoundAction(action, undefined, variables, cbs); + expect(cbs.purchaseProduct).toHaveBeenCalledWith("product-456"); + }); + + test("purchase-product with an unset product variable (absent productId) does nothing", () => { + const cbs = makeCallbacks(); + const variables: VariableStore = new Map([["product-var", { key: "product", value: {} }]]); + const action: ComponentBoundAction = { + type: "purchase-product", + payload: { type: "variable-reference", variableId: "product-var" }, + }; + executeComponentBoundAction(action, undefined, variables, cbs); + expect(cbs.purchaseProduct).not.toHaveBeenCalled(); + }); + + test("purchase-product from payload field", () => { + const cbs = makeCallbacks(); + const action: ComponentBoundAction = { + type: "purchase-product", + payload: { type: "action-payload", field: "productId" }, + }; + executeComponentBoundAction(action, { productId: "yearly" }, emptyVars, cbs); + expect(cbs.purchaseProduct).toHaveBeenCalledWith("yearly"); + }); + + test("purchase-product from payload field skips non-string or absent values", () => { + const cbs = makeCallbacks(); + const action: ComponentBoundAction = { + type: "purchase-product", + payload: { type: "action-payload", field: "productId" }, + }; + executeComponentBoundAction(action, { productId: 42 }, emptyVars, cbs); + executeComponentBoundAction(action, {}, emptyVars, cbs); + executeComponentBoundAction(action, undefined, emptyVars, cbs); + expect(cbs.purchaseProduct).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/paywall-renderer-web-core/src/actions.ts b/packages/paywall-renderer-web-core/src/actions.ts new file mode 100644 index 000000000..3dca18ded --- /dev/null +++ b/packages/paywall-renderer-web-core/src/actions.ts @@ -0,0 +1,230 @@ +import type { + Action, + ActionValueSource, + ComponentActionValueSource, + ComponentBoundAction, + ComponentProductSource, + ProductSource, + VariableValue, +} from "./snapshot-types"; +import type { VariableReader } from "./variables"; + +export interface ActionCallbacks { + onClosePaywall: () => void; + onPurchaseProduct: (productId: string) => void; + onSetVariable: (variableId: string, newValue: VariableValue) => void; +} + +function resolveActionValue( + source: ActionValueSource, + variables: VariableReader, +): VariableValue | undefined { + if (!source?.type) { + return undefined; + } + if (source.type === "literal") { + return source.value as VariableValue; + } + if (!source.value || !("id" in source.value) || !source.value.id) { + return undefined; + } + return variables.get(source.value.id); +} + +function resolveProductId(source: ProductSource, variables: VariableReader): string | undefined { + if (!source?.type) { + return undefined; + } + if (source.type === "literal") { + return source.productId; + } + if (!source.variableId) { + return undefined; + } + const variable = variables.get(source.variableId); + if (variable?.key === "product") { + return variable.value?.productId; + } + return undefined; +} + +/** + * Executes a visual-node interaction action against the given variable lookup + * (a plain store or an ancestor-chain reader) and host callbacks. + */ +export function executeAction( + action: Action, + variables: VariableReader, + callbacks: ActionCallbacks, +): void { + if (!action?.type) { + return; + } + + switch (action.type) { + case "none": + break; + case "set-variable": { + const payload = action.payload; + if (!payload?.newValue || !payload.variableId) { + break; + } + const newValue = resolveActionValue(payload.newValue, variables); + if (newValue !== undefined) { + callbacks.onSetVariable(payload.variableId, newValue); + } + break; + } + case "close-paywall": + callbacks.onClosePaywall(); + break; + case "purchase-product": { + if (!action.payload) { + break; + } + const productId = resolveProductId(action.payload, variables); + if (productId !== undefined) { + callbacks.onPurchaseProduct(productId); + } + break; + } + } +} + +/** + * Coerces a raw component action payload field to the type of the target + * variable. Scalars must match the target's typeof exactly; a product + * variable accepts any scalar field as `{ productId: String(value) }` + * (spec §3.2). Returns `undefined` (skip) for anything else. + */ +function coercePayloadFieldToVariableValue( + raw: unknown, + target: VariableValue, +): VariableValue | undefined { + switch (target.key) { + case "string": + return typeof raw === "string" ? { key: "string", value: raw } : undefined; + case "number": + return typeof raw === "number" ? { key: "number", value: raw } : undefined; + case "boolean": + return typeof raw === "boolean" ? { key: "boolean", value: raw } : undefined; + case "product": + return typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean" + ? { key: "product", value: { productId: String(raw) } } + : undefined; + default: + return undefined; + } +} + +function resolveComponentActionValue( + source: ComponentActionValueSource, + payload: Record | undefined, + variables: VariableReader, + targetVariableId: string, +): VariableValue | undefined { + if (!source?.type) { + return undefined; + } + if (source.type === "literal") { + return source.value as VariableValue; + } + if (source.type === "variable-reference") { + if (!source.value || !("id" in source.value) || !source.value.id) { + return undefined; + } + return variables.get(source.value.id); + } + if (!source.value || !("field" in source.value) || !source.value.field) { + return undefined; + } + const raw = payload?.[source.value.field]; + if (raw === undefined) { + return undefined; + } + const target = variables.get(targetVariableId); + if (target === undefined) { + return undefined; + } + return coercePayloadFieldToVariableValue(raw, target); +} + +function resolveComponentProductId( + source: ComponentProductSource, + payload: Record | undefined, + variables: VariableReader, +): string | undefined { + if (!source?.type) { + return undefined; + } + if (source.type === "literal") { + return source.productId; + } + if (source.type === "variable-reference") { + if (!source.variableId) { + return undefined; + } + const variable = variables.get(source.variableId); + if (variable?.key === "product") { + return variable.value?.productId; + } + return undefined; + } + if (!source.field) { + return undefined; + } + const raw = payload?.[source.field]; + return typeof raw === "string" ? raw : undefined; +} + +/** + * Executes a component bound action with the payload emitted by the component + * action (if any). Literal and variable-reference sources behave like + * {@link executeAction}; `action-payload` sources resolve `payload[field]` + * and no-op when the field is absent. `set-variable` coerces payload-sourced + * values to the target variable's type and skips on mismatch. + */ +export function executeComponentBoundAction( + action: ComponentBoundAction, + payload: Record | undefined, + variables: VariableReader, + callbacks: ActionCallbacks, +): void { + if (!action?.type) { + return; + } + + switch (action.type) { + case "none": + break; + case "set-variable": { + const actionPayload = action.payload; + if (!actionPayload?.newValue || !actionPayload.variableId) { + break; + } + const newValue = resolveComponentActionValue( + actionPayload.newValue, + payload, + variables, + actionPayload.variableId, + ); + if (newValue !== undefined) { + callbacks.onSetVariable(actionPayload.variableId, newValue); + } + break; + } + case "close-paywall": + callbacks.onClosePaywall(); + break; + case "purchase-product": { + if (!action.payload) { + break; + } + const productId = resolveComponentProductId(action.payload, payload, variables); + if (productId !== undefined) { + callbacks.onPurchaseProduct(productId); + } + break; + } + } +} diff --git a/packages/paywall-renderer-web-core/src/evaluator.test.ts b/packages/paywall-renderer-web-core/src/evaluator.test.ts new file mode 100644 index 000000000..7d98df79f --- /dev/null +++ b/packages/paywall-renderer-web-core/src/evaluator.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, test } from "vite-plus/test"; + +import { evaluateDNF } from "./evaluator"; +import type { Conjunction, DNF, Operand, Predicate, PredicateType } from "./snapshot-types"; +import type { VariableStore } from "./variables"; + +function lit(key: "boolean", value: boolean): Operand; +function lit(key: "number", value: number): Operand; +function lit(key: "string", value: string): Operand; +function lit(key: string, value: unknown): Operand { + return { type: "literal" as const, value: { key, value } } as Operand; +} + +function varRef(id: string): Operand { + return { type: "variable-reference", value: { id } }; +} + +function predicate(type: PredicateType, left: Operand, right: Operand): Predicate { + return { type, value: { left, right } }; +} + +function and(...predicates: Predicate[]): Conjunction { + return { + type: "and", + value: predicates.map((value, index) => ({ + id: `predicate-${index}`, + pos: `a${index}`, + value, + })), + }; +} + +function or(...conjunctions: Conjunction[]): DNF { + return { + type: "or", + value: conjunctions.map((value, index) => ({ + id: `conjunction-${index}`, + pos: `a${index}`, + value, + })), + }; +} + +function orRaw(...conjunctions: Conjunction[]): DNF { + return { + type: "or", + value: conjunctions as unknown as DNF["value"], + }; +} + +describe("evaluateDNF", () => { + describe("equals predicate", () => { + test("true when both literals match", () => { + const dnf = or(and(predicate("equals", lit("boolean", true), lit("boolean", true)))); + expect(evaluateDNF(dnf, new Map())).toBe(true); + }); + + test("false when literals differ", () => { + const dnf = or(and(predicate("equals", lit("boolean", true), lit("boolean", false)))); + expect(evaluateDNF(dnf, new Map())).toBe(false); + }); + + test("compares numbers", () => { + const dnf = or(and(predicate("equals", lit("number", 5), lit("number", 5)))); + expect(evaluateDNF(dnf, new Map())).toBe(true); + }); + + test("compares strings", () => { + const dnf = or(and(predicate("equals", lit("string", "a"), lit("string", "b")))); + expect(evaluateDNF(dnf, new Map())).toBe(false); + }); + }); + + describe("not-equals predicate", () => { + test("true when values differ", () => { + const dnf = or(and(predicate("not-equals", lit("number", 1), lit("number", 2)))); + expect(evaluateDNF(dnf, new Map())).toBe(true); + }); + + test("false when values match", () => { + const dnf = or(and(predicate("not-equals", lit("number", 1), lit("number", 1)))); + expect(evaluateDNF(dnf, new Map())).toBe(false); + }); + }); + + describe("comparison predicates", () => { + test("greater-than", () => { + expect( + evaluateDNF( + or(and(predicate("greater-than", lit("number", 5), lit("number", 3)))), + new Map(), + ), + ).toBe(true); + expect( + evaluateDNF( + or(and(predicate("greater-than", lit("number", 3), lit("number", 5)))), + new Map(), + ), + ).toBe(false); + expect( + evaluateDNF( + or(and(predicate("greater-than", lit("number", 5), lit("number", 5)))), + new Map(), + ), + ).toBe(false); + }); + + test("greater-than-or-equal", () => { + expect( + evaluateDNF( + or(and(predicate("greater-than-or-equal", lit("number", 5), lit("number", 5)))), + new Map(), + ), + ).toBe(true); + expect( + evaluateDNF( + or(and(predicate("greater-than-or-equal", lit("number", 4), lit("number", 5)))), + new Map(), + ), + ).toBe(false); + }); + + test("less-than", () => { + expect( + evaluateDNF(or(and(predicate("less-than", lit("number", 3), lit("number", 5)))), new Map()), + ).toBe(true); + expect( + evaluateDNF(or(and(predicate("less-than", lit("number", 5), lit("number", 3)))), new Map()), + ).toBe(false); + }); + + test("less-than-or-equal", () => { + expect( + evaluateDNF( + or(and(predicate("less-than-or-equal", lit("number", 5), lit("number", 5)))), + new Map(), + ), + ).toBe(true); + expect( + evaluateDNF( + or(and(predicate("less-than-or-equal", lit("number", 6), lit("number", 5)))), + new Map(), + ), + ).toBe(false); + }); + }); + + describe("variable references", () => { + test("resolves variable reference from store", () => { + const variables: VariableStore = new Map([["var-1", { key: "boolean", value: true }]]); + const dnf = or(and(predicate("equals", varRef("var-1"), lit("boolean", true)))); + expect(evaluateDNF(dnf, variables)).toBe(true); + }); + + test("returns false for missing variable reference", () => { + const dnf = or(and(predicate("equals", varRef("missing"), lit("boolean", true)))); + expect(evaluateDNF(dnf, new Map())).toBe(false); + }); + + test("compares two variable references", () => { + const variables: VariableStore = new Map([ + ["var-1", { key: "number", value: 10 }], + ["var-2", { key: "number", value: 10 }], + ]); + const dnf = or(and(predicate("equals", varRef("var-1"), varRef("var-2")))); + expect(evaluateDNF(dnf, variables)).toBe(true); + }); + }); + + describe("conjunction (AND)", () => { + test("all predicates must be true", () => { + const dnf = or( + and( + predicate("equals", lit("boolean", true), lit("boolean", true)), + predicate("equals", lit("number", 1), lit("number", 1)), + ), + ); + expect(evaluateDNF(dnf, new Map())).toBe(true); + }); + + test("fails if any predicate is false", () => { + const dnf = or( + and( + predicate("equals", lit("boolean", true), lit("boolean", true)), + predicate("equals", lit("number", 1), lit("number", 2)), + ), + ); + expect(evaluateDNF(dnf, new Map())).toBe(false); + }); + }); + + describe("DNF (OR of ANDs)", () => { + test("true if any conjunction is true", () => { + const dnf = or( + and(predicate("equals", lit("boolean", true), lit("boolean", false))), + and(predicate("equals", lit("number", 1), lit("number", 1))), + ); + expect(evaluateDNF(dnf, new Map())).toBe(true); + }); + + test("false if all conjunctions are false", () => { + const dnf = or( + and(predicate("equals", lit("boolean", true), lit("boolean", false))), + and(predicate("equals", lit("number", 1), lit("number", 2))), + ); + expect(evaluateDNF(dnf, new Map())).toBe(false); + }); + + test("supports legacy payloads with plain (entry-less) conjunction/predicate arrays", () => { + const dnf = orRaw({ + type: "and", + value: [ + { + type: "equals", + value: { + left: { type: "literal", value: { key: "boolean", value: true } }, + right: { + type: "literal", + value: { key: "boolean", value: true }, + }, + }, + }, + ] as unknown as Conjunction["value"], + } as Conjunction); + expect(evaluateDNF(dnf, new Map())).toBe(true); + }); + }); +}); diff --git a/packages/paywall-renderer-web-core/src/evaluator.ts b/packages/paywall-renderer-web-core/src/evaluator.ts new file mode 100644 index 000000000..75af192d9 --- /dev/null +++ b/packages/paywall-renderer-web-core/src/evaluator.ts @@ -0,0 +1,109 @@ +import type { ArrayEntry } from "@voidhash/mimic-schema"; + +import type { Conjunction, DNF, Operand, Predicate, VariableValue } from "./snapshot-types"; +import type { VariableReader } from "./variables"; + +/** + * Unwraps an ordered-array entry (`{id, pos, value}`) to its value. Legacy + * embedded payloads carry bare values (and pos-less `{id, value}` entries), + * so the runtime check stays permissive: anything with `id` + `value` and no + * `type` discriminator is treated as an entry. + */ +function unwrapValue(candidate: T | ArrayEntry | undefined): T | undefined { + if (!candidate) { + return undefined; + } + if ( + typeof candidate === "object" && + candidate !== null && + "id" in candidate && + "value" in candidate && + !("type" in candidate) + ) { + return candidate.value as T; + } + return candidate as T; +} + +export function evaluateDNF(dnf: DNF, variables: VariableReader): boolean { + if (!dnf?.value || !Array.isArray(dnf.value)) { + return false; + } + return dnf.value.some((conjunctionEntry) => { + const conjunction = unwrapValue(conjunctionEntry); + if (!conjunction) { + return false; + } + return evaluateConjunction(conjunction, variables); + }); +} + +function evaluateConjunction(conjunction: Conjunction, variables: VariableReader): boolean { + if (!conjunction?.value || !Array.isArray(conjunction.value)) { + return false; + } + return conjunction.value.every((predicateEntry) => { + const predicate = unwrapValue(predicateEntry); + if (!predicate) { + return false; + } + return evaluatePredicate(predicate, variables); + }); +} + +function resolveOperand(operand: Operand, variables: VariableReader): VariableValue | undefined { + if (operand.type === "literal") { + return operand.value; + } + if (!operand.value || !("id" in operand.value) || !operand.value.id) { + return undefined; + } + return variables.get(operand.value.id); +} + +function toComparable(v: VariableValue): string | number | boolean { + switch (v.key) { + case "boolean": + return v.value ?? false; + case "number": + return v.value ?? 0; + case "string": + return v.value ?? ""; + case "product": + return v.value?.productId ?? ""; + default: + return ""; + } +} + +function evaluatePredicate(predicate: Predicate, variables: VariableReader): boolean { + if (!predicate?.value?.left || !predicate?.value?.right) { + return false; + } + const left = resolveOperand(predicate.value.left, variables); + const right = resolveOperand(predicate.value.right, variables); + + if (left === undefined || right === undefined) { + return false; + } + + const l = toComparable(left); + const r = toComparable(right); + + switch (predicate.type) { + case "equals": + return l === r; + case "not-equals": + return l !== r; + case "greater-than": + return l > r; + case "greater-than-or-equal": + return l >= r; + case "less-than": + return l < r; + case "less-than-or-equal": + return l <= r; + default: + return false; + } +} diff --git a/packages/paywall-renderer-web-core/src/index.ts b/packages/paywall-renderer-web-core/src/index.ts new file mode 100644 index 000000000..61d1e0541 --- /dev/null +++ b/packages/paywall-renderer-web-core/src/index.ts @@ -0,0 +1,101 @@ +// Types +export type { + CodeComponentSnapshotNode, + ComponentSnapshotNode, + LibrarySnapshotNode, + NodeType, + PathSnapshotNode, + RenderOptions, + RenderResult, + RootSnapshotNode, + ScreenSnapshotNode, + ScrollViewSnapshotNode, + ShapeSnapshotNode, + SnapshotNode, + TextSnapshotNode, + ViewSnapshotNode, +} from "./types"; + +// Style builders +export { + buildBackgroundStyles, + buildPathStyles, + buildPreviewBackgroundStyles, + buildScreenContainerStyles, + buildScreenLayoutStyles, + buildScrollViewStyles, + buildShapeContainerStyles, + buildTextStyles, + buildViewStyles, + type BackgroundStyleInput, + type PathSvgAttributes, + type PreviewBackgroundStyleInput, + type ScrollViewOptions, + type ViewStyleInput, +} from "./styles"; + +// Utilities +export { px, pxOrAuto } from "./styles/utils"; + +// Snapshot types +export type { + Action, + ActionValueSource, + ComponentActionValueSource, + ComponentBoundAction, + ComponentProductSource, + ComponentPropBinding, + ComponentPropValue, + DNF, + Interaction, + NodeState, + ProductSource, + VariableValue, +} from "./snapshot-types"; + +// Variables +export { + collectVariables, + collectVariableScopes, + createChainVariableReader, + findDeclaringNodeInChain, + type NodeVariableMap, + type VariableAliases, + type VariableCollection, + type VariableReader, + type VariableScopes, + type VariableStore, +} from "./variables"; + +// Evaluator +export { evaluateDNF } from "./evaluator"; + +// State resolver +export { resolveActionOverride, resolveStyle } from "./state-resolver"; + +// Actions +export { executeAction, executeComponentBoundAction, type ActionCallbacks } from "./actions"; + +// Preview trees +export { + buildPreviewNodeStyles, + buildPreviewMotionStyles, + previewResizeModeToObjectFit, + type PreviewImageNode, + type PreviewNode, + type PreviewNodeStyle, + type PreviewResolvedMotionStyle, + type PreviewPlaceholderNode, + type PreviewPressableNode, + type PreviewResizeMode, + type PreviewScrollNode, + type PreviewSlotNode, + type PreviewStyleValue, + type PreviewTextNode, + type PreviewTree, + type PreviewViewNode, + type StyledPreviewNodeType, +} from "./preview-tree"; + +// Messages +export { isPaywallMessage, type PaywallMessage } from "./messages"; diff --git a/packages/paywall-renderer-web-core/src/messages.ts b/packages/paywall-renderer-web-core/src/messages.ts new file mode 100644 index 000000000..31d298a21 --- /dev/null +++ b/packages/paywall-renderer-web-core/src/messages.ts @@ -0,0 +1,11 @@ +export type PaywallMessage = + | { type: "paywall:close" } + | { type: "paywall:purchase"; productId: string }; + +export function isPaywallMessage(data: unknown): data is PaywallMessage { + if (typeof data !== "object" || data === null) { + return false; + } + const msg = data as Record; + return msg.type === "paywall:close" || msg.type === "paywall:purchase"; +} diff --git a/packages/paywall-renderer-web-core/src/preview-tree/index.ts b/packages/paywall-renderer-web-core/src/preview-tree/index.ts new file mode 100644 index 000000000..508f937c3 --- /dev/null +++ b/packages/paywall-renderer-web-core/src/preview-tree/index.ts @@ -0,0 +1,22 @@ +export type { + PreviewImageNode, + PreviewNode, + PreviewNodeStyle, + PreviewResolvedMotionStyle, + PreviewPlaceholderNode, + PreviewPressableNode, + PreviewResizeMode, + PreviewScrollNode, + PreviewSlotNode, + PreviewStyleValue, + PreviewTextNode, + PreviewTree, + PreviewViewNode, + StyledPreviewNodeType, +} from "./types"; + +export { + buildPreviewMotionStyles, + buildPreviewNodeStyles, + previewResizeModeToObjectFit, +} from "./styles"; diff --git a/packages/paywall-renderer-web-core/src/preview-tree/styles.test.ts b/packages/paywall-renderer-web-core/src/preview-tree/styles.test.ts new file mode 100644 index 000000000..27d817a28 --- /dev/null +++ b/packages/paywall-renderer-web-core/src/preview-tree/styles.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, test } from "vite-plus/test"; + +import { + buildPreviewMotionStyles, + buildPreviewNodeStyles, + previewResizeModeToObjectFit, +} from "./styles"; + +describe("buildPreviewNodeStyles", () => { + test("applies the RN view reset for view nodes", () => { + const styles = buildPreviewNodeStyles({}, "view"); + expect(styles).toEqual({ + alignItems: "stretch", + boxSizing: "border-box", + display: "flex", + flexBasis: "auto", + flexDirection: "column", + flexShrink: 0, + margin: 0, + minHeight: 0, + minWidth: 0, + padding: 0, + position: "relative", + }); + }); + + test("applies the text reset for text nodes", () => { + const styles = buildPreviewNodeStyles({}, "text"); + expect(styles).toEqual({ + boxSizing: "border-box", + display: "block", + margin: 0, + padding: 0, + whiteSpace: "pre-wrap", + wordWrap: "break-word", + }); + }); + + test("applies pressable affordances on top of the view reset", () => { + const styles = buildPreviewNodeStyles({}, "pressable"); + expect(styles).toMatchObject({ + cursor: "pointer", + display: "flex", + flexDirection: "column", + touchAction: "manipulation", + userSelect: "none", + }); + }); + + test("applies vertical overflow for scroll nodes", () => { + const styles = buildPreviewNodeStyles({}, "scroll"); + expect(styles).toMatchObject({ + display: "flex", + overflowX: "hidden", + overflowY: "auto", + WebkitOverflowScrolling: "touch", + }); + }); + + test("applies the image reset for image nodes", () => { + const styles = buildPreviewNodeStyles({}, "image"); + expect(styles).toEqual({ + boxSizing: "border-box", + display: "block", + }); + }); + + test("converts numbers to px and passes percent strings through", () => { + const styles = buildPreviewNodeStyles( + { fontSize: 16, height: "50%", lineHeight: 24, width: 120 }, + "text", + ); + expect(styles).toMatchObject({ + fontSize: "16px", + height: "50%", + lineHeight: "24px", + width: "120px", + }); + }); + + test("keeps unitless keys numeric", () => { + const styles = buildPreviewNodeStyles( + { + aspectRatio: 1.5, + flex: 1, + flexGrow: 2, + flexShrink: 1, + fontWeight: 700, + opacity: 0.5, + zIndex: 3, + }, + "view", + ); + expect(styles).toMatchObject({ + aspectRatio: 1.5, + flex: 1, + flexGrow: 2, + flexShrink: 1, + fontWeight: 700, + opacity: 0.5, + zIndex: 3, + }); + }); + + test("emits the flex shorthand before its longhands so longhands win", () => { + const styles = buildPreviewNodeStyles({ flex: 1, flexBasis: "auto" }, "view"); + const keys = Object.keys(styles); + expect(keys.indexOf("flex")).toBeLessThan(keys.indexOf("flexBasis")); + expect(styles).toMatchObject({ flex: 1, flexBasis: "auto" }); + }); + + test("re-emits reset longhands after shorthands so they win in insertion order", () => { + const styles = buildPreviewNodeStyles({ flex: 1, flexShrink: 0 }, "view"); + const keys = Object.keys(styles); + expect(keys.indexOf("flex")).toBeLessThan(keys.indexOf("flexShrink")); + expect(styles).toMatchObject({ flex: 1, flexShrink: 0 }); + }); + + test("emits per-side padding/margin edges as px", () => { + const styles = buildPreviewNodeStyles( + { + marginBottom: 4, + marginTop: 8, + paddingLeft: 30, + paddingRight: 20, + }, + "view", + ); + expect(styles).toMatchObject({ + marginBottom: "4px", + marginTop: "8px", + paddingLeft: "30px", + paddingRight: "20px", + }); + }); + + test("a per-side border width implies solid borderStyle", () => { + expect(buildPreviewNodeStyles({ borderTopWidth: 2 }, "view")).toMatchObject({ + borderStyle: "solid", + borderTopWidth: "2px", + }); + expect(buildPreviewNodeStyles({ borderColor: "#fff" }, "view")).toMatchObject({ + borderColor: "#fff", + borderStyle: "solid", + }); + expect( + buildPreviewNodeStyles({ borderStyle: "dashed", borderTopWidth: 2 }, "view"), + ).toMatchObject({ + borderStyle: "dashed", + borderTopWidth: "2px", + }); + expect(buildPreviewNodeStyles({}, "view").borderStyle).toBe(undefined); + }); + + test("author styles override the reset", () => { + const styles = buildPreviewNodeStyles( + { alignItems: "center", flexDirection: "row", position: "absolute" }, + "view", + ); + expect(styles).toMatchObject({ + alignItems: "center", + flexDirection: "row", + position: "absolute", + }); + }); + + test("zero stays unitless", () => { + expect(buildPreviewNodeStyles({ gap: 0, width: 0 }, "view")).toMatchObject({ + gap: 0, + width: 0, + }); + }); + + test("solid background passes backgroundColor through untouched", () => { + const styles = buildPreviewNodeStyles( + { backgroundColor: "rgba(1, 2, 3, 1)", backgroundType: "solid" }, + "view", + ); + expect(styles.backgroundColor).toBe("rgba(1, 2, 3, 1)"); + // The structured derivation keys never reach the DOM. + expect("backgroundType" in styles).toBe(false); + expect("backgroundGradient" in styles).toBe(false); + }); + + test("gradient background lowers to an SVG data-URI and drops backgroundColor", () => { + const styles = buildPreviewNodeStyles( + { + backgroundColor: "rgba(1, 2, 3, 1)", + backgroundType: "gradient", + backgroundGradient: { + kind: "linear", + startX: 0, + startY: 0, + endX: 0, + endY: 1, + stops: [ + { color: "rgba(255, 0, 0, 1)", position: 0 }, + { color: "rgba(0, 0, 255, 1)", position: 1 }, + ], + }, + }, + "view", + ); + expect(styles.backgroundColor).toBeUndefined(); + expect(String(styles.backgroundImage)).toContain("data:image/svg+xml"); + expect(styles.backgroundSize).toBe("100% 100%"); + expect("backgroundGradient" in styles).toBe(false); + }); + + test("image background lowers to a url() with resize-mapped size", () => { + const styles = buildPreviewNodeStyles( + { + backgroundType: "image", + backgroundImage: { url: "https://cdn.example.com/bg.png", resizeMode: "contain" }, + }, + "view", + ); + expect(String(styles.backgroundImage)).toContain("https://cdn.example.com/bg.png"); + expect(styles.backgroundSize).toBe("contain"); + }); + + test("a single-stop gradient degrades to that solid color", () => { + const styles = buildPreviewNodeStyles( + { + backgroundType: "gradient", + backgroundGradient: { + kind: "linear", + startX: 0, + startY: 0, + endX: 1, + endY: 1, + stops: [{ color: "rgba(9, 9, 9, 1)", position: 0 }], + }, + }, + "view", + ); + expect(styles.backgroundColor).toBe("rgba(9, 9, 9, 1)"); + expect(styles.backgroundImage).toBeUndefined(); + }); +}); + +describe("buildPreviewMotionStyles", () => { + test("lowers v2 rest motion after static style using canonical transform order", () => { + expect( + buildPreviewNodeStyles({ opacity: 0.2 }, "view", { + opacity: 1, + rotate: 10, + scale: 1.1, + x: 12, + y: -4, + }), + ).toMatchObject({ + opacity: 1, + transform: "translate3d(12px, -4px, 0) rotate(10deg) scale(1.1)", + }); + expect(buildPreviewMotionStyles({ transformOrigin: { x: 0.5, y: 0 } })).toEqual({ + transformOrigin: "50% 0%", + }); + }); +}); + +describe("previewResizeModeToObjectFit", () => { + test("maps every resize mode and defaults to cover", () => { + expect(previewResizeModeToObjectFit("cover")).toBe("cover"); + expect(previewResizeModeToObjectFit("contain")).toBe("contain"); + expect(previewResizeModeToObjectFit("stretch")).toBe("fill"); + expect(previewResizeModeToObjectFit("center")).toBe("none"); + expect(previewResizeModeToObjectFit(undefined)).toBe("cover"); + }); +}); diff --git a/packages/paywall-renderer-web-core/src/preview-tree/styles.ts b/packages/paywall-renderer-web-core/src/preview-tree/styles.ts new file mode 100644 index 000000000..10da16dd0 --- /dev/null +++ b/packages/paywall-renderer-web-core/src/preview-tree/styles.ts @@ -0,0 +1,246 @@ +import type { Properties } from "csstype"; + +import { buildPreviewBackgroundStyles } from "../styles/background"; +import type { + PreviewNodeStyle, + PreviewResolvedMotionStyle, + PreviewResizeMode, + StyledPreviewNodeType, +} from "./types"; + +/** + * React Native `View` reset, mirrored from the OSS DOM host + * (`@voidhash/paywalls` dom-host). Preview trees are authored with RN layout + * expectations (column flow, no implicit shrink, border box), so the web + * renderers re-create them instead of inheriting block-layout defaults. + */ +const VIEW_BASE: Record = { + alignItems: "stretch", + boxSizing: "border-box", + display: "flex", + flexBasis: "auto", + flexDirection: "column", + flexShrink: 0, + margin: 0, + minHeight: 0, + minWidth: 0, + padding: 0, + position: "relative", +}; + +const TEXT_BASE: Record = { + boxSizing: "border-box", + display: "block", + margin: 0, + padding: 0, + whiteSpace: "pre-wrap", + wordWrap: "break-word", +}; + +const IMAGE_BASE: Record = { + boxSizing: "border-box", + display: "block", +}; + +const PRESSABLE_BASE: Record = { + ...VIEW_BASE, + cursor: "pointer", + touchAction: "manipulation", + userSelect: "none", +}; + +// Preview trees carry no `horizontal` flag, so scroll nodes always get the RN +// ScrollView default (vertical) overflow. +const SCROLL_BASE: Record = { + ...VIEW_BASE, + overflowX: "hidden", + overflowY: "auto", + WebkitOverflowScrolling: "touch", +}; + +const BASE_BY_NODE_TYPE: Record> = { + image: IMAGE_BASE, + pressable: PRESSABLE_BASE, + scroll: SCROLL_BASE, + text: TEXT_BASE, + view: VIEW_BASE, +}; + +/** + * §3.1 keys that are CSS shorthands on the DOM. Committed later in an inline + * style object they reset their longhands (`flex` clears `flexBasis`, …), + * whereas React Native gives longhands precedence within one object regardless + * of declaration order — so these keys are emitted before everything else. The + * style vocabulary has no shorthand spacing/border keys (per-side longhands + * only), so `flex` and `gap` are the only shorthands that remain. + */ +const CSS_SHORTHAND_KEYS = ["flex", "gap"] as const; + +/** + * Structured background keys the renderer consumes to derive the CSS background + * quad (see {@link buildPreviewBackgroundStyles}) rather than emitting as raw + * longhands. `backgroundColor` is intentionally NOT here: a `solid`/absent type + * lets it pass through as the plain CSS property. + */ +const BACKGROUND_DERIVED_KEYS = [ + "backgroundType", + "backgroundGradient", + "backgroundImage", +] as const; + +const SKIPPED_LONGHAND_KEYS = new Set([...CSS_SHORTHAND_KEYS, ...BACKGROUND_DERIVED_KEYS]); + +/** + * Keys whose numeric values are unitless in CSS; every other numeric value is + * an RN point and becomes `px`. + */ +const UNITLESS_KEYS = new Set([ + "aspectRatio", + "flex", + "flexGrow", + "flexShrink", + "fontWeight", + "opacity", + "zIndex", +]); + +function toCssValue(key: string, value: string | number): string | number { + if (typeof value === "number" && !UNITLESS_KEYS.has(key)) { + return value === 0 ? 0 : `${value}px`; + } + return value; +} + +const RESIZE_MODE_TO_OBJECT_FIT: Record = { + center: "none", + contain: "contain", + cover: "cover", + stretch: "fill", +}; + +/** + * Maps a preview image node's `resizeMode` to CSS `object-fit`. Mirrors the + * OSS DOM host, including its `cover` default for an absent `resizeMode`. + */ +export function previewResizeModeToObjectFit( + resizeMode: PreviewResizeMode | undefined, +): Properties["objectFit"] { + return RESIZE_MODE_TO_OBJECT_FIT[resizeMode ?? "cover"]; +} + +/** Lowers the v2 rest-motion field using the same canonical transform ordering as the DOM host. */ +export function buildPreviewMotionStyles( + motion: PreviewResolvedMotionStyle | undefined, +): Properties { + if (!motion) return {}; + const transforms: string[] = []; + if (motion.x !== undefined || motion.y !== undefined) { + transforms.push(`translate3d(${motion.x ?? 0}px, ${motion.y ?? 0}px, 0)`); + } + if (motion.rotate !== undefined) transforms.push(`rotate(${motion.rotate}deg)`); + if (motion.scale !== undefined) transforms.push(`scale(${motion.scale})`); + if (motion.scaleX !== undefined) transforms.push(`scaleX(${motion.scaleX})`); + if (motion.scaleY !== undefined) transforms.push(`scaleY(${motion.scaleY})`); + return { + ...(motion.opacity === undefined ? {} : { opacity: motion.opacity }), + ...(motion.backgroundColor === undefined ? {} : { backgroundColor: motion.backgroundColor }), + ...(transforms.length === 0 ? {} : { transform: transforms.join(" ") }), + ...(motion.transformOrigin === undefined + ? {} + : { + transformOrigin: `${motion.transformOrigin.x * 100}% ${motion.transformOrigin.y * 100}%`, + }), + } as Properties; +} + +/** + * Lowers a contract §3.1 preview node style onto the web as csstype + * `Properties`, porting the OSS DOM-host semantics: + * + * - per-node-type RN resets (view/pressable/scroll flex-column reset, text + * block reset, image block reset; pressable cursor affordances; scroll + * vertical overflow); + * - deterministic specificity order — CSS shorthands (`flex`, `gap`) first, + * then explicit edges/corners — so RN's "longhand wins within one object" + * semantics hold on the DOM. Longhands already present in the node reset + * (`flexShrink`, `flexBasis`, …) are re-inserted at the end of the object's + * key order, since inline styles apply in insertion order and a + * reset-position longhand would otherwise lose to a later shorthand; + * - numbers become `px` (except unitless keys), percent and other strings + * pass through; + * - a per-side `border*Width`/`borderColor` without `borderStyle` implies + * `solid` (RN renders solid borders implicitly). + * - a `backgroundType` of `gradient`/`image` is lowered onto the CSS background + * quad (SVG data-URI or `url(...)`) via {@link buildPreviewBackgroundStyles}; + * the structured `backgroundGradient`/`backgroundImage` keys never reach the + * DOM as raw CSS. A `solid`/absent type leaves `backgroundColor` as-is. + * + * Image `object-fit` is node data, not style — see + * {@link previewResizeModeToObjectFit}. + */ +export function buildPreviewNodeStyles( + style: PreviewNodeStyle, + nodeType: StyledPreviewNodeType, + motion?: PreviewResolvedMotionStyle, +): Properties { + const out: Record = { ...BASE_BY_NODE_TYPE[nodeType] }; + + // Deleting before assigning moves keys the reset already declared to the + // end of insertion order; plain assignment would update the value but keep + // the early reset position, letting a later shorthand clobber the longhand. + const emitLonghand = (key: string, value: string | number) => { + delete out[key]; + out[key] = toCssValue(key, value); + }; + + for (const key of CSS_SHORTHAND_KEYS) { + const value = style[key]; + if (value !== undefined) { + out[key] = toCssValue(key, value); + } + } + + for (const [key, value] of Object.entries(style)) { + // Structured background values (objects) are derived below, not emitted as + // longhands; the scalar guard both skips them and narrows the type. + if (value === undefined || SKIPPED_LONGHAND_KEYS.has(key)) { + continue; + } + if (typeof value !== "string" && typeof value !== "number") { + continue; + } + emitLonghand(key, value); + } + + const hasBorderWidth = + style.borderTopWidth !== undefined || + style.borderRightWidth !== undefined || + style.borderBottomWidth !== undefined || + style.borderLeftWidth !== undefined; + if ((hasBorderWidth || style.borderColor !== undefined) && style.borderStyle === undefined) { + out.borderStyle = "solid"; + } + + // A gradient/image background type is lowered onto the CSS background quad, + // overriding the pass-through `backgroundColor`. A `solid` (or absent) type + // leaves `backgroundColor` untouched. + if (style.backgroundType === "gradient" || style.backgroundType === "image") { + delete out.backgroundColor; + Object.assign( + out, + buildPreviewBackgroundStyles({ + backgroundColor: + typeof style.backgroundColor === "string" ? style.backgroundColor : undefined, + backgroundType: style.backgroundType, + backgroundGradient: style.backgroundGradient, + backgroundImage: style.backgroundImage, + }), + ); + } + + Object.assign(out, buildPreviewMotionStyles(motion)); + + // Single wire→CSS boundary widening: §3.1 values arrive as string | number, + // which cannot satisfy csstype's per-property literal unions structurally. + return out as Properties; +} diff --git a/packages/paywall-renderer-web-core/src/preview-tree/types.ts b/packages/paywall-renderer-web-core/src/preview-tree/types.ts new file mode 100644 index 000000000..bcc4e7380 --- /dev/null +++ b/packages/paywall-renderer-web-core/src/preview-tree/types.ts @@ -0,0 +1,165 @@ +/** + * Render-side mirrors of the paywall-deploy-contract §3 preview node tree. + * + * These are aliases of the OSS single source of truth, `@voidhash/paywalls/schema` + * (the `Paywall*` node/style types + `PAYWALL_TREE_VERSION`), so the render side + * never drifts from the authoring library. The one deliberate widening: the OSS + * `PaywallStyle` uses precise per-key literal unions (`flexDirection?: "row" | …`, + * `flex?: number`, …), whereas the render side treats every §3.1 style value as + * `string | number` ({@link PreviewNodeStyle}). This keeps the render side a + * strict superset of both OSS `PaywallStyle` and `@voidhash-mono/core`'s + * effect-Schema `PreviewStyle` (whose decoded values are also `string | number`), + * so a core-decoded tree stays structurally assignable to {@link PreviewTree}. + * Validation is the server's job (`PreviewTreeSchema` in `@voidhash-mono/core`); + * these types exist so renderers do not depend on `effect` or the server. + */ + +import type { + PaywallBackgroundType, + PaywallNodeResizeMode, + PaywallPlaceholderNode, + PaywallSlotNode, + PaywallStyle, +} from "@voidhash/paywalls/schema"; + +/** + * §3 preview node tree wire versions accepted during the motion rollout. + * Version 1 omits motion; version 2 adds resolved rest-state motion fields. + */ +export type PreviewTreeVersion = 1 | 2; + +/** §3.1 style value — a color/length string or a device-independent pixel number. */ +export type PreviewStyleValue = string | number; + +/** + * The §3.1 style keys whose value is a structured object rather than a scalar: + * the gradient/image background descriptors. Their decoded shape is preserved + * (an OSS-built preview tree carries plain objects here, not CRDT envelopes), so + * {@link buildPreviewNodeStyles} can lower them onto the CSS background quad. + */ +type PreviewStructuredStyleKey = "backgroundType" | "backgroundGradient" | "backgroundImage"; + +/** A gradient color stop — an RGBA color at a normalized `0..1` position. */ +export interface PreviewGradientStop { + readonly color: string; + readonly position: number; +} + +/** + * A gradient background descriptor. Structurally a superset of both OSS + * `PaywallBackgroundGradient` (mutable `stops`) and `@voidhash-mono/core`'s + * effect-decoded gradient (`readonly` `stops`) — `stops` is `readonly` here so + * either assigns in, keeping {@link PreviewNodeStyle} a strict superset of both. + */ +export interface PreviewBackgroundGradient { + readonly kind: "linear" | "radial"; + readonly startX: number; + readonly startY: number; + readonly endX: number; + readonly endY: number; + readonly stops: ReadonlyArray; +} + +/** An image background descriptor. Superset of the OSS/core image shapes. */ +export interface PreviewBackgroundImage { + readonly url: string; + readonly resizeMode: "cover" | "contain" | "stretch" | "center"; +} + +/** + * Contract §3.1 RN-compatible style subset. Keyed to the OSS {@link PaywallStyle} + * vocabulary (a key added or removed there flows here automatically). Scalar + * values are widened to {@link PreviewStyleValue} so wire/`effect`-decoded trees + * — whose style values arrive as `string | number` — remain assignable; the + * three structured background keys keep their object shape (widened to + * `readonly` so both OSS and core's decoded gradient assign in). + */ +export type PreviewNodeStyle = { + readonly [K in keyof PaywallStyle as K extends PreviewStructuredStyleKey ? never : K]?: + | PreviewStyleValue + | undefined; +} & { + readonly backgroundType?: PaywallBackgroundType | undefined; + readonly backgroundGradient?: PreviewBackgroundGradient | undefined; + readonly backgroundImage?: PreviewBackgroundImage | undefined; +}; + +/** How an image node fills its frame. Exact alias of the OSS resize mode. */ +export type PreviewResizeMode = PaywallNodeResizeMode; + +/** One resolved, serializable rest visual state from a v2 preview artifact. */ +export interface PreviewResolvedMotionStyle { + readonly x?: number | undefined; + readonly y?: number | undefined; + readonly scale?: number | undefined; + readonly scaleX?: number | undefined; + readonly scaleY?: number | undefined; + readonly rotate?: number | undefined; + readonly opacity?: number | undefined; + readonly backgroundColor?: string | undefined; + readonly transformOrigin?: { readonly x: number; readonly y: number } | undefined; +} + +export interface PreviewViewNode { + readonly type: "view"; + readonly style: PreviewNodeStyle; + readonly motion?: PreviewResolvedMotionStyle | undefined; + readonly children: ReadonlyArray; +} + +export interface PreviewPressableNode { + readonly type: "pressable"; + readonly style: PreviewNodeStyle; + readonly motion?: PreviewResolvedMotionStyle | undefined; + readonly children: ReadonlyArray; + /** The declared component action name this pressable fires. */ + readonly action?: string | undefined; +} + +export interface PreviewScrollNode { + readonly type: "scroll"; + readonly style: PreviewNodeStyle; + readonly motion?: PreviewResolvedMotionStyle | undefined; + readonly children: ReadonlyArray; +} + +export interface PreviewTextNode { + readonly type: "text"; + readonly style: PreviewNodeStyle; + readonly motion?: PreviewResolvedMotionStyle | undefined; + readonly text: string; +} + +export interface PreviewImageNode { + readonly type: "image"; + readonly style: PreviewNodeStyle; + readonly motion?: PreviewResolvedMotionStyle | undefined; + readonly src: string; + readonly resizeMode?: PreviewResizeMode | undefined; +} + +/** Slot marker — the editor mounts the component node's children here. Alias of the OSS node. */ +export type PreviewSlotNode = PaywallSlotNode; + +/** Emitted where rendering failed or produced nothing renderable. Alias of the OSS node. */ +export type PreviewPlaceholderNode = PaywallPlaceholderNode; + +/** Closed contract §3 node union. */ +export type PreviewNode = + | PreviewViewNode + | PreviewPressableNode + | PreviewScrollNode + | PreviewTextNode + | PreviewImageNode + | PreviewSlotNode + | PreviewPlaceholderNode; + +/** The §3 `previews/.json` artifact: a tree of closed primitives. */ +export interface PreviewTree { + readonly treeVersion: PreviewTreeVersion; + readonly state: string; + readonly root: PreviewNode; +} + +/** Preview node types that carry a `style` field. */ +export type StyledPreviewNodeType = "view" | "pressable" | "scroll" | "text" | "image"; diff --git a/packages/paywall-renderer-web-core/src/snapshot-types.ts b/packages/paywall-renderer-web-core/src/snapshot-types.ts new file mode 100644 index 000000000..1d37bc735 --- /dev/null +++ b/packages/paywall-renderer-web-core/src/snapshot-types.ts @@ -0,0 +1,53 @@ +import type { + Action, + ActionOverride, + ActionValueSource, + ComponentActionValueSource, + ComponentBoundAction, + ComponentProductSource, + ComponentPropBinding, + ComponentPropValue, + ConjunctionSnapshot, + DNFSnapshot, + Interaction, + OperandSnapshot, + PathNodeData, + PredicateSnapshot, + ProductSource, + ScreenNodeData, + ShapeNodeData, + TextNodeData, + Variable, + VariableType, + VariableTypeKey, + ViewNodeData, +} from "@voidhash/mimic-schema"; + +export type { + Action, + ActionOverride, + ActionValueSource, + ComponentActionValueSource, + ComponentBoundAction, + ComponentProductSource, + ComponentPropBinding, + ComponentPropValue, + Interaction, + ProductSource, + Variable, + VariableTypeKey as VariableValueKey, +}; + +export type Conjunction = NonNullable; +export type DNF = NonNullable; +export type NodeState = + | ViewNodeData["data"]["states"][number]["value"] + | PathNodeData["data"]["states"][number]["value"] + | ScreenNodeData["data"]["states"][number]["value"] + | ShapeNodeData["data"]["states"][number]["value"] + | TextNodeData["data"]["states"][number]["value"]; +export type Operand = NonNullable; +export type Predicate = NonNullable; +export type VariableValue = VariableType; + +export type PredicateType = Predicate["type"]; diff --git a/packages/paywall-renderer-web-core/src/state-resolver.test.ts b/packages/paywall-renderer-web-core/src/state-resolver.test.ts new file mode 100644 index 000000000..4c2b24542 --- /dev/null +++ b/packages/paywall-renderer-web-core/src/state-resolver.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, test } from "vite-plus/test"; + +import type { Action, DNF, NodeState } from "./snapshot-types"; +import { resolveActionOverride, resolveStyle } from "./state-resolver"; +import type { VariableStore } from "./variables"; + +function makeDNF(literal: boolean): DNF { + return { + type: "or", + value: [ + { + id: "conjunction-1", + pos: "a0", + value: { + type: "and" as const, + value: [ + { + id: "predicate-1", + pos: "a0", + value: { + type: "equals" as const, + value: { + left: { + type: "literal" as const, + value: { key: "boolean" as const, value: true }, + }, + right: { + type: "literal" as const, + value: { key: "boolean" as const, value: literal }, + }, + }, + }, + }, + ], + }, + }, + ], + }; +} + +const alwaysTrue = makeDNF(true); +const alwaysFalse = makeDNF(false); + +function variableEquals(variableId: string, value: boolean): DNF { + return { + type: "or", + value: [ + { + id: "conjunction-1", + pos: "a0", + value: { + type: "and" as const, + value: [ + { + id: "predicate-1", + pos: "a0", + value: { + type: "equals" as const, + value: { + left: { + type: "variable-reference" as const, + value: { id: variableId }, + }, + right: { + type: "literal" as const, + value: { key: "boolean" as const, value }, + }, + }, + }, + }, + ], + }, + }, + ], + }; +} + +function variableEqualsRaw(variableId: string, value: boolean): DNF { + return { + type: "or", + value: [ + { + type: "and", + value: [ + { + type: "equals", + value: { + left: { + type: "variable-reference", + value: { id: variableId }, + }, + right: { + type: "literal", + value: { key: "boolean", value }, + }, + }, + }, + ], + }, + ] as unknown as DNF["value"], + }; +} + +function state( + condition: DNF, + styleOverrides: Record, + actionOverrides?: Array<{ interactionId: string; action: Action }>, +): { value: NodeState } { + return { + value: { + condition, + id: "state-1", + name: "State", + overrides: { + style: styleOverrides, + ...(actionOverrides ? { actions: actionOverrides } : {}), + }, + } as unknown as NodeState, + }; +} + +describe("resolveStyle", () => { + const baseStyle = { backgroundColor: "red", opacity: 1, width: 100 }; + const emptyVars: VariableStore = new Map(); + + test("returns base style when no states", () => { + const result = resolveStyle(baseStyle, [], emptyVars); + expect(result).toEqual(baseStyle); + }); + + test("returns base style when state condition is false", () => { + const result = resolveStyle( + baseStyle, + [state(alwaysFalse, { backgroundColor: "blue" })], + emptyVars, + ); + expect(result).toEqual(baseStyle); + }); + + test("merges active state style overrides", () => { + const result = resolveStyle( + baseStyle, + [state(alwaysTrue, { backgroundColor: "blue" })], + emptyVars, + ); + expect(result).toEqual({ backgroundColor: "blue", opacity: 1, width: 100 }); + }); + + test("last active state wins for conflicting properties", () => { + const result = resolveStyle( + baseStyle, + [ + state(alwaysTrue, { backgroundColor: "blue" }), + state(alwaysTrue, { backgroundColor: "green" }), + ], + emptyVars, + ); + expect(result).toEqual({ + backgroundColor: "green", + opacity: 1, + width: 100, + }); + }); + + test("merges non-conflicting properties from multiple states", () => { + const result = resolveStyle( + baseStyle, + [state(alwaysTrue, { backgroundColor: "blue" }), state(alwaysTrue, { opacity: 0.5 })], + emptyVars, + ); + expect(result).toEqual({ + backgroundColor: "blue", + opacity: 0.5, + width: 100, + }); + }); + + test("evaluates state condition using variable store", () => { + const variables: VariableStore = new Map([["var-1", { key: "boolean", value: true }]]); + + const result = resolveStyle( + baseStyle, + [state(variableEquals("var-1", true), { width: 200 })], + variables, + ); + expect(result.width).toBe(200); + }); + + test("skips state when variable condition is false", () => { + const variables: VariableStore = new Map([["var-1", { key: "boolean", value: false }]]); + + const result = resolveStyle( + baseStyle, + [state(variableEquals("var-1", true), { width: 200 })], + variables, + ); + expect(result.width).toBe(100); + }); + + test("ignores undefined override values", () => { + const result = resolveStyle( + baseStyle, + [state(alwaysTrue, { backgroundColor: undefined })], + emptyVars, + ); + expect(result.backgroundColor).toBe("red"); + }); + + test("replaces a structured background object wholesale (no deep merge)", () => { + const structuredBase = { + backgroundType: "gradient", + backgroundGradient: { + kind: "linear", + startX: 0, + startY: 0, + endX: 1, + endY: 1, + stops: [{ value: { color: "rgba(0, 0, 0, 1)", position: 0 } }], + }, + }; + const overrideGradient = { + kind: "radial", + startX: 0.5, + startY: 0.5, + endX: 1, + endY: 1, + stops: [{ value: { color: "rgba(255, 0, 0, 1)", position: 1 } }], + }; + const result = resolveStyle( + structuredBase, + [state(alwaysTrue, { backgroundGradient: overrideGradient })], + emptyVars, + ); + // The whole gradient object is swapped for the override, not merged + // key-by-key — the intended per-key replacement semantic. + expect(result.backgroundGradient).toBe(overrideGradient); + expect(result.backgroundType).toBe("gradient"); + }); +}); + +describe("resolveActionOverride", () => { + const emptyVars: VariableStore = new Map(); + const closeAction: Action = { type: "close-paywall" }; + const noneAction: Action = { type: "none" }; + + test("returns undefined when no states", () => { + expect(resolveActionOverride("int-1", [], emptyVars)).toBeUndefined(); + }); + + test("returns undefined when no active states", () => { + const states = [state(alwaysFalse, {}, [{ interactionId: "int-1", action: closeAction }])]; + expect(resolveActionOverride("int-1", states, emptyVars)).toBeUndefined(); + }); + + test("returns overridden action for matching interaction", () => { + const states = [state(alwaysTrue, {}, [{ interactionId: "int-1", action: closeAction }])]; + expect(resolveActionOverride("int-1", states, emptyVars)).toEqual(closeAction); + }); + + test("returns undefined for non-matching interaction id", () => { + const states = [state(alwaysTrue, {}, [{ interactionId: "int-1", action: closeAction }])]; + expect(resolveActionOverride("int-2", states, emptyVars)).toBeUndefined(); + }); + + test("last active state override wins", () => { + const states = [ + state(alwaysTrue, {}, [{ interactionId: "int-1", action: closeAction }]), + state(alwaysTrue, {}, [{ interactionId: "int-1", action: noneAction }]), + ]; + expect(resolveActionOverride("int-1", states, emptyVars)).toEqual(noneAction); + }); + + test("toggle scenario: override found only when variable condition is true", () => { + const setFalseAction: Action = { + type: "set-variable", + payload: { + variableId: "var-isSelected", + newValue: { type: "literal", value: { key: "boolean", value: false } }, + }, + }; + + const states = [ + state(variableEquals("var-isSelected", true), { backgroundColor: "blue" }, [ + { interactionId: "int-click", action: setFalseAction }, + ]), + ]; + + // First click: isSelected = false -> condition false -> no override. + const varsBeforeClick: VariableStore = new Map([ + ["var-isSelected", { key: "boolean", value: false }], + ]); + expect(resolveActionOverride("int-click", states, varsBeforeClick)).toBeUndefined(); + + // After first click: isSelected = true -> condition true -> override found. + const varsAfterFirstClick: VariableStore = new Map([ + ["var-isSelected", { key: "boolean", value: true }], + ]); + const override = resolveActionOverride("int-click", states, varsAfterFirstClick); + expect(override).toEqual(setFalseAction); + }); + + test("supports real snapshot action override entries", () => { + const close: Action = { type: "close-paywall" }; + const states = [ + { + value: { + id: "state-1", + name: "Selected", + condition: variableEqualsRaw("entry-isSelected", true), + overrides: { + style: {}, + actions: [ + { + id: "entry-1", + pos: "a0", + value: { + interactionId: "interaction-1", + action: close, + }, + }, + ], + }, + }, + } as unknown as { value: NodeState }, + ]; + + const variables: VariableStore = new Map([ + ["entry-isSelected", { key: "boolean", value: true }], + ]); + expect(resolveActionOverride("interaction-1", states, variables)).toEqual(close); + }); +}); diff --git a/packages/paywall-renderer-web-core/src/state-resolver.ts b/packages/paywall-renderer-web-core/src/state-resolver.ts new file mode 100644 index 000000000..d0f4f8b5f --- /dev/null +++ b/packages/paywall-renderer-web-core/src/state-resolver.ts @@ -0,0 +1,62 @@ +import type { Action, NodeState } from "./snapshot-types"; +import { evaluateDNF } from "./evaluator"; +import type { VariableReader } from "./variables"; + +export function resolveStyle>( + baseStyle: TStyle, + states: ReadonlyArray<{ value: NodeState }>, + variables: VariableReader, +): TStyle { + const merged = { ...baseStyle }; + + for (const stateEntry of states) { + const state = stateEntry.value; + if (!state?.condition || !state.overrides) { + continue; + } + if (evaluateDNF(state.condition, variables)) { + const overrides = state.overrides.style as Record; + if (overrides) { + for (const key of Object.keys(overrides)) { + if (overrides[key] !== undefined) { + (merged as Record)[key] = overrides[key]; + } + } + } + } + } + + return merged; +} + +export function resolveActionOverride( + interactionId: string, + states: ReadonlyArray<{ value: NodeState }>, + variables: VariableReader, +): Action | undefined { + let overriddenAction: Action | undefined; + + for (const stateEntry of states) { + const state = stateEntry.value; + if (!state?.condition || !state.overrides) { + continue; + } + if (evaluateDNF(state.condition, variables)) { + const actionOverrides = "actions" in state.overrides ? state.overrides.actions : undefined; + if (actionOverrides) { + for (const overrideCandidate of actionOverrides) { + const override = + "value" in overrideCandidate ? overrideCandidate.value : overrideCandidate; + if (!override) { + continue; + } + if (override.interactionId === interactionId) { + overriddenAction = override.action; + } + } + } + } + } + + return overriddenAction; +} diff --git a/packages/paywall-renderer-web-core/src/styles/background.test.ts b/packages/paywall-renderer-web-core/src/styles/background.test.ts new file mode 100644 index 000000000..8981c5082 --- /dev/null +++ b/packages/paywall-renderer-web-core/src/styles/background.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, test } from "vite-plus/test"; + +import { buildBackgroundStyles, type BackgroundStyleInput } from "./background"; + +const stop = (color: string, position: number) => ({ value: { color, position } }); + +function decodeGradientSvg(backgroundImage: string): string { + const match = /^url\("data:image\/svg\+xml,(.*)"\)$/.exec(backgroundImage); + if (!match) { + throw new Error(`unexpected backgroundImage: ${backgroundImage}`); + } + return decodeURIComponent(match[1]!); +} + +describe("buildBackgroundStyles", () => { + test("disabled background is transparent regardless of type", () => { + expect(buildBackgroundStyles({ backgroundEnabled: false, backgroundType: "gradient" })).toEqual({ + backgroundColor: "transparent", + }); + expect(buildBackgroundStyles({})).toEqual({ backgroundColor: "transparent" }); + }); + + test("solid uses backgroundColor", () => { + expect( + buildBackgroundStyles({ + backgroundEnabled: true, + backgroundType: "solid", + backgroundColor: "rgba(1, 2, 3, 1)", + }), + ).toEqual({ backgroundColor: "rgba(1, 2, 3, 1)" }); + }); + + test("absent backgroundType defaults to solid", () => { + expect( + buildBackgroundStyles({ backgroundEnabled: true, backgroundColor: "rgba(9, 9, 9, 1)" }), + ).toEqual({ backgroundColor: "rgba(9, 9, 9, 1)" }); + }); + + describe("gradient", () => { + const linear: BackgroundStyleInput = { + backgroundEnabled: true, + backgroundType: "gradient", + backgroundGradient: { + kind: "linear", + startX: 0.5, + startY: 0, + endX: 0.5, + endY: 1, + stops: [stop("rgba(255, 255, 255, 1)", 0), stop("rgba(0, 0, 0, 1)", 1)], + }, + }; + + test("emits a normalized SVG data-URI with size/repeat", () => { + const styles = buildBackgroundStyles(linear); + expect(styles.backgroundSize).toBe("100% 100%"); + expect(styles.backgroundRepeat).toBe("no-repeat"); + const svg = decodeGradientSvg(styles.backgroundImage as string); + expect(svg).toContain("preserveAspectRatio='none'"); + expect(svg).toContain("viewBox='0 0 1 1'"); + expect(svg).toContain(""); + expect(svg).toContain(""); + expect(svg).toContain(""); + expect(svg).toContain(""); + }); + + test("sorts stops by position before emitting", () => { + const styles = buildBackgroundStyles({ + ...linear, + backgroundGradient: { + ...linear.backgroundGradient!, + stops: [ + stop("rgba(0, 0, 0, 1)", 1), + stop("rgba(128, 128, 128, 1)", 0.5), + stop("rgba(255, 255, 255, 1)", 0), + ], + }, + }); + const svg = decodeGradientSvg(styles.backgroundImage as string); + const first = svg.indexOf("offset='0'"); + const mid = svg.indexOf("offset='0.5'"); + const last = svg.indexOf("offset='1'"); + expect(first).toBeGreaterThanOrEqual(0); + expect(first).toBeLessThan(mid); + expect(mid).toBeLessThan(last); + }); + + test("accepts logical {color, position} stops (unwrapped shape)", () => { + // A caller that builds a style from a non-CRDT source (preview tree, + // compiled component, hand-written input) passes logical stops. The + // builder must NOT read `.value` blindly and collapse to transparent. + const styles = buildBackgroundStyles({ + backgroundEnabled: true, + backgroundType: "gradient", + backgroundGradient: { + kind: "linear", + startX: 0.5, + startY: 0, + endX: 0.5, + endY: 1, + stops: [ + { color: "rgba(255, 255, 255, 1)", position: 0 }, + { color: "rgba(0, 0, 0, 1)", position: 1 }, + ], + }, + }); + expect(styles.backgroundColor).toBeUndefined(); + const svg = decodeGradientSvg(styles.backgroundImage as string); + expect(svg).toContain(""); + expect(svg).toContain(""); + }); + + test("wrapped and logical stops produce identical output", () => { + const geometry = { + kind: "linear" as const, + startX: 0, + startY: 0, + endX: 1, + endY: 1, + }; + const wrapped = buildBackgroundStyles({ + backgroundEnabled: true, + backgroundType: "gradient", + backgroundGradient: { + ...geometry, + stops: [stop("rgba(1, 2, 3, 1)", 0), stop("rgba(4, 5, 6, 1)", 1)], + }, + }); + const logical = buildBackgroundStyles({ + backgroundEnabled: true, + backgroundType: "gradient", + backgroundGradient: { + ...geometry, + stops: [ + { color: "rgba(1, 2, 3, 1)", position: 0 }, + { color: "rgba(4, 5, 6, 1)", position: 1 }, + ], + }, + }); + expect(logical).toEqual(wrapped); + }); + + test("radial uses cx/cy at start and r = euclidean distance start→end", () => { + const styles = buildBackgroundStyles({ + backgroundEnabled: true, + backgroundType: "gradient", + backgroundGradient: { + kind: "radial", + startX: 0, + startY: 0, + endX: 3, + endY: 4, + stops: [stop("rgba(1, 1, 1, 1)", 0), stop("rgba(2, 2, 2, 1)", 1)], + }, + }); + const svg = decodeGradientSvg(styles.backgroundImage as string); + expect(svg).toContain(""); + }); + + test("a single stop degrades to that solid color", () => { + const styles = buildBackgroundStyles({ + ...linear, + backgroundGradient: { + ...linear.backgroundGradient!, + stops: [stop("rgba(7, 7, 7, 1)", 0)], + }, + }); + expect(styles).toEqual({ backgroundColor: "rgba(7, 7, 7, 1)" }); + }); + + test("zero stops (or absent gradient) is transparent", () => { + expect( + buildBackgroundStyles({ + ...linear, + backgroundGradient: { ...linear.backgroundGradient!, stops: [] }, + }), + ).toEqual({ backgroundColor: "transparent" }); + expect( + buildBackgroundStyles({ backgroundEnabled: true, backgroundType: "gradient" }), + ).toEqual({ backgroundColor: "transparent" }); + }); + }); + + describe("image", () => { + test("maps resize modes to backgroundSize with center position and no-repeat", () => { + const base = { + backgroundEnabled: true, + backgroundType: "image" as const, + }; + const modes = { + cover: "cover", + contain: "contain", + stretch: "100% 100%", + center: "auto", + } as const; + for (const [resizeMode, size] of Object.entries(modes)) { + const styles = buildBackgroundStyles({ + ...base, + backgroundImage: { + url: "https://example.com/a.png", + resizeMode: resizeMode as keyof typeof modes, + }, + }); + expect(styles).toEqual({ + backgroundImage: 'url("https://example.com/a.png")', + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + backgroundSize: size, + }); + } + }); + + test("empty url is transparent", () => { + expect( + buildBackgroundStyles({ + backgroundEnabled: true, + backgroundType: "image", + backgroundImage: { url: "", resizeMode: "cover" }, + }), + ).toEqual({ backgroundColor: "transparent" }); + }); + + test("encodes special characters in the url", () => { + const styles = buildBackgroundStyles({ + backgroundEnabled: true, + backgroundType: "image", + backgroundImage: { url: "https://example.com/a b.png?x=1&y=2", resizeMode: "cover" }, + }); + expect(styles.backgroundImage).toBe('url("https://example.com/a%20b.png?x=1&y=2")'); + }); + }); +}); diff --git a/packages/paywall-renderer-web-core/src/styles/background.ts b/packages/paywall-renderer-web-core/src/styles/background.ts new file mode 100644 index 000000000..b48e0c050 --- /dev/null +++ b/packages/paywall-renderer-web-core/src/styles/background.ts @@ -0,0 +1,256 @@ +import type { Properties } from "csstype"; + +/** A single gradient color stop, decoded from the mimic CRDT array wrapper. */ +interface GradientStop { + readonly color: string; + readonly position: number; +} + +/** + * A gradient stop as it can appear on a decoded style. The mimic CRDT array + * decoder wraps each element as `{ id, pos, value: { color, position } }` (and + * a decode failure leaves `value` `undefined`), but callers that build a style + * from a non-CRDT source (the preview-tree wire shape, hand-written test input, + * a compiled component) pass the logical `{ color, position }` directly. Both + * are accepted so a stop can never silently collapse to transparent because the + * caller used the "wrong" shape — see {@link normalizeStop}. + */ +type GradientStopEntry = GradientStop | { readonly value: GradientStop | undefined }; + +/** + * Reads the logical `{ color, position }` from either stop shape. Returns + * `undefined` when the value is missing (a failed decode) or does not carry a + * usable `color`, so callers filter it out. + */ +function normalizeStop(entry: GradientStopEntry | undefined): GradientStop | undefined { + if (entry === undefined || entry === null) { + return undefined; + } + // CRDT-wrapped entry: `{ id, pos, value: { color, position } }`. The logical + // shape has a string `color`, so an object `value` disambiguates the wrapper. + const candidate = + "value" in entry && typeof (entry as { value: unknown }).value === "object" + ? (entry as { value: GradientStop | undefined }).value + : (entry as GradientStop); + if (candidate === undefined || candidate === null || typeof candidate.color !== "string") { + return undefined; + } + return { color: candidate.color, position: candidate.position ?? 0 }; +} + +/** + * The background-related subset of a node's decoded style. `stops` accept both + * the mimic CRDT snapshot shape (`{ id, pos, value }` entries) and the logical + * `{ color, position }` shape; {@link normalizeStop} reconciles them. + */ +export interface BackgroundStyleInput { + readonly backgroundEnabled?: boolean; + readonly backgroundColor?: string; + readonly backgroundType?: "solid" | "gradient" | "image"; + readonly backgroundGradient?: { + readonly kind: "linear" | "radial"; + readonly startX: number; + readonly startY: number; + readonly endX: number; + readonly endY: number; + readonly stops: ReadonlyArray; + }; + readonly backgroundImage?: { + readonly url: string; + readonly resizeMode: "cover" | "contain" | "stretch" | "center"; + }; +} + +const RESIZE_MODE_TO_BACKGROUND_SIZE: Record< + NonNullable["resizeMode"], + string +> = { + center: "auto", + contain: "contain", + cover: "cover", + stretch: "100% 100%", +}; + +/** + * Builds an inline SVG whose gradient uses `objectBoundingBox` units, so the + * two-point normalized geometry (`0..1` node space) maps exactly onto the + * element regardless of its aspect ratio. `preserveAspectRatio="none"` lets the + * 1×1 viewBox stretch to fill. + */ +/** The geometry (start/end + kind) a gradient SVG needs; stops are passed separately. */ +interface GradientGeometry { + readonly kind: "linear" | "radial"; + readonly startX: number; + readonly startY: number; + readonly endX: number; + readonly endY: number; +} + +function buildGradientSvg(gradient: GradientGeometry, stops: readonly GradientStop[]): string { + const stopEls = stops + .map( + (stop) => + ``, + ) + .join(""); + + const def = + gradient.kind === "radial" + ? // Radius is the euclidean distance from start→end; cx/cy anchor at start. + (() => { + const r = Math.hypot(gradient.endX - gradient.startX, gradient.endY - gradient.startY); + return `${stopEls}`; + })() + : `${stopEls}`; + + return `${def}`; +} + +/** + * Lowers a node's background style onto CSS. `backgroundEnabled` gates every + * type; when disabled (or when a type resolves to nothing renderable) the node + * is `transparent`. + * + * - `solid` → `backgroundColor` (legacy behavior). + * - `gradient` → an SVG data-URI `backgroundImage`, so the two-point normalized + * geometry stays exact independent of element aspect ratio. Stops are sorted + * by position. A single stop degrades to that solid color; zero stops to + * `transparent`. + * - `image` → `backgroundImage: url(...)` with the resize mode mapped to + * `backgroundSize`. An empty url resolves to `transparent`. + * + * The returned fragment always sets exactly one of `backgroundColor` or the + * `backgroundImage`/`backgroundSize`/`backgroundRepeat`/`backgroundPosition` + * quad, so callers can spread it over a base style without leftover keys from a + * previous background type. + */ +export function buildBackgroundStyles(style: BackgroundStyleInput): Properties { + if (!style.backgroundEnabled) { + return { backgroundColor: "transparent" }; + } + + const type = style.backgroundType ?? "solid"; + + if (type === "gradient") { + const gradient = style.backgroundGradient; + // Normalize each stop from either the CRDT-wrapped or logical shape, drop + // undecodable entries, then sort by position along the start→end line. + const stops = (gradient?.stops ?? []) + .map(normalizeStop) + .filter((stop): stop is GradientStop => stop !== undefined) + .sort((a, b) => a.position - b.position); + if (!gradient || stops.length === 0) { + return { backgroundColor: "transparent" }; + } + if (stops.length === 1) { + return { backgroundColor: stops[0]!.color }; + } + const svg = buildGradientSvg(gradient, stops); + return { + backgroundImage: `url("data:image/svg+xml,${encodeURIComponent(svg)}")`, + backgroundRepeat: "no-repeat", + backgroundSize: "100% 100%", + }; + } + + if (type === "image") { + const url = style.backgroundImage?.url ?? ""; + if (url === "") { + return { backgroundColor: "transparent" }; + } + const resizeMode = style.backgroundImage?.resizeMode ?? "cover"; + // encodeURI leaves the double-quote untouched; escape it too so it cannot + // break out of the quoted `url("…")` token. + const safeUrl = encodeURI(url).replace(/"/g, "%22"); + return { + backgroundImage: `url("${safeUrl}")`, + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + backgroundSize: RESIZE_MODE_TO_BACKGROUND_SIZE[resizeMode], + }; + } + + // solid + return { backgroundColor: style.backgroundColor }; +} + +/** + * The background subset of a §3 preview-tree node style. Unlike + * {@link BackgroundStyleInput}, this is the OSS-built wire shape: `stops` are + * plain `{ color, position }` objects (no CRDT `{ id, pos, value }` envelope), + * and there is no `backgroundEnabled` gate — a non-`solid` `backgroundType` + * already implies the background is enabled (the mimic→tree lowering only emits + * a gradient/image type when it was enabled). + */ +export interface PreviewBackgroundStyleInput { + readonly backgroundColor?: string | undefined; + readonly backgroundType?: "solid" | "gradient" | "image" | undefined; + readonly backgroundGradient?: + | { + readonly kind: "linear" | "radial"; + readonly startX: number; + readonly startY: number; + readonly endX: number; + readonly endY: number; + readonly stops: readonly GradientStop[]; + } + | undefined; + readonly backgroundImage?: + | { + readonly url: string; + readonly resizeMode: "cover" | "contain" | "stretch" | "center"; + } + | undefined; +} + +/** + * Lowers a preview-tree node's background style onto CSS. Byte-compatible with + * {@link buildBackgroundStyles} for the `gradient`/`image`/`solid` cases, but + * reads the plain (un-enveloped) preview-tree wire shape and treats a non-solid + * `backgroundType` as enabled. Returns only the background CSS fragment so + * callers spread it over a base style. + */ +export function buildPreviewBackgroundStyles(style: PreviewBackgroundStyleInput): Properties { + const type = style.backgroundType ?? "solid"; + + if (type === "gradient") { + const gradient = style.backgroundGradient; + // Normalize defensively: the preview wire shape is logical `{ color, + // position }`, but accept a CRDT-wrapped stop too so a mislowered tree can + // never silently collapse the gradient to transparent. + const stops = (gradient?.stops ?? []) + .map(normalizeStop) + .filter((stop): stop is GradientStop => stop !== undefined) + .sort((a, b) => a.position - b.position); + if (!gradient || stops.length === 0) { + return { backgroundColor: "transparent" }; + } + if (stops.length === 1) { + return { backgroundColor: stops[0]!.color }; + } + const svg = buildGradientSvg(gradient, stops); + return { + backgroundImage: `url("data:image/svg+xml,${encodeURIComponent(svg)}")`, + backgroundRepeat: "no-repeat", + backgroundSize: "100% 100%", + }; + } + + if (type === "image") { + const url = style.backgroundImage?.url ?? ""; + if (url === "") { + return { backgroundColor: "transparent" }; + } + const resizeMode = style.backgroundImage?.resizeMode ?? "cover"; + const safeUrl = encodeURI(url).replace(/"/g, "%22"); + return { + backgroundImage: `url("${safeUrl}")`, + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + backgroundSize: RESIZE_MODE_TO_BACKGROUND_SIZE[resizeMode], + }; + } + + // solid + return style.backgroundColor === undefined ? {} : { backgroundColor: style.backgroundColor }; +} diff --git a/packages/paywall-renderer-web-core/src/styles/index.ts b/packages/paywall-renderer-web-core/src/styles/index.ts new file mode 100644 index 000000000..d87328e88 --- /dev/null +++ b/packages/paywall-renderer-web-core/src/styles/index.ts @@ -0,0 +1,12 @@ +export { + buildBackgroundStyles, + buildPreviewBackgroundStyles, + type BackgroundStyleInput, + type PreviewBackgroundStyleInput, +} from "./background"; +export { buildViewStyles, type ViewStyleInput } from "./view-styles"; +export { buildScrollViewStyles, type ScrollViewOptions } from "./scroll-view-styles"; +export { buildPathStyles, type PathSvgAttributes } from "./path-styles"; +export { buildScreenContainerStyles, buildScreenLayoutStyles } from "./screen-styles"; +export { buildShapeContainerStyles } from "./shape-styles"; +export { buildTextStyles } from "./text-styles"; diff --git a/packages/paywall-renderer-web-core/src/styles/node-styles.test.ts b/packages/paywall-renderer-web-core/src/styles/node-styles.test.ts new file mode 100644 index 000000000..fb0a2fbab --- /dev/null +++ b/packages/paywall-renderer-web-core/src/styles/node-styles.test.ts @@ -0,0 +1,268 @@ +import { ScrollViewNode, ShapeNode, TextNode, ViewNode } from "@voidhash/mimic-schema"; +import type { Properties } from "csstype"; +import { describe, expect, test } from "vite-plus/test"; + +import { buildPreviewNodeStyles } from "../preview-tree/styles"; +import { buildScrollViewStyles } from "./scroll-view-styles"; +import { buildShapeContainerStyles } from "./shape-styles"; +import { buildTextStyles } from "./text-styles"; +import { px, pxOrAuto } from "./utils"; +import { buildViewStyles } from "./view-styles"; + +// Encode/decode round-trips through the real node data structs materialize +// schema defaults, so fixtures carry the exact snapshot shape the engine +// produces. +function makeViewStyle(style?: Parameters[0]["style"]) { + return ViewNode.data.decode(ViewNode.data.encode({ style })).style; +} + +function makeScrollViewStyle(style?: Parameters[0]["style"]) { + return ScrollViewNode.data.decode(ScrollViewNode.data.encode({ style })).style; +} + +function makeShapeStyle(style?: Parameters[0]["style"]) { + return ShapeNode.data.decode(ShapeNode.data.encode({ style })).style; +} + +function makeTextStyle(style?: Parameters[0]["style"]) { + return TextNode.data.decode(TextNode.data.encode({ style })).style; +} + +describe("pxOrAuto", () => { + test('passes through "auto" and absent values as the CSS auto keyword', () => { + expect(pxOrAuto("auto")).toBe("auto"); + expect(pxOrAuto(undefined)).toBe("auto"); + }); + + test("converts numbers to px", () => { + expect(pxOrAuto(0)).toBe("0"); + expect(pxOrAuto(12)).toBe("12px"); + expect(px(0)).toBe("0"); + expect(px(8)).toBe("8px"); + }); +}); + +describe("buildViewStyles", () => { + test("schema defaults yield auto (hug) dimensions without constraints or flex", () => { + const styles = buildViewStyles(makeViewStyle()); + expect(styles.width).toBe("auto"); + expect(styles.height).toBe("auto"); + expect(styles.minWidth).toBeUndefined(); + expect(styles.maxWidth).toBeUndefined(); + expect(styles.minHeight).toBeUndefined(); + expect(styles.maxHeight).toBeUndefined(); + expect(styles.flex).toBeUndefined(); + }); + + test('schema defaults yield relative flow with no positioning offsets', () => { + const styles = buildViewStyles(makeViewStyle()); + expect(styles.position).toBe("relative"); + expect(styles.left).toBeUndefined(); + expect(styles.top).toBeUndefined(); + expect(styles.right).toBeUndefined(); + expect(styles.bottom).toBeUndefined(); + }); + + test("absolute position with numeric offsets emits px (including zero)", () => { + const styles = buildViewStyles( + makeViewStyle({ position: "absolute", top: 10, left: 20, bottom: 0 }), + ); + expect(styles.position).toBe("absolute"); + expect(styles.top).toBe("10px"); + expect(styles.left).toBe("20px"); + expect(styles.bottom).toBe("0"); + // An "auto" (default) offset is the CSS default and stays unset. + expect(styles.right).toBeUndefined(); + }); + + test('"auto" width/height (hug contents) lower to the CSS auto keyword', () => { + const styles = buildViewStyles(makeViewStyle({ width: "auto", height: "auto" })); + expect(styles.width).toBe("auto"); + expect(styles.height).toBe("auto"); + }); + + test("explicit flex and min/max constraints emit values (including zero)", () => { + const styles = buildViewStyles(makeViewStyle({ flex: 1, maxWidth: 320, minHeight: 0 })); + expect(styles.flex).toBe(1); + expect(styles.maxWidth).toBe("320px"); + expect(styles.minHeight).toBe("0"); + expect(styles.minWidth).toBeUndefined(); + expect(styles.maxHeight).toBeUndefined(); + }); + + test("disabled background lowers to transparent", () => { + const styles = buildViewStyles(makeViewStyle()); + expect(styles.backgroundColor).toBe("transparent"); + }); + + test("enabled solid background uses backgroundColor", () => { + const styles = buildViewStyles( + makeViewStyle({ backgroundEnabled: true, backgroundColor: "rgba(1, 2, 3, 1)" }), + ); + expect(styles.backgroundColor).toBe("rgba(1, 2, 3, 1)"); + }); + + test("gradient background lowers to an SVG data-URI through the real schema", () => { + const styles = buildViewStyles( + makeViewStyle({ + backgroundEnabled: true, + backgroundType: "gradient", + backgroundGradient: { + kind: "linear", + startX: 0, + startY: 0, + endX: 1, + endY: 1, + stops: [ + { color: "rgba(255, 255, 255, 1)", position: 0 }, + { color: "rgba(0, 0, 0, 1)", position: 1 }, + ], + }, + }), + ); + expect(styles.backgroundSize).toBe("100% 100%"); + expect(String(styles.backgroundImage)).toContain("data:image/svg+xml,"); + expect(styles.backgroundColor).toBeUndefined(); + }); + + test("image background lowers to url() through the real schema", () => { + const styles = buildViewStyles( + makeViewStyle({ + backgroundEnabled: true, + backgroundType: "image", + backgroundImage: { url: "https://example.com/bg.png", resizeMode: "contain" }, + }), + ); + expect(styles.backgroundImage).toBe('url("https://example.com/bg.png")'); + expect(styles.backgroundSize).toBe("contain"); + expect(styles.backgroundPosition).toBe("center"); + }); +}); + +describe("buildShapeContainerStyles", () => { + test('"auto" dimensions and absent constraints mirror the flex semantics', () => { + const styles = buildShapeContainerStyles(makeShapeStyle({ width: "auto", height: 24 })); + expect(styles.width).toBe("auto"); + expect(styles.height).toBe("24px"); + expect(styles.minWidth).toBeUndefined(); + expect(styles.flex).toBeUndefined(); + }); +}); + +describe("buildTextStyles", () => { + test("absent min/max constraints and flex stay unset; present ones emit px", () => { + const defaults = buildTextStyles(makeTextStyle()); + expect(defaults.minWidth).toBeUndefined(); + expect(defaults.maxHeight).toBeUndefined(); + expect(defaults.flex).toBeUndefined(); + + const constrained = buildTextStyles(makeTextStyle({ flex: 2, minWidth: 40 })); + expect(constrained.flex).toBe(2); + expect(constrained.minWidth).toBe("40px"); + }); + + test("schema defaults yield relative flow with no positioning offsets", () => { + const styles = buildTextStyles(makeTextStyle()); + expect(styles.position).toBe("relative"); + expect(styles.left).toBeUndefined(); + expect(styles.top).toBeUndefined(); + expect(styles.right).toBeUndefined(); + expect(styles.bottom).toBeUndefined(); + }); + + test("absolute position with numeric offsets emits px (including zero)", () => { + const styles = buildTextStyles( + makeTextStyle({ position: "absolute", top: 10, left: 0, bottom: 4 }), + ); + expect(styles.position).toBe("absolute"); + expect(styles.top).toBe("10px"); + // A zero offset still emits (lowered to the unitless "0"). + expect(styles.left).toBe("0"); + expect(styles.bottom).toBe("4px"); + // An "auto" (default) offset is the CSS default and stays unset. + expect(styles.right).toBeUndefined(); + }); +}); + +describe("buildScrollViewStyles", () => { + test("vertical (default) scrolls on Y and hides X, keeping column flow", () => { + const styles = buildScrollViewStyles(makeScrollViewStyle(), { + horizontal: false, + showsScrollIndicator: true, + }); + expect(styles.overflowY).toBe("auto"); + expect(styles.overflowX).toBe("hidden"); + expect(styles.flexDirection).toBe("column"); + expect(styles.WebkitOverflowScrolling).toBe("touch"); + // Indicator visible → no scrollbar suppression. + expect(styles.scrollbarWidth).toBeUndefined(); + }); + + test("horizontal scrolls on X, hides Y and forces row flow", () => { + const styles = buildScrollViewStyles(makeScrollViewStyle({ flexDirection: "column" }), { + horizontal: true, + showsScrollIndicator: true, + }); + expect(styles.overflowX).toBe("auto"); + expect(styles.overflowY).toBe("hidden"); + // RN horizontal ScrollView overrides the authored direction to row. + expect(styles.flexDirection).toBe("row"); + }); + + test("hidden indicator suppresses the scrollbar width", () => { + const styles = buildScrollViewStyles(makeScrollViewStyle(), { + horizontal: false, + showsScrollIndicator: false, + }); + expect(styles.scrollbarWidth).toBe("none"); + }); + + // Yoga defaults a flex item's min main size to 0; CSS defaults it to `auto` + // (content floor), which would keep a content-tall scrollView from ever + // shrinking, so `overflowY: auto` never engages. We default `min-*: 0`. + test("default min sizes are 0 so the box can shrink below content and scroll", () => { + const styles = buildScrollViewStyles(makeScrollViewStyle(), { + horizontal: false, + showsScrollIndicator: true, + }); + expect(styles.minHeight).toBe(0); + expect(styles.minWidth).toBe(0); + }); + + test("author-set min sizes are preserved over the 0 default", () => { + const styles = buildScrollViewStyles(makeScrollViewStyle({ minHeight: 100, minWidth: 40 }), { + horizontal: false, + showsScrollIndicator: true, + }); + expect(styles.minHeight).toBe("100px"); + expect(styles.minWidth).toBe("40px"); + }); + + // The document `scrollView` lowering hand-mirrors the preview-tree `scroll` + // lowering (`SCROLL_BASE`); nested compositions must render identically, so + // the scroll-specific CSS must never drift. Compare the real implementations + // rather than duplicating literals. + test("default (vertical) scroll CSS matches the preview-tree scroll lowering", () => { + const documentStyles = buildScrollViewStyles(makeScrollViewStyle(), { + horizontal: false, + showsScrollIndicator: true, + }); + const previewStyles = buildPreviewNodeStyles({}, "scroll"); + + const scrollKeys = [ + "overflowX", + "overflowY", + "WebkitOverflowScrolling", + "minHeight", + "minWidth", + ] as const; + const pick = (styles: Properties) => + Object.fromEntries(scrollKeys.map((key) => [key, styles[key]])); + + expect(pick(documentStyles)).toEqual(pick(previewStyles)); + // Guard against the keys silently going absent from both sides. + expect(documentStyles.overflowY).toBe("auto"); + expect(documentStyles.overflowX).toBe("hidden"); + expect(documentStyles.WebkitOverflowScrolling).toBe("touch"); + }); +}); diff --git a/packages/paywall-renderer-web-core/src/styles/path-styles.ts b/packages/paywall-renderer-web-core/src/styles/path-styles.ts new file mode 100644 index 000000000..4c37e67f9 --- /dev/null +++ b/packages/paywall-renderer-web-core/src/styles/path-styles.ts @@ -0,0 +1,27 @@ +import type { PathNodeData } from "@voidhash/mimic-schema"; + +export interface PathSvgAttributes { + fill: string; + fillRule: "nonzero" | "evenodd"; + fillOpacity: number; + stroke: string; + strokeWidth: number; + strokeOpacity: number; + strokeLinecap: "butt" | "round" | "square"; + strokeLinejoin: "miter" | "round" | "bevel"; + opacity: number; +} + +export function buildPathStyles(style: PathNodeData["data"]["style"]): PathSvgAttributes { + return { + fill: style.fillEnabled ? style.fillColor : "none", + fillRule: style.fillRule, + fillOpacity: style.fillOpacity, + stroke: style.strokeEnabled ? style.strokeColor : "none", + strokeWidth: style.strokeWidth, + strokeOpacity: style.strokeOpacity, + strokeLinecap: style.strokeLinecap, + strokeLinejoin: style.strokeLinejoin, + opacity: style.opacity, + }; +} diff --git a/packages/paywall-renderer-web-core/src/styles/screen-styles.ts b/packages/paywall-renderer-web-core/src/styles/screen-styles.ts new file mode 100644 index 000000000..8ae97a88e --- /dev/null +++ b/packages/paywall-renderer-web-core/src/styles/screen-styles.ts @@ -0,0 +1,34 @@ +import type { ScreenNodeData } from "@voidhash/mimic-schema"; +import type { Properties } from "csstype"; + +import { buildBackgroundStyles } from "./background"; +import { px } from "./utils"; + +export function buildScreenContainerStyles(style: ScreenNodeData["data"]["style"]): Properties { + const styles: Properties = { + boxSizing: "border-box", + height: "100vh", + overflow: "hidden", + width: "100vw", + }; + + Object.assign(styles, buildBackgroundStyles(style)); + + return styles; +} + +export function buildScreenLayoutStyles(style: ScreenNodeData["data"]["style"]): Properties { + return { + alignItems: style.alignItems, + display: style.display, + flexDirection: style.flexDirection, + gap: px(style.gap ?? 0), + height: "100vh", + justifyContent: style.justifyContent, + paddingBottom: px(style.paddingBottom ?? 0), + paddingLeft: px(style.paddingLeft ?? 0), + paddingRight: px(style.paddingRight ?? 0), + paddingTop: px(style.paddingTop ?? 0), + width: "100vw", + }; +} diff --git a/packages/paywall-renderer-web-core/src/styles/scroll-view-styles.ts b/packages/paywall-renderer-web-core/src/styles/scroll-view-styles.ts new file mode 100644 index 000000000..4ae1a37cf --- /dev/null +++ b/packages/paywall-renderer-web-core/src/styles/scroll-view-styles.ts @@ -0,0 +1,62 @@ +import type { Properties } from "csstype"; + +import { buildViewStyles, type ViewStyleInput } from "./view-styles"; + +/** RN ScrollView node data driving the overflow/axis lowering. */ +export interface ScrollViewOptions { + /** RN `horizontal`: lay children out along the row axis and scroll on X. */ + horizontal: boolean; + /** RN `showsScrollIndicator`: whether the native scrollbar is visible. */ + showsScrollIndicator: boolean; +} + +/** + * Lowers a scrollView node onto the web. Builds the shared view style surface + * ({@link buildViewStyles}) then layers RN ScrollView overflow semantics on top, + * mirroring the preview-tree `SCROLL_BASE` (vertical `overflowY: auto`) but + * honoring the document's `horizontal`/`showsScrollIndicator` fields: + * + * - vertical (default): `overflowY: auto`, `overflowX: hidden`; + * - `horizontal: true`: `overflowX: auto`, `overflowY: hidden`, and + * `flexDirection: row` (RN horizontal ScrollView flows children in a row + * regardless of the authored direction); + * - `showsScrollIndicator: false`: `scrollbarWidth: none`. NOTE: this hides the + * scrollbar in Firefox and standards-compliant engines; WebKit needs a + * `::-webkit-scrollbar { display: none }` rule which cannot be expressed as + * an inline style, so it is not applied by these inline-only renderers. + * + * Yoga-vs-CSS min-size: React Native (Yoga) defaults a flex item's minimum main + * size to 0, but CSS defaults `min-height`/`min-width` to `auto` — which floors + * a flex item at its content size. Without an explicit `min-*: 0`, a scrollView + * taller than the viewport can never shrink below its content, so `flex-shrink` + * is inert and `overflowY: auto` has nothing to scroll. We therefore default + * `minHeight`/`minWidth` to `0` (matching the preview-tree `VIEW_BASE`), but + * only when the author hasn't set them — {@link buildViewStyles} emits those + * keys only when present, so authored values are already in `styles` and win. + */ +export function buildScrollViewStyles( + style: ViewStyleInput, + { horizontal, showsScrollIndicator }: ScrollViewOptions, +): Properties { + const styles = buildViewStyles(style); + + styles.minHeight ??= 0; + styles.minWidth ??= 0; + + styles.WebkitOverflowScrolling = "touch"; + + if (horizontal) { + styles.flexDirection = "row"; + styles.overflowX = "auto"; + styles.overflowY = "hidden"; + } else { + styles.overflowX = "hidden"; + styles.overflowY = "auto"; + } + + if (!showsScrollIndicator) { + styles.scrollbarWidth = "none"; + } + + return styles; +} diff --git a/packages/paywall-renderer-web-core/src/styles/shape-styles.ts b/packages/paywall-renderer-web-core/src/styles/shape-styles.ts new file mode 100644 index 000000000..996aac3ef --- /dev/null +++ b/packages/paywall-renderer-web-core/src/styles/shape-styles.ts @@ -0,0 +1,43 @@ +import type { ShapeNodeData } from "@voidhash/mimic-schema"; +import type { Properties } from "csstype"; + +import { px, pxOrAuto } from "./utils"; + +export function buildShapeContainerStyles(style: ShapeNodeData["data"]["style"]): Properties { + const styles: Properties = { + boxSizing: "border-box", + display: style.display === "none" ? "none" : "block", + height: pxOrAuto(style.height), + marginBottom: px(style.marginBottom ?? 0), + marginLeft: px(style.marginLeft ?? 0), + marginRight: px(style.marginRight ?? 0), + marginTop: px(style.marginTop ?? 0), + opacity: style.opacity, + position: "relative", + width: pxOrAuto(style.width), + }; + + // Min/max constraints — absent means unconstrained + if (style.minWidth !== undefined) { + styles.minWidth = px(style.minWidth); + } + if (style.maxWidth !== undefined) { + styles.maxWidth = px(style.maxWidth); + } + if (style.minHeight !== undefined) { + styles.minHeight = px(style.minHeight); + } + if (style.maxHeight !== undefined) { + styles.maxHeight = px(style.maxHeight); + } + + // Flex child properties — absent means no explicit flex + if (style.flex !== undefined) { + styles.flex = style.flex; + } + if (style.alignSelf !== "auto") { + styles.alignSelf = style.alignSelf; + } + + return styles; +} diff --git a/packages/paywall-renderer-web-core/src/styles/text-styles.ts b/packages/paywall-renderer-web-core/src/styles/text-styles.ts new file mode 100644 index 000000000..ced44120e --- /dev/null +++ b/packages/paywall-renderer-web-core/src/styles/text-styles.ts @@ -0,0 +1,72 @@ +import type { TextNodeData } from "@voidhash/mimic-schema"; +import type { Properties } from "csstype"; + +import { px } from "./utils"; + +export function buildTextStyles(style: TextNodeData["data"]["style"]): Properties { + const styles: Properties = { + color: style.color, + display: style.display, + fontSize: px(style.fontSize), + fontWeight: style.fontWeight, + letterSpacing: px(style.letterSpacing), + lineHeight: style.lineHeight, + marginBottom: px(style.marginBottom ?? 0), + marginLeft: px(style.marginLeft ?? 0), + marginRight: px(style.marginRight ?? 0), + marginTop: px(style.marginTop ?? 0), + opacity: style.opacity, + overflow: style.overflow, + position: style.position ?? "relative", + textAlign: style.textAlign, + }; + + // Min/max constraints — absent means unconstrained + if (style.minWidth !== undefined) { + styles.minWidth = px(style.minWidth); + } + if (style.maxWidth !== undefined) { + styles.maxWidth = px(style.maxWidth); + } + if (style.minHeight !== undefined) { + styles.minHeight = px(style.minHeight); + } + if (style.maxHeight !== undefined) { + styles.maxHeight = px(style.maxHeight); + } + + // Position offsets — a numeric offset emits px; the "auto" default (or absent) + // is the CSS default and is left unset so it never overrides parent flow. + if (typeof style.left === "number") { + styles.left = px(style.left); + } + if (typeof style.top === "number") { + styles.top = px(style.top); + } + if (typeof style.right === "number") { + styles.right = px(style.right); + } + if (typeof style.bottom === "number") { + styles.bottom = px(style.bottom); + } + + // Border + if (style.borderEnabled) { + styles.borderTopWidth = px(style.borderTopWidth ?? 0); + styles.borderRightWidth = px(style.borderRightWidth ?? 0); + styles.borderBottomWidth = px(style.borderBottomWidth ?? 0); + styles.borderLeftWidth = px(style.borderLeftWidth ?? 0); + styles.borderColor = style.borderColor; + styles.borderStyle = style.borderStyle; + } + + // Flex child properties — absent means no explicit flex + if (style.flex !== undefined) { + styles.flex = style.flex; + } + if (style.alignSelf !== "auto") { + styles.alignSelf = style.alignSelf; + } + + return styles; +} diff --git a/packages/paywall-renderer-web-core/src/styles/utils.ts b/packages/paywall-renderer-web-core/src/styles/utils.ts new file mode 100644 index 000000000..4062892fa --- /dev/null +++ b/packages/paywall-renderer-web-core/src/styles/utils.ts @@ -0,0 +1,18 @@ +/** + * Converts a numeric value to a pixel string. + * Returns 0 as "0" (no unit needed), otherwise appends "px". + */ +export function px(value: number): string { + return value === 0 ? "0" : `${value}px`; +} + +/** + * Converts a numeric value to a pixel string. The explicit `"auto"` literal + * (hug contents) and absent values pass through as the CSS keyword "auto". + */ +export function pxOrAuto(value: number | "auto" | undefined): string { + if (value === "auto" || value === undefined) { + return "auto"; + } + return px(value); +} diff --git a/packages/paywall-renderer-web-core/src/styles/view-styles.ts b/packages/paywall-renderer-web-core/src/styles/view-styles.ts new file mode 100644 index 000000000..d83c4cbab --- /dev/null +++ b/packages/paywall-renderer-web-core/src/styles/view-styles.ts @@ -0,0 +1,99 @@ +import type { ViewNodeData } from "@voidhash/mimic-schema"; +import type { Properties } from "csstype"; + +import { buildBackgroundStyles } from "./background"; +import { px, pxOrAuto } from "./utils"; + +type ViewStyle = ViewNodeData["data"]["style"]; + +type PositionField = "position" | "left" | "top" | "right" | "bottom"; + +/** + * View style with the absolute-positioning fields optional, so screen styles + * (which don't carry them) can be lowered through the same converter and fall + * back to the relative-flow defaults. + */ +export type ViewStyleInput = Omit & + Partial>; + +export function buildViewStyles(style: ViewStyleInput): Properties { + const styles: Properties = { + alignItems: style.alignItems, + boxSizing: "border-box", + display: style.display, + flexDirection: style.flexDirection, + gap: px(style.gap ?? 0), + height: pxOrAuto(style.height), + justifyContent: style.justifyContent, + marginBottom: px(style.marginBottom ?? 0), + marginLeft: px(style.marginLeft ?? 0), + marginRight: px(style.marginRight ?? 0), + marginTop: px(style.marginTop ?? 0), + opacity: style.opacity, + overflow: style.overflow, + paddingBottom: px(style.paddingBottom ?? 0), + paddingLeft: px(style.paddingLeft ?? 0), + paddingRight: px(style.paddingRight ?? 0), + paddingTop: px(style.paddingTop ?? 0), + position: style.position ?? "relative", + width: pxOrAuto(style.width), + }; + + // Min/max constraints — absent means unconstrained + if (style.minWidth !== undefined) { + styles.minWidth = px(style.minWidth); + } + if (style.maxWidth !== undefined) { + styles.maxWidth = px(style.maxWidth); + } + if (style.minHeight !== undefined) { + styles.minHeight = px(style.minHeight); + } + if (style.maxHeight !== undefined) { + styles.maxHeight = px(style.maxHeight); + } + + // Position offsets — a numeric offset emits px; the "auto" default (or absent) + // is the CSS default and is left unset so it never overrides parent flow. + if (typeof style.left === "number") { + styles.left = px(style.left); + } + if (typeof style.top === "number") { + styles.top = px(style.top); + } + if (typeof style.right === "number") { + styles.right = px(style.right); + } + if (typeof style.bottom === "number") { + styles.bottom = px(style.bottom); + } + + // Background + Object.assign(styles, buildBackgroundStyles(style)); + + // Border + if (style.borderEnabled) { + styles.borderTopWidth = px(style.borderTopWidth ?? 0); + styles.borderRightWidth = px(style.borderRightWidth ?? 0); + styles.borderBottomWidth = px(style.borderBottomWidth ?? 0); + styles.borderLeftWidth = px(style.borderLeftWidth ?? 0); + styles.borderColor = style.borderColor; + styles.borderStyle = style.borderStyle; + } + + // Border radius + styles.borderTopLeftRadius = px(style.borderTopLeftRadius ?? 0); + styles.borderTopRightRadius = px(style.borderTopRightRadius ?? 0); + styles.borderBottomRightRadius = px(style.borderBottomRightRadius ?? 0); + styles.borderBottomLeftRadius = px(style.borderBottomLeftRadius ?? 0); + + // Flex child properties — absent means no explicit flex + if (style.flex !== undefined) { + styles.flex = style.flex; + } + if (style.alignSelf !== "auto") { + styles.alignSelf = style.alignSelf; + } + + return styles; +} diff --git a/packages/paywall-renderer-web-core/src/types.ts b/packages/paywall-renderer-web-core/src/types.ts new file mode 100644 index 000000000..5ccb0bc0b --- /dev/null +++ b/packages/paywall-renderer-web-core/src/types.ts @@ -0,0 +1,91 @@ +import type { + CodeComponentNodeData, + ComponentNodeData, + LibraryNodeData, + NodeType as SchemaNodeType, + PathNodeData, + RootNodeData, + ScreenNodeData, + ScrollViewNodeData, + ShapeNodeData, + TextNodeData, + ViewNodeData, +} from "@voidhash/mimic-schema"; + +export type NodeType = SchemaNodeType; + +/** + * A single document root in the workspace-engine tree snapshot shape: + * `{id, type, parentId, pos, data, children}` with all node payload fields + * nested under `data`. The engine's document snapshot is an array of roots; + * consumers of this package receive exactly one root (`roots[0]`). + */ +export type SnapshotNode = + | RootSnapshotNode + | ScreenSnapshotNode + | ViewSnapshotNode + | ScrollViewSnapshotNode + | TextSnapshotNode + | ShapeSnapshotNode + | PathSnapshotNode + | ComponentSnapshotNode + | LibrarySnapshotNode + | CodeComponentSnapshotNode; + +export type RootSnapshotNode = Omit & { + children: readonly SnapshotNode[]; +}; + +export type ScreenSnapshotNode = Omit & { + children: readonly SnapshotNode[]; +}; + +export type ViewSnapshotNode = Omit & { + children: readonly SnapshotNode[]; +}; + +export type ScrollViewSnapshotNode = Omit & { + children: readonly SnapshotNode[]; +}; + +export type TextSnapshotNode = Omit & { + children: readonly SnapshotNode[]; +}; + +export type ShapeSnapshotNode = Omit & { + children: readonly SnapshotNode[]; +}; + +export type PathSnapshotNode = Omit & { + children: readonly SnapshotNode[]; +}; + +/** + * Snapshot of a `component` node. `ComponentNodeData["children"]` is widened + * by the recursion-breaking annotation in mimic-schema, so it is replaced + * with the renderer-side union. Component nodes carry no + * style/states/localVariables — consumers must narrow before accessing those. + */ +export type ComponentSnapshotNode = Omit & { + children: readonly SnapshotNode[]; +}; + +/** + * Snapshot of the singleton `library` node — a non-visual container for + * code-component definitions hung off the root. Never rendered on canvas; + * present in the union so root-children consumers can narrow it out. + */ +export type LibrarySnapshotNode = Omit & { + children: readonly SnapshotNode[]; +}; + +/** Snapshot of a `codeComponent` definition node (source only, no children). */ +export type CodeComponentSnapshotNode = Omit & { + children: readonly SnapshotNode[]; +}; + +export type RenderOptions = Record; + +export interface RenderResult { + html: string; +} diff --git a/packages/paywall-renderer-web-core/src/variables.test.ts b/packages/paywall-renderer-web-core/src/variables.test.ts new file mode 100644 index 000000000..a1b95e5bd --- /dev/null +++ b/packages/paywall-renderer-web-core/src/variables.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, test } from "vite-plus/test"; + +import type { Variable } from "./snapshot-types"; +import type { SnapshotNode } from "./types"; +import { + collectVariables, + collectVariableScopes, + createChainVariableReader, + findDeclaringNodeInChain, + type VariableScopes, +} from "./variables"; + +function entry(entryId: string, variable: Variable): { id: string; pos: string; value: Variable } { + return { id: entryId, pos: "a0", value: variable }; +} + +function makeViewNode( + id: string, + localVariables: Array<{ id: string; pos: string; value: Variable }>, + children: SnapshotNode[] = [], +): SnapshotNode { + return { + type: "view", + id, + parentId: null, + pos: "a0", + data: { + name: "View", + localVariables, + linkedVariables: [], + interactions: [], + states: [], + style: {}, + }, + children, + } as unknown as SnapshotNode; +} + +function makeRootNode(children: SnapshotNode[]): SnapshotNode { + return { + type: "root", + id: "root", + parentId: null, + pos: "a0", + data: { name: "Root" }, + children, + } as unknown as SnapshotNode; +} + +describe("collectVariables", () => { + test("collects variables scoped to each node", () => { + const node = makeViewNode("view-1", [ + entry("entry-1", { id: "var-1", name: "isActive", value: { key: "boolean", value: true } }), + entry("entry-2", { id: "var-2", name: "count", value: { key: "number", value: 42 } }), + ]); + + const map = collectVariables(node); + expect(map.size).toBe(1); + + const nodeVars = map.get("view-1"); + expect(nodeVars).toBeDefined(); + // 2 variables × 2 IDs each (internal + entry) + expect(nodeVars!.store.size).toBe(4); + expect(nodeVars!.store.get("var-1")).toEqual({ key: "boolean", value: true }); + expect(nodeVars!.store.get("entry-1")).toEqual({ key: "boolean", value: true }); + expect(nodeVars!.store.get("var-2")).toEqual({ key: "number", value: 42 }); + expect(nodeVars!.store.get("entry-2")).toEqual({ key: "number", value: 42 }); + }); + + test("skips entries whose variable lacks an internal id or value", () => { + const node = makeViewNode("view-1", [ + entry("entry-1", { + name: "no-id", + value: { key: "boolean", value: true }, + } as unknown as Variable), + entry("entry-2", { id: "var-2", name: "no-value" } as unknown as Variable), + entry("entry-3", { id: "var-3", name: "ok", value: { key: "number", value: 1 } }), + ]); + + const map = collectVariables(node); + const nodeVars = map.get("view-1"); + expect(nodeVars).toBeDefined(); + expect(nodeVars!.store.size).toBe(2); + expect(nodeVars!.store.get("var-3")).toEqual({ key: "number", value: 1 }); + expect(nodeVars!.store.get("entry-3")).toEqual({ key: "number", value: 1 }); + }); + + test("returns bidirectional aliases per node", () => { + const node = makeViewNode("view-1", [ + entry("entry-1", { id: "var-1", name: "isActive", value: { key: "boolean", value: true } }), + ]); + + const map = collectVariables(node); + const nodeVars = map.get("view-1"); + expect(nodeVars).toBeDefined(); + expect(nodeVars!.aliases.get("entry-1")).toBe("var-1"); + expect(nodeVars!.aliases.get("var-1")).toBe("entry-1"); + }); + + test("collects variables from nested nodes into separate stores", () => { + const child = makeViewNode("view-2", [ + entry("entry-2", { id: "var-2", name: "label", value: { key: "string", value: "hello" } }), + ]); + const parent = makeViewNode( + "view-1", + [ + entry("entry-1", { + id: "var-1", + name: "isActive", + value: { key: "boolean", value: false }, + }), + ], + [child], + ); + + const map = collectVariables(parent); + expect(map.size).toBe(2); + + const parentVars = map.get("view-1"); + expect(parentVars!.store.size).toBe(2); + expect(parentVars!.store.get("var-1")).toEqual({ key: "boolean", value: false }); + + const childVars = map.get("view-2"); + expect(childVars!.store.size).toBe(2); + expect(childVars!.store.get("var-2")).toEqual({ key: "string", value: "hello" }); + }); + + test("collects from root with multiple children", () => { + const root = makeRootNode([ + makeViewNode("view-1", [ + entry("entry-1", { id: "var-1", name: "a", value: { key: "boolean", value: true } }), + ]), + makeViewNode("view-2", [ + entry("entry-2", { id: "var-2", name: "b", value: { key: "number", value: 10 } }), + ]), + ]); + + const map = collectVariables(root); + expect(map.size).toBe(2); + expect(map.get("view-1")!.store.get("var-1")).toEqual({ key: "boolean", value: true }); + expect(map.get("view-2")!.store.get("var-2")).toEqual({ key: "number", value: 10 }); + }); + + test("returns empty map for tree with no variables", () => { + const root = makeRootNode([makeViewNode("view-1", [])]); + const map = collectVariables(root); + expect(map.size).toBe(0); + }); + + test("handles nodes whose data has no localVariables property", () => { + const root: SnapshotNode = { + type: "root", + id: "root", + parentId: null, + pos: "a0", + data: {}, + children: [], + } as unknown as SnapshotNode; + + const map = collectVariables(root); + expect(map.size).toBe(0); + }); + + test("two nodes with same variable IDs do not collide", () => { + const root = makeRootNode([ + makeViewNode("view-1", [ + entry("entry-1", { id: "var-1", name: "isActive", value: { key: "boolean", value: true } }), + ]), + makeViewNode("view-2", [ + entry("entry-1", { + id: "var-1", + name: "isActive", + value: { key: "boolean", value: false }, + }), + ]), + ]); + + const map = collectVariables(root); + expect(map.size).toBe(2); + + // Each node keeps its own value + expect(map.get("view-1")!.store.get("var-1")).toEqual({ key: "boolean", value: true }); + expect(map.get("view-2")!.store.get("var-1")).toEqual({ key: "boolean", value: false }); + }); +}); + +describe("collectVariableScopes", () => { + function makeChainTree(): SnapshotNode { + // root → view-outer(var-outer, var-shadowed) → view-mid(no vars) → view-inner(var-inner, var-shadowed) + const inner = makeViewNode("view-inner", [ + entry("entry-inner", { + id: "var-inner", + name: "inner", + value: { key: "string", value: "inner-value" }, + }), + entry("entry-shadowed-inner", { + id: "var-shadowed", + name: "shadowed", + value: { key: "number", value: 2 }, + }), + ]); + const mid = makeViewNode("view-mid", [], [inner]); + const outer = makeViewNode( + "view-outer", + [ + entry("entry-outer", { + id: "var-outer", + name: "outer", + value: { key: "boolean", value: true }, + }), + entry("entry-shadowed-outer", { + id: "var-shadowed", + name: "shadowed", + value: { key: "number", value: 1 }, + }), + ], + [mid], + ); + return makeRootNode([outer]); + } + + function readerFor(scopes: VariableScopes, nodeId: string) { + return createChainVariableReader(scopes.parents, (id) => scopes.stores.get(id)?.store, nodeId); + } + + test("returns stores matching collectVariables and parent links for every node", () => { + const root = makeChainTree(); + const scopes = collectVariableScopes(root); + + expect(scopes.stores.size).toBe(2); + expect(scopes.stores.get("view-outer")!.store.get("var-outer")).toEqual({ + key: "boolean", + value: true, + }); + + expect(scopes.parents.get("root")).toBe(null); + expect(scopes.parents.get("view-outer")).toBe("root"); + expect(scopes.parents.get("view-mid")).toBe("view-outer"); + expect(scopes.parents.get("view-inner")).toBe("view-mid"); + }); + + test("own store wins", () => { + const scopes = collectVariableScopes(makeChainTree()); + expect(readerFor(scopes, "view-inner").get("var-inner")).toEqual({ + key: "string", + value: "inner-value", + }); + }); + + test("falls back to the nearest ancestor store", () => { + const scopes = collectVariableScopes(makeChainTree()); + expect(readerFor(scopes, "view-inner").get("var-outer")).toEqual({ + key: "boolean", + value: true, + }); + // A node without any store of its own resolves through ancestors too. + expect(readerFor(scopes, "view-mid").get("var-outer")).toEqual({ + key: "boolean", + value: true, + }); + }); + + test("shadowed id: nearest declaration wins for reads", () => { + const scopes = collectVariableScopes(makeChainTree()); + expect(readerFor(scopes, "view-inner").get("var-shadowed")).toEqual({ + key: "number", + value: 2, + }); + // From the middle node the outer declaration is the nearest. + expect(readerFor(scopes, "view-mid").get("var-shadowed")).toEqual({ + key: "number", + value: 1, + }); + }); + + test("shadowed id: writes target the nearest declaring store", () => { + const scopes = collectVariableScopes(makeChainTree()); + const getStore = (nodeId: string) => scopes.stores.get(nodeId)?.store; + + expect(findDeclaringNodeInChain(scopes.parents, getStore, "view-inner", "var-shadowed")).toBe( + "view-inner", + ); + expect(findDeclaringNodeInChain(scopes.parents, getStore, "view-mid", "var-shadowed")).toBe( + "view-outer", + ); + expect(findDeclaringNodeInChain(scopes.parents, getStore, "view-inner", "missing")).toBe( + undefined, + ); + }); + + test("variables not declared anywhere in the chain resolve to undefined", () => { + const scopes = collectVariableScopes(makeChainTree()); + expect(readerFor(scopes, "view-inner").get("missing")).toBe(undefined); + // Sibling scopes are not visible: var-inner is not in view-outer's chain. + expect(readerFor(scopes, "view-outer").get("var-inner")).toBe(undefined); + }); + + test("createChainVariableReader reads through the chain and sees live store updates", () => { + const scopes = collectVariableScopes(makeChainTree()); + const liveStores = new Map( + [...scopes.stores].map(([nodeId, { store }]) => [nodeId, new Map(store)]), + ); + const reader = createChainVariableReader( + scopes.parents, + (nodeId) => liveStores.get(nodeId), + "view-inner", + ); + + expect(reader.get("var-shadowed")).toEqual({ key: "number", value: 2 }); + expect(reader.get("var-outer")).toEqual({ key: "boolean", value: true }); + expect(reader.get("missing")).toBe(undefined); + + liveStores.get("view-outer")!.set("var-outer", { key: "boolean", value: false }); + expect(reader.get("var-outer")).toEqual({ key: "boolean", value: false }); + }); +}); diff --git a/packages/paywall-renderer-web-core/src/variables.ts b/packages/paywall-renderer-web-core/src/variables.ts new file mode 100644 index 000000000..39405fe18 --- /dev/null +++ b/packages/paywall-renderer-web-core/src/variables.ts @@ -0,0 +1,138 @@ +import type { VariableValue } from "./snapshot-types"; +import type { SnapshotNode } from "./types"; + +export type VariableStore = Map; + +/** + * Read-only variable lookup accepted by the evaluator, state resolver and + * action executor. A plain {@link VariableStore} satisfies it; chain-aware + * consumers pass {@link createChainVariableReader} output instead. + */ +export type VariableReader = Pick; + +/** Bidirectional map between array entry IDs and variable internal IDs. */ +export type VariableAliases = Map; + +export interface VariableCollection { + store: VariableStore; + aliases: VariableAliases; +} + +/** Per-node variable data returned by `collectVariables`. */ +export type NodeVariableMap = Map; + +/** + * Collects all variables from the snapshot tree, scoped by node ID. + * + * Each node with `localVariables` gets its own `VariableStore` and `VariableAliases`. + * This prevents collisions when a node is duplicated (copied nodes share the same + * internal variable IDs but have different node IDs). + */ +export function collectVariables(root: SnapshotNode): NodeVariableMap { + const map: NodeVariableMap = new Map(); + collectFromNode(root, map); + return map; +} + +/** + * Per-node variable stores plus parent links for ancestor-scoped (lexical) + * resolution. A variable declared on a node is visible to that node and all + * of its descendants; internal variable ids are not globally unique, so + * lookups must walk the ancestor chain instead of flat-merging stores. + */ +export interface VariableScopes { + stores: NodeVariableMap; + parents: Map; +} + +/** + * Collects per-node variable stores (same shape as {@link collectVariables}) + * together with parent links for every node in the snapshot tree. + */ +export function collectVariableScopes(root: SnapshotNode): VariableScopes { + const scopes: VariableScopes = { stores: new Map(), parents: new Map() }; + collectScopesFromNode(root, null, scopes); + return scopes; +} + +function collectScopesFromNode( + node: SnapshotNode, + parentId: string | null, + scopes: VariableScopes, +): void { + scopes.parents.set(node.id, parentId); + collectFromNode(node, scopes.stores); + for (const child of node.children) { + collectScopesFromNode(child, node.id, scopes); + } +} + +/** + * Finds the nearest node in the ancestor chain (own node first, then nearest + * ancestor first) whose store declares `variableId`. This is the node a + * `set-variable` write must target. + */ +export function findDeclaringNodeInChain( + parents: ReadonlyMap, + getStore: (nodeId: string) => VariableStore | undefined, + nodeId: string, + variableId: string, +): string | undefined { + const visited = new Set(); + let current: string | null = nodeId; + while (current !== null && !visited.has(current)) { + visited.add(current); + if (getStore(current)?.has(variableId)) { + return current; + } + current = parents.get(current) ?? null; + } + return undefined; +} + +/** + * Creates a {@link VariableReader} that resolves ids through the ancestor + * chain of `nodeId` (own store first, then nearest ancestor first). `getStore` + * is read lazily on every lookup, so live store snapshots (e.g. renderer + * state) stay current. + */ +export function createChainVariableReader( + parents: ReadonlyMap, + getStore: (nodeId: string) => VariableStore | undefined, + nodeId: string, +): VariableReader { + return { + get: (variableId: string) => { + const declaringNodeId = findDeclaringNodeInChain(parents, getStore, nodeId, variableId); + if (declaringNodeId === undefined) { + return undefined; + } + return getStore(declaringNodeId)?.get(variableId); + }, + }; +} + +function collectFromNode(node: SnapshotNode, map: NodeVariableMap): void { + // Unknown-type nodes from newer payloads may carry no data at all. + const data = node.data; + if (data !== undefined && "localVariables" in data && data.localVariables.length > 0) { + const store: VariableStore = new Map(); + const aliases: VariableAliases = new Map(); + for (const entry of data.localVariables) { + const variable = entry.value; + if (variable?.id === undefined || variable.value === undefined) { + continue; + } + store.set(variable.id, variable.value); + store.set(entry.id, variable.value); + aliases.set(entry.id, variable.id); + aliases.set(variable.id, entry.id); + } + if (store.size > 0) { + map.set(node.id, { store, aliases }); + } + } + for (const child of node.children) { + collectFromNode(child, map); + } +} diff --git a/packages/paywall-renderer-web-core/sst-env.d.ts b/packages/paywall-renderer-web-core/sst-env.d.ts new file mode 100644 index 000000000..eec65b9bd --- /dev/null +++ b/packages/paywall-renderer-web-core/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst"; +export {}; diff --git a/packages/paywall-renderer-web-core/tsconfig.json b/packages/paywall-renderer-web-core/tsconfig.json new file mode 100644 index 000000000..fdaf14775 --- /dev/null +++ b/packages/paywall-renderer-web-core/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@voidhash/tsconfig/typescript-6.json", + "include": ["src"], + "exclude": ["**/node_modules/**"] +} diff --git a/packages/paywall-workspace/LICENSE.md b/packages/paywall-workspace/LICENSE.md new file mode 100644 index 000000000..be3f7b28e --- /dev/null +++ b/packages/paywall-workspace/LICENSE.md @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/paywall-workspace/README.md b/packages/paywall-workspace/README.md new file mode 100644 index 000000000..84065f28b --- /dev/null +++ b/packages/paywall-workspace/README.md @@ -0,0 +1,7 @@ +# @voidhash/paywall-workspace + +Isomorphic workspace paths, snapshot serialization, source hashing, and validated document-write primitives for Voidhash paywall authoring. + +```typescript +import { parseWorkspacePath } from "@voidhash/paywall-workspace"; +``` diff --git a/packages/paywall-workspace/package.json b/packages/paywall-workspace/package.json new file mode 100644 index 000000000..d4728aa14 --- /dev/null +++ b/packages/paywall-workspace/package.json @@ -0,0 +1,45 @@ +{ + "name": "@voidhash/paywall-workspace", + "version": "0.0.1-alpha.1", + "private": true, + "description": "Isomorphic paths, snapshots, hashing, and document-write primitives for Voidhash paywall workspaces.", + "keywords": [ + "paywalls", + "voidhash", + "workspace" + ], + "homepage": "https://voidhash.com/docs", + "bugs": { + "url": "https://github.com/voidhashcom/voidhash/issues" + }, + "license": "AGPL-3.0-only", + "author": "Voidhash (https://voidhash.com)", + "repository": { + "type": "git", + "url": "https://github.com/voidhashcom/voidhash", + "directory": "packages/paywall-workspace" + }, + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run -c vitest.mts" + }, + "dependencies": { + "@voidhash/ai-shared": "workspace:*", + "@voidhash/mimic-core": "workspace:*", + "@voidhash/mimic-schema": "workspace:*", + "@voidhash/paywall-renderer-web-core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^20", + "@voidhash/tsconfig": "workspace:*", + "typescript": "6.0.3", + "vite-plus": "0.1.23", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.2.7" + } +} diff --git a/packages/paywall-workspace/src/hash.test.ts b/packages/paywall-workspace/src/hash.test.ts new file mode 100644 index 000000000..2b70f96fa --- /dev/null +++ b/packages/paywall-workspace/src/hash.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { hashSource } from "./hash.ts"; + +describe("hashSource (SHA-256)", () => { + it("returns 64 lowercase hex chars", () => { + const digest = hashSource("anything"); + expect(digest).toMatch(/^[0-9a-f]{64}$/); + }); + + it("matches the standard SHA-256 vector for the empty string", () => { + expect(hashSource("")).toBe( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ); + }); + + it('matches the standard SHA-256 vector for "abc"', () => { + expect(hashSource("abc")).toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); + }); + + it("hashes multi-byte UTF-8 as its UTF-8 bytes", () => { + // node -e 'crypto.createHash("sha256").update("héllo wörld — 日本語 🎉","utf8").digest("hex")' + expect(hashSource("héllo wörld — 日本語 🎉")).toBe( + "1043a91841e5a61ef9b6cc83f848d1b9c4c373ec7b4add02f07e001a75e7af0b", + ); + }); + + it("hashes a >64-byte (multi-block) input", () => { + // 200 bytes → spans four 64-byte SHA-256 blocks incl. padding block. + expect(hashSource("a".repeat(200))).toBe( + "c2a908d98f5df987ade41b5fce213067efbcc21ef2240212a41e54b5e7c28ae5", + ); + }); + + it("is stable and collision-sensitive to single-char changes", () => { + expect(hashSource("A")).toBe(hashSource("A")); + expect(hashSource("A")).not.toBe(hashSource("B")); + }); +}); diff --git a/packages/paywall-workspace/src/hash.ts b/packages/paywall-workspace/src/hash.ts new file mode 100644 index 000000000..0dd16e337 --- /dev/null +++ b/packages/paywall-workspace/src/hash.ts @@ -0,0 +1,115 @@ +/** + * The content-address of a code-component's source — the single hash both the + * browser compile pipeline and the server manifest cache agree on. + * + * A synchronous SHA-256 over the UTF-8 source text, returned as 64 lowercase + * hex chars. It is a global content address (the primary key of the shared, + * unscoped `paywall_component_manifest` cache), so it must resist adversarial + * collisions — a cryptographic digest, not a cheap fold. It stays SYNC because + * every caller is sync (the browser compile-state keys, `waitForCompile` keys, + * and the pure `componentSourceHashes` / `buildRegistryFromSnapshot` on the + * server); inputs are KB-sized and hashed only at compile time, so a pure-TS + * implementation is more than fast enough and needs no `crypto`/`SubtleCrypto` + * (the latter is async-only in the browser). + * + * Kept here, in the pure isomorphic workspace package, so the browser (which + * uploads `(sourceHash, manifest)` after each compile) and the server (which + * looks manifests up by `hashSource(codeComponent.source)`) can never drift — + * the moment they disagree the server would fail to resolve a just-compiled + * component's manifest. + */ +export function hashSource(input: string): string { + return sha256Hex(new TextEncoder().encode(input)); +} + +// SHA-256 round constants (first 32 bits of the fractional parts of the cube +// roots of the first 64 primes). +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +const rotr = (value: number, bits: number): number => + ((value >>> bits) | (value << (32 - bits))) >>> 0; + +/** + * Pure synchronous SHA-256 of a byte array, returned as 64 lowercase hex chars. + * A textbook FIPS 180-4 implementation over 512-bit blocks with the standard + * length-in-bits padding; kept dependency-free and self-contained. + */ +function sha256Hex(bytes: Uint8Array): string { + const h = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ]); + + // Padded message length: original bytes + 1 marker byte + zero-fill so the + // total is a multiple of 64, with the last 8 bytes holding the bit length. + const bitLength = bytes.length * 8; + const paddedLength = ((bytes.length + 8) >> 6) * 64 + 64; + const message = new Uint8Array(paddedLength); + message.set(bytes); + message[bytes.length] = 0x80; + // 64-bit big-endian bit length in the final 8 bytes. Sources are far below + // 2^32 bits, so the high word is always zero — write the low 32 bits. + const view = new DataView(message.buffer); + view.setUint32(paddedLength - 4, bitLength >>> 0, false); + + const w = new Uint32Array(64); + for (let offset = 0; offset < paddedLength; offset += 64) { + for (let t = 0; t < 16; t += 1) { + w[t] = view.getUint32(offset + t * 4, false); + } + for (let t = 16; t < 64; t += 1) { + const s0 = rotr(w[t - 15]!, 7) ^ rotr(w[t - 15]!, 18) ^ (w[t - 15]! >>> 3); + const s1 = rotr(w[t - 2]!, 17) ^ rotr(w[t - 2]!, 19) ^ (w[t - 2]! >>> 10); + w[t] = (w[t - 16]! + s0 + w[t - 7]! + s1) >>> 0; + } + + let a = h[0]!; + let b = h[1]!; + let c = h[2]!; + let d = h[3]!; + let e = h[4]!; + let f = h[5]!; + let g = h[6]!; + let hh = h[7]!; + + for (let t = 0; t < 64; t += 1) { + const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const temp1 = (hh + S1 + ch + K[t]! + w[t]!) >>> 0; + const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (S0 + maj) >>> 0; + hh = g; + g = f; + f = e; + e = (d + temp1) >>> 0; + d = c; + c = b; + b = a; + a = (temp1 + temp2) >>> 0; + } + + h[0] = (h[0]! + a) >>> 0; + h[1] = (h[1]! + b) >>> 0; + h[2] = (h[2]! + c) >>> 0; + h[3] = (h[3]! + d) >>> 0; + h[4] = (h[4]! + e) >>> 0; + h[5] = (h[5]! + f) >>> 0; + h[6] = (h[6]! + g) >>> 0; + h[7] = (h[7]! + hh) >>> 0; + } + + let hex = ""; + for (let i = 0; i < 8; i += 1) { + hex += h[i]!.toString(16).padStart(8, "0"); + } + return hex; +} diff --git a/packages/paywall-workspace/src/index.ts b/packages/paywall-workspace/src/index.ts new file mode 100644 index 000000000..386deab71 --- /dev/null +++ b/packages/paywall-workspace/src/index.ts @@ -0,0 +1,56 @@ +/** + * `@voidhash/paywall-workspace` — the pure, isomorphic workspace seam + * shared by the browser designer and the headless server. + * + * A *workspace* is a virtual filesystem projection over a project's paywalls; + * mimic documents stay the source of truth. This package holds only the pure + * seam: the path vocabulary, the snapshot readers over a document's code + * components, the content hash, and the file-write → mimic `Command[]` lowering + * (document edits, component source writes, component move/delete, reconcile). + * No DOM, no Node APIs, no Effect services — it runs unchanged in a browser tab, + * a Worker, or a Durable Object. + */ + +export { + COMPOSITION_FILENAME, + docRelativeComponentPath, + docRelativeFromFileName, + fileNameFromDocRelative, + formatComponentPath, + formatCompositionPath, + formatWorkspacePath, + parseWorkspacePath, + workspacePathForDocRelative, + type ComponentPath, + type CompositionPath, + type ParseWorkspacePathResult, + type WorkspacePath, + type WorkspacePathError, +} from "./paths.ts"; + +export { readComponentDefinitions, type WorkspaceComponentDefinition } from "./snapshot.ts"; + +export { hashSource } from "./hash.ts"; + +export { + lowerComponentDelete, + lowerComponentMove, + uniqueComponentFileName, + validateComponentFileName, + type ApplyFileWriteResult, + type WriteDiagnostic, +} from "./write.ts"; + +export { + applyDocumentEditsToTree, + applyWorkspaceDelete, + applyWorkspaceMove, + componentPathsFromTree, + encodePaywallDocument, + reconcileToTree, + writeComponentSourceToTree, + type ApplyDocumentEditsToTreeResult, + type ApplyWorkspaceWriteResult, + type DocumentEdit, + type MintedIds, +} from "./service-write.ts"; diff --git a/packages/paywall-workspace/src/paths.test.ts b/packages/paywall-workspace/src/paths.test.ts new file mode 100644 index 000000000..1ae40c576 --- /dev/null +++ b/packages/paywall-workspace/src/paths.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "vite-plus/test"; + +import { + docRelativeComponentPath, + docRelativeFromFileName, + fileNameFromDocRelative, + formatComponentPath, + formatCompositionPath, + formatWorkspacePath, + parseWorkspacePath, + workspacePathForDocRelative, + type WorkspacePath, +} from "./paths.ts"; + +/** + * Contract tests locking the workspace path vocabulary. Tools, MCP resources, + * and the editor file tree all agree on exactly this scheme — drift here would + * silently break addressing across surfaces. A component is identified by its + * FILE NAME (`.tsx`), matching the canonical document-relative path + * (`components/.tsx`) the mimic nodes store. + */ +describe("workspace path vocabulary", () => { + test("formats a composition path", () => { + expect(formatCompositionPath("trial")).toBe("/paywalls/trial/paywall.tsx"); + }); + + test("formats a component path from a file name (extension included)", () => { + expect(formatComponentPath("trial", "hero.tsx")).toBe( + "/paywalls/trial/components/hero.tsx", + ); + }); + + test("parses a composition path", () => { + const result = parseWorkspacePath("/paywalls/trial/paywall.tsx"); + expect(result).toEqual({ ok: true, path: { kind: "composition", paywallSlug: "trial" } }); + }); + + test("parses a component path, keeping the .tsx in fileName", () => { + const result = parseWorkspacePath("/paywalls/trial/components/hero.tsx"); + expect(result).toEqual({ + ok: true, + path: { kind: "component", paywallSlug: "trial", fileName: "hero.tsx" }, + }); + }); + + test.each([ + { kind: "composition", paywallSlug: "trial" }, + { kind: "component", paywallSlug: "trial", fileName: "hero.tsx" }, + { kind: "component", paywallSlug: "trial", fileName: "pricing-option.tsx" }, + ])("format ∘ parse round-trips %o", (path) => { + const result = parseWorkspacePath(formatWorkspacePath(path)); + expect(result.ok && result.path).toEqual(path); + }); + + test("reserves the top-level /components namespace (not supported yet)", () => { + const result = parseWorkspacePath("/components/shared.tsx"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.kind).toBe("reserved"); + } + }); + + test.each([ + ["paywalls/trial/paywall.tsx", "relative path (no leading slash)"], + ["/unknown/trial/paywall.tsx", "unknown top-level dir"], + ["/paywalls/trial", "no file"], + ["/paywalls/trial/other.tsx", "unrecognized file"], + ["/paywalls/trial/components/hero.ts", "wrong extension"], + ["/paywalls//paywall.tsx", "empty paywall slug"], + ["/paywalls/trial/components/.tsx", "empty component file name"], + ])("rejects %j as malformed (%s)", (path) => { + const result = parseWorkspacePath(path); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.kind).toBe("malformed"); + } + }); +}); + +describe("doc-relative ↔ workspace-absolute mapping", () => { + test("docRelativeFromFileName wraps the basename under components/", () => { + expect(docRelativeFromFileName("hero.tsx")).toBe("components/hero.tsx"); + }); + + test("fileNameFromDocRelative strips the components/ prefix", () => { + expect(fileNameFromDocRelative("components/hero.tsx")).toBe("hero.tsx"); + }); + + test("docRelativeComponentPath drops the /paywalls// prefix", () => { + expect(docRelativeComponentPath("/paywalls/trial/components/pricing-option.tsx")).toBe( + "components/pricing-option.tsx", + ); + }); + + test("docRelativeComponentPath is undefined for a non-component path", () => { + expect(docRelativeComponentPath("/paywalls/trial/paywall.tsx")).toBeUndefined(); + expect(docRelativeComponentPath("/nope")).toBeUndefined(); + }); + + test("workspacePathForDocRelative re-attaches the paywall directory", () => { + expect(workspacePathForDocRelative("trial", "components/hero.tsx")).toBe( + "/paywalls/trial/components/hero.tsx", + ); + }); + + test("workspace ↔ doc-relative round-trips", () => { + const workspace = "/paywalls/trial/components/pricing-option.tsx"; + const docRelative = docRelativeComponentPath(workspace)!; + expect(workspacePathForDocRelative("trial", docRelative)).toBe(workspace); + }); +}); diff --git a/packages/paywall-workspace/src/paths.ts b/packages/paywall-workspace/src/paths.ts new file mode 100644 index 000000000..a085774a0 --- /dev/null +++ b/packages/paywall-workspace/src/paths.ts @@ -0,0 +1,193 @@ +/** + * The workspace path vocabulary — the single string scheme every tool, MCP + * resource, and file tree agrees on. A workspace is a virtual filesystem + * projection over a project's paywalls; a path names one file inside it. + * + * ``` + * /paywalls//paywall.tsx ← the composition + * /paywalls//components/.tsx ← a code-component source + * ``` + * + * A paywall slug is the stable per-paywall directory name (a rename never + * changes a slug), so composition paths are stable. A component is identified by + * its FILE NAME (`.tsx`) — the file basename IS the component's + * identity, matching the canonical document-relative path + * (`components/.tsx`) a `codeComponent`/instance node stores. Renaming + * a component is a file MOVE (a path change), not a display-name edit. The + * top-level `/components/*` namespace (a project/org-shared component library) is + * *reserved* — parsed and rejected as not-yet-supported so the vocabulary is + * forward-compatible. + */ + +/** Fixed filename of a paywall's composition file within its directory. */ +export const COMPOSITION_FILENAME = "paywall.tsx"; + +const PAYWALLS_SEGMENT = "paywalls"; +const COMPONENTS_SEGMENT = "components"; + +/** + * A paywall slug segment: an identifier with no path separators. Kept + * intentionally permissive (paywall slugs are generated upstream); it only rules + * out characters that would break the path grammar itself. + */ +const SLUG = /^[^/]+$/; + +/** A parsed workspace path — a discriminated union over the file kinds. */ +export type WorkspacePath = CompositionPath | ComponentPath; + +/** `/paywalls//paywall.tsx` — a paywall's composition file. */ +export interface CompositionPath { + readonly kind: "composition"; + readonly paywallSlug: string; +} + +/** + * `/paywalls//components/` — a component source. The + * `fileName` is the file basename INCLUDING its `.tsx` extension (e.g. + * `pricing-option.tsx`); it is the component's identity and matches the + * `codeComponent`/instance nodes' canonical `components/` path. + */ +export interface ComponentPath { + readonly kind: "component"; + readonly paywallSlug: string; + readonly fileName: string; +} + +/** Why a path string is not a usable workspace path. */ +export type WorkspacePathError = + | { readonly kind: "malformed"; readonly reason: string } + | { readonly kind: "reserved"; readonly reason: string }; + +/** Result of {@link parseWorkspacePath}: a parsed path or a typed error. */ +export type ParseWorkspacePathResult = + | { readonly ok: true; readonly path: WorkspacePath } + | { readonly ok: false; readonly error: WorkspacePathError }; + +/** Formats a paywall's composition path. */ +export function formatCompositionPath(paywallSlug: string): string { + return `/${PAYWALLS_SEGMENT}/${paywallSlug}/${COMPOSITION_FILENAME}`; +} + +/** + * Formats a code-component's source path. `fileName` is the file basename + * including its `.tsx` extension (`pricing-option.tsx`) — the component's + * identity. + */ +export function formatComponentPath(paywallSlug: string, fileName: string): string { + return `/${PAYWALLS_SEGMENT}/${paywallSlug}/${COMPONENTS_SEGMENT}/${fileName}`; +} + +/** Formats a parsed {@link WorkspacePath} back to its string form. */ +export function formatWorkspacePath(path: WorkspacePath): string { + return path.kind === "composition" + ? formatCompositionPath(path.paywallSlug) + : formatComponentPath(path.paywallSlug, path.fileName); +} + +/** + * The canonical document-relative path (`components/`) a `codeComponent` + * definition and its local instances store, given a workspace-absolute component + * path (`/paywalls//components/`). This is the identity key the + * mimic bridge (lift/lower) round-trips — dropping the `/paywalls//` + * prefix. Returns `undefined` when the input is not a component path. + */ +export function docRelativeComponentPath(workspaceAbsolutePath: string): string | undefined { + const parsed = parseWorkspacePath(workspaceAbsolutePath); + if (!parsed.ok || parsed.path.kind !== "component") { + return undefined; + } + return docRelativeFromFileName(parsed.path.fileName); +} + +/** + * The canonical document-relative path (`components/`) for a component + * file basename. The inverse of {@link fileNameFromDocRelative}. Pure string + * concatenation — the segment the mimic nodes key on. + */ +export function docRelativeFromFileName(fileName: string): string { + return `${COMPONENTS_SEGMENT}/${fileName}`; +} + +/** + * The file basename (`.tsx`) of a canonical document-relative + * component path (`components/.tsx`) — the inverse of + * {@link docRelativeFromFileName}. Returns the input unchanged when it carries no + * `components/` prefix (defensive; a well-formed doc-relative path always does). + */ +export function fileNameFromDocRelative(docRelativePath: string): string { + const prefix = `${COMPONENTS_SEGMENT}/`; + return docRelativePath.startsWith(prefix) ? docRelativePath.slice(prefix.length) : docRelativePath; +} + +/** + * The workspace-absolute component path (`/paywalls//components/`) + * for a paywall slug and a canonical document-relative component path + * (`components/`) — the inverse of {@link docRelativeComponentPath}. + */ +export function workspacePathForDocRelative(paywallSlug: string, docRelativePath: string): string { + return formatComponentPath(paywallSlug, fileNameFromDocRelative(docRelativePath)); +} + +const malformed = (reason: string): ParseWorkspacePathResult => ({ + ok: false, + error: { kind: "malformed", reason }, +}); + +const reserved = (reason: string): ParseWorkspacePathResult => ({ + ok: false, + error: { kind: "reserved", reason }, +}); + +/** + * Parse a workspace path string into a {@link WorkspacePath}, or a typed error. + * + * Recognizes exactly two shapes under `/paywalls//`: the composition file + * (`paywall.tsx`) and a component source (`components/.tsx`). A + * component file's name must end with `.tsx`; the retained basename (including + * the extension) is the component's identity. The top-level `/components/*` + * namespace is reserved for a future shared library and is rejected with a + * `reserved` error. Everything else is `malformed`. + */ +export function parseWorkspacePath(path: string): ParseWorkspacePathResult { + if (!path.startsWith("/")) { + return malformed(`path must be absolute (start with "/"): "${path}"`); + } + const segments = path.slice(1).split("/"); + const [head, ...rest] = segments; + + if (head === COMPONENTS_SEGMENT) { + return reserved( + `"/components/*" (project-shared component library) is not supported yet`, + ); + } + + if (head !== PAYWALLS_SEGMENT) { + return malformed(`unknown top-level directory "/${head ?? ""}"`); + } + + const [paywallSlug, ...tail] = rest; + if (paywallSlug === undefined || !SLUG.test(paywallSlug)) { + return malformed(`missing or invalid paywall slug in "${path}"`); + } + + // /paywalls//paywall.tsx + if (tail.length === 1 && tail[0] === COMPOSITION_FILENAME) { + return { ok: true, path: { kind: "composition", paywallSlug } }; + } + + // /paywalls//components/.tsx + if (tail.length === 2 && tail[0] === COMPONENTS_SEGMENT) { + const fileName = tail[1]!; + if (!fileName.endsWith(".tsx")) { + return malformed(`component file must end with ".tsx": "${path}"`); + } + // A non-empty basename before the extension is required (`.tsx` alone is not + // a component file). The full basename INCLUDING `.tsx` is the identity. + if (fileName.length <= ".tsx".length) { + return malformed(`missing component file name in "${path}"`); + } + return { ok: true, path: { kind: "component", paywallSlug, fileName } }; + } + + return malformed(`not a recognized workspace file: "${path}"`); +} diff --git a/packages/paywall-workspace/src/service-write.test.ts b/packages/paywall-workspace/src/service-write.test.ts new file mode 100644 index 000000000..5259f8dc5 --- /dev/null +++ b/packages/paywall-workspace/src/service-write.test.ts @@ -0,0 +1,202 @@ +import { PaywallDesignerDocument } from "@voidhash/mimic-schema"; +import type { DocumentEdit } from "@voidhash/ai-shared"; +import { describe, expect, test } from "vitest"; + +import { applyDocumentEditsToTree, writeComponentSourceToTree } from "./service-write.ts"; + +/** Encode a decoded document input into the raw live tree the write path reads. */ +const encode = (roots: unknown): unknown => PaywallDesignerDocument.encode(roots as never); + +/** The screen id in an encoded live tree (the edit target). */ +const screenIdOf = (tree: unknown): string => + (tree as { nodes: { id: string; value: { fields: { type?: { value?: string } } } }[] }).nodes.find( + (node) => node.value.fields.type?.value === "screen", + )!.id; + +/** Whether the raw tree contains a node with `id` carrying a string field `key === value`. */ +const treeHasNodeWithField = (tree: unknown, id: string, key: string, value: string): boolean => + (tree as { nodes: { id: string; value: { fields: Record } }[] }).nodes.some( + (node) => node.id === id && node.value.fields[key]?.value === value, + ); + +describe("applyDocumentEditsToTree", () => { + const liveTree = () => + encode([{ type: "root", name: "Paywall", children: [{ type: "screen", name: "Main" }] }]); + + test("insert emits a tree.insert carrying the pre-minted target id (id survives reconcile)", () => { + const tree = liveTree(); + const screenId = screenIdOf(tree); + const { result, mintedIds } = applyDocumentEditsToTree({ + tree, + edits: [{ op: "insert", parentId: screenId, node: { type: "view", name: "Card" } }] as DocumentEdit[], + }); + expect(result.kind).toBe("commands"); + if (result.kind !== "commands") return; + + const minted = mintedIds["0"]!; + expect(minted).toHaveLength(1); + const newId = minted[0]!; + + // The reconcile emitted a tree.insert whose node.id IS the minted id — proving + // the pre-minted id survives encode + reconcile and is addressable next. + const insert = result.commands.find( + (c) => (c as { kind: string }).kind === "tree.insert", + ) as { node: { id: string } } | undefined; + expect(insert).toBeDefined(); + expect(insert!.node.id).toBe(newId); + }); + + test("update touches only the edited node (no-op elsewhere → minimal commands)", () => { + const tree = liveTree(); + const screenId = screenIdOf(tree); + const { result } = applyDocumentEditsToTree({ + tree, + edits: [{ op: "update", nodeId: screenId, set: { name: "Renamed" } }] as DocumentEdit[], + }); + expect(result.kind).toBe("commands"); + if (result.kind !== "commands") return; + // Exactly the name field changed → a single object.set, no inserts/moves/deletes. + expect(result.commands.every((c) => (c as { kind: string }).kind === "object.set")).toBe(true); + }); + + test("a no-op edit batch (empty target diff) reconciles to zero commands", () => { + const tree = liveTree(); + const screenId = screenIdOf(tree); + // Setting the name to its current value is a no-op. + const { result } = applyDocumentEditsToTree({ + tree, + edits: [{ op: "update", nodeId: screenId, set: { name: "Main" } }] as DocumentEdit[], + }); + expect(result.kind).toBe("commands"); + if (result.kind !== "commands") return; + expect(result.commands).toEqual([]); + }); + + test("a malformed tree is rejected on the typed channel", () => { + const { result } = applyDocumentEditsToTree({ + tree: { not: "a tree" }, + edits: [{ op: "update", nodeId: "x", set: {} }] as DocumentEdit[], + }); + expect(result.kind).toBe("rejected"); + }); + + test("an entry-wrapped array-bearing node emits NO commands when untouched", () => { + // A view whose `localVariables` is an authored entry-wrapped array — decode + // wraps each item in an `{id,pos,value}` envelope. An UNRELATED edit (renaming + // the screen) must not re-emit that array: reconcile compares arrays by logical + // value (envelope-agnostic), so the untouched view yields zero commands. + const tree = encode([ + { + type: "root", + name: "Paywall", + children: [ + { + type: "screen", + name: "Main", + children: [ + { + type: "view", + name: "Stateful", + localVariables: [ + { id: "var_1", name: "plan", value: { key: "string", value: "yearly" } }, + ], + }, + ], + }, + ], + }, + ]); + const screenId = screenIdOf(tree); + // Read the array-bearing view's id so we can assert nothing addresses it. + const viewId = (tree as { nodes: { id: string; value: { fields: { type?: { value?: string } } } }[] }).nodes.find( + (node) => node.value.fields.type?.value === "view", + )!.id; + + const { result } = applyDocumentEditsToTree({ + tree, + edits: [{ op: "update", nodeId: screenId, set: { name: "Renamed" } }] as DocumentEdit[], + }); + expect(result.kind).toBe("commands"); + if (result.kind !== "commands") return; + // Only the screen's name changed — no command touches the array-bearing view. + const touchesView = result.commands.some((c) => { + const path = (c as { path?: unknown }).path; + return typeof path === "string" ? path.includes(viewId) : JSON.stringify(c).includes(viewId); + }); + expect(touchesView).toBe(false); + // And every emitted command is the single name set on the screen. + expect(result.commands.every((c) => (c as { kind: string }).kind === "object.set")).toBe(true); + }); +}); + +describe("writeComponentSourceToTree", () => { + test("creates the library + codeComponent for a new path", () => { + const tree = encode([ + { type: "root", name: "Paywall", children: [{ type: "screen", name: "Main" }] }, + ]); + const result = writeComponentSourceToTree({ + tree, + path: "components/hero.tsx", + source: "export default () => null;", + }); + expect(result.kind).toBe("commands"); + if (result.kind !== "commands") return; + // A fresh library + codeComponent are inserted. + const inserts = result.commands.filter((c) => (c as { kind: string }).kind === "tree.insert"); + const types = inserts.map( + (c) => + (c as unknown as { node: { value: { fields: { type: { value: string } } } } }).node.value + .fields.type.value, + ); + expect(types).toContain("library"); + expect(types).toContain("codeComponent"); + }); + + test("replaces an existing component's source in place (id preserved)", () => { + const tree = encode([ + { + type: "root", + name: "Paywall", + children: [ + { type: "screen", name: "Main" }, + { + type: "library", + children: [{ type: "codeComponent", path: "components/hero.tsx", source: "old" }], + }, + ], + }, + ]); + // Read the existing codeComponent id. + const ccId = (tree as { nodes: { id: string; value: { fields: { type?: { value?: string } } } }[] }).nodes.find( + (node) => node.value.fields.type?.value === "codeComponent", + )!.id; + + const result = writeComponentSourceToTree({ + tree, + path: "components/hero.tsx", + source: "new source", + }); + expect(result.kind).toBe("commands"); + if (result.kind !== "commands") return; + // No inserts (the node already exists) — only a field update on the same id. + expect(result.commands.some((c) => (c as { kind: string }).kind === "tree.insert")).toBe(false); + const update = result.commands.find( + (c) => (c as { kind: string; key?: string }).kind === "object.set" && (c as { key?: string }).key === "source", + ) as { path: unknown; value: { value?: string } } | undefined; + expect(update).toBeDefined(); + // The update carries the new source and the target tree still has the same id. + expect(treeHasNodeWithField(tree, ccId, "path", "components/hero.tsx")).toBe(true); + }); + + test("rejects an invalid component file name", () => { + const tree = encode([ + { type: "root", name: "Paywall", children: [{ type: "screen", name: "Main" }] }, + ]); + const result = writeComponentSourceToTree({ + tree, + path: "components/../evil.tsx", + source: "x", + }); + expect(result.kind).toBe("rejected"); + }); +}); diff --git a/packages/paywall-workspace/src/service-write.ts b/packages/paywall-workspace/src/service-write.ts new file mode 100644 index 000000000..962c314ce --- /dev/null +++ b/packages/paywall-workspace/src/service-write.ts @@ -0,0 +1,311 @@ +import type { TreeValue } from "@voidhash/mimic-core"; +import { PaywallDesignerDocument, reconcile } from "@voidhash/mimic-schema"; +import type { SnapshotNode } from "@voidhash/paywall-renderer-web-core"; +import { + applyDocumentEdits, + unwrapEntries, + type DocumentEdit, + type EditableDocumentNode, + type MintedIds, +} from "@voidhash/ai-shared"; + +export type { DocumentEdit, MintedIds } from "@voidhash/ai-shared"; + +import { fileNameFromDocRelative } from "./paths.ts"; +import { readComponentDefinitions } from "./snapshot.ts"; +import { + lowerComponentDelete, + lowerComponentMove, + validateComponentFileName, + type ApplyFileWriteResult, +} from "./write.ts"; + +/** + * Narrow a raw mimic document value (the encoded `TreeValue` returned by the + * mimic host as `unknown`) into a `TreeValue`, or `undefined` when it is not a + * well-formed tree. Structural — the pure package owns the narrowing (core stays + * free of the mimic-core value kinds). + */ +function asTreeValue(tree: unknown): TreeValue | undefined { + return tree !== null && + typeof tree === "object" && + (tree as { kind?: unknown }).kind === "tree" && + Array.isArray((tree as { nodes?: unknown }).nodes) + ? (tree as TreeValue) + : undefined; +} + +/** + * Encode a decoded paywall document (array of root snapshots) into the RAW + * `TreeValue` that the mimic host stores and the write path reconciles against. + * The inverse of `PaywallDesignerDocument.decode`. Exposed from the pure package + * so consumers (and tests) can build a raw document tree without importing + * `mimic-schema` directly — mirroring how {@link componentPathsFromTree} owns the + * decode. + */ +export function encodePaywallDocument(roots: unknown): unknown { + return PaywallDesignerDocument.encode(roots as never); +} + +/** + * Result of a server-side workspace lowering ({@link applyWorkspaceMove}, + * {@link applyWorkspaceDelete}, {@link reconcileToTree}): either the lowered + * command batch, or a rejection — including the case where the raw tree was not a + * well-formed tree value. + */ +export type ApplyWorkspaceWriteResult = + | ApplyFileWriteResult + | { + readonly kind: "rejected"; + readonly diagnostics: ReadonlyArray<{ readonly message: string }>; + }; + +/** + * Server-side orchestration of a component MOVE (a file rename): narrow the raw + * document tree and lower the move ({@link lowerComponentMove} — repaths the + * target `codeComponent` node's `path` and re-points every local instance + * referencing it). No registry needed (a move never touches the composition + * grammar directly, only node paths). A malformed tree is rejected on the typed + * channel. `fromPath`/`toPath` are canonical document-relative component paths + * (`components/.tsx`). + */ +export function applyWorkspaceMove(input: { + readonly fromPath: string; + readonly toPath: string; + readonly tree: unknown; +}): ApplyWorkspaceWriteResult { + const liveTree = asTreeValue(input.tree); + if (liveTree === undefined) { + return { + kind: "rejected", + diagnostics: [{ message: "The paywall document is not a well-formed tree." }], + }; + } + return lowerComponentMove(liveTree, input.fromPath, input.toPath); +} + +/** + * Server-side orchestration of a component DELETE: narrow the raw document tree + * and lower the delete ({@link lowerComponentDelete} — removes only the target + * `codeComponent` node, never cascading into `component` instances). No registry + * needed. A malformed tree is rejected on the typed channel. `path` is the + * component's canonical document-relative identity. + */ +export function applyWorkspaceDelete(input: { + readonly path: string; + readonly tree: unknown; +}): ApplyWorkspaceWriteResult { + const liveTree = asTreeValue(input.tree); + if (liveTree === undefined) { + return { + kind: "rejected", + diagnostics: [{ message: "The paywall document is not a well-formed tree." }], + }; + } + return lowerComponentDelete(liveTree, input.path); +} + +/** + * The canonical document-relative `path`s of a document's code-component + * definitions read from its RAW encoded tree — the existing paths a server-side + * component create needs to derive a unique file name + * ({@link uniqueComponentFileName}) the same way the browser does, and the keys a + * move must check for collisions. + */ +export function componentPathsFromTree(tree: unknown): string[] { + const value = asTreeValue(tree); + if (value === undefined) { + return []; + } + const snapshot = (PaywallDesignerDocument.decode(value) ?? []) as readonly SnapshotNode[]; + return readComponentDefinitions(snapshot).map((definition) => definition.path); +} + +/** + * Reconcile the CURRENT document tree back to a previously captured `target` + * tree — the checkpoint-revert lowering. Returns the minimal command batch that + * transforms the live document into the captured pre-turn state (empty when they + * already match). A malformed live or captured tree is rejected on the typed + * channel. Pure — the retry/submit loop lives in the workspace service. + */ +export function reconcileToTree(input: { + readonly current: unknown; + readonly target: unknown; +}): ApplyWorkspaceWriteResult { + const current = asTreeValue(input.current); + const target = asTreeValue(input.target); + if (current === undefined || target === undefined) { + return { + kind: "rejected", + diagnostics: [{ message: "A revert tree is not a well-formed document tree." }], + }; + } + return { kind: "commands", commands: reconcile(current, target) }; +} + +/** + * The outcome of {@link applyDocumentEditsToTree}: the reconcile lowering to + * submit, plus the ids minted for every node an `insert` / `replaceChildren` op + * created (so the caller can return them to the model). + */ +export interface ApplyDocumentEditsToTreeResult { + readonly result: ApplyWorkspaceWriteResult; + readonly mintedIds: MintedIds; +} + +/** + * Adapt a decoded document snapshot node to the ai-shared + * {@link EditableDocumentNode} the applier + encoder read: keep `id`/`type`, and + * UNWRAP the CRDT `{id,pos,value}` array envelopes inside `data` so the encoder + * (which re-mints array-item envelopes) accepts it — a proven no-op round-trip + * through `reconcile`, which ignores array-item envelopes. + */ +function snapshotToEditable(node: SnapshotNode): EditableDocumentNode { + return { + id: node.id, + type: node.type, + data: unwrapEntries(node.data) as Record, + children: node.children.map(snapshotToEditable), + }; +} + +/** Flatten an {@link EditableDocumentNode} into the `PaywallDesignerDocument.encode` input shape. */ +function editableToEncodeInput(node: EditableDocumentNode): Record { + const input: Record = { id: node.id, type: node.type }; + for (const [key, value] of Object.entries(node.data ?? {})) { + input[key] = value; + } + input["children"] = (node.children ?? []).map(editableToEncodeInput); + return input; +} + +/** + * Apply a batch of ALREADY-VALIDATED {@link DocumentEdit} ops to the live raw + * document `tree` and reconcile the result into a command batch — the server + * lowering behind the AI/MCP `edit_paywall` tool. Decodes the raw tree to the + * editable snapshot, runs the pure {@link applyDocumentEdits} applier (which + * pre-mints real {@link import("@voidhash/mimic-core").shortId} ids for + * created nodes), re-encodes the target, and `reconcile`s the live tree to it. + * + * The minted ids SURVIVE: `PaywallDesignerDocument.encode` preserves any `id` + * present on an input node, and `reconcile` emits `tree.insert` carrying the + * target id verbatim for a node id absent from the live tree — so a follow-up + * edit can address the newly-created nodes by the returned ids. + * + * Designed to be re-run per submit attempt against a FRESH `tree`: the ops are + * id/position-based, so re-deriving the target from an advanced live tree merges + * concurrent edits rather than clobbering them. A malformed tree is rejected on + * the typed channel. + */ +export function applyDocumentEditsToTree(input: { + readonly tree: unknown; + readonly edits: readonly DocumentEdit[]; +}): ApplyDocumentEditsToTreeResult { + const current = asTreeValue(input.tree); + if (current === undefined) { + return { + result: { + kind: "rejected", + diagnostics: [{ message: "The paywall document is not a well-formed tree." }], + }, + mintedIds: {}, + }; + } + const snapshot = (PaywallDesignerDocument.decode(current) ?? []) as readonly SnapshotNode[]; + const root = snapshot[0]; + if (root === undefined) { + return { + result: { + kind: "rejected", + diagnostics: [{ message: "The paywall document has no root node to edit." }], + }, + mintedIds: {}, + }; + } + const { root: target, mintedIds } = applyDocumentEdits(snapshotToEditable(root), input.edits); + const encoded = PaywallDesignerDocument.encode([editableToEncodeInput(target)] as never); + const targetTree = asTreeValue(encoded); + if (targetTree === undefined) { + return { + result: { + kind: "rejected", + diagnostics: [{ message: "The edited document failed to re-encode into a tree." }], + }, + mintedIds: {}, + }; + } + return { + result: { kind: "commands", commands: reconcile(current, targetTree) }, + mintedIds, + }; +} + +/** + * Lower a component-source WRITE to a reconcile command batch: create-or-replace + * a `codeComponent` definition at the canonical document-relative `path` + * (`components/.tsx`), mirroring the browser's create/write semantics. + * + * - An existing `codeComponent` at `path` has its `source` replaced. + * - A new `path` is created: the singleton `library` node under the root is + * found (created on first use), and a fresh `codeComponent` with `{path, + * source}` is appended — the engine mints its id (which the write does not + * need to return; a follow-up read addresses it by path). + * + * A malformed tree (or one with no root) is rejected on the typed channel, as is + * an invalid component file name. Pure — decodes, edits the snapshot, re-encodes, + * and reconciles against the live tree; re-runnable per submit attempt. + */ +export function writeComponentSourceToTree(input: { + readonly tree: unknown; + readonly path: string; + readonly source: string; +}): ApplyWorkspaceWriteResult { + const nameError = validateComponentFileName(fileNameFromDocRelative(input.path)); + if (nameError !== undefined) { + return { kind: "rejected", diagnostics: [{ message: nameError }] }; + } + const current = asTreeValue(input.tree); + if (current === undefined) { + return { + kind: "rejected", + diagnostics: [{ message: "The paywall document is not a well-formed tree." }], + }; + } + const snapshot = (PaywallDesignerDocument.decode(current) ?? []) as readonly SnapshotNode[]; + const root = snapshot[0]; + if (root === undefined) { + return { + kind: "rejected", + diagnostics: [{ message: "The paywall document has no root node to write into." }], + }; + } + + const rootInput = editableToEncodeInput(snapshotToEditable(root)); + const children = rootInput["children"] as Record[]; + + let library = children.find((child) => child["type"] === "library"); + if (library === undefined) { + library = { type: "library", children: [] as Record[] }; + children.push(library); + } + const libraryChildren = (library["children"] ??= []) as Record[]; + + const existing = libraryChildren.find( + (child) => child["type"] === "codeComponent" && child["path"] === input.path, + ); + if (existing !== undefined) { + existing["source"] = input.source; + } else { + libraryChildren.push({ type: "codeComponent", path: input.path, source: input.source }); + } + + const encoded = PaywallDesignerDocument.encode([rootInput] as never); + const targetTree = asTreeValue(encoded); + if (targetTree === undefined) { + return { + kind: "rejected", + diagnostics: [{ message: "The edited document failed to re-encode into a tree." }], + }; + } + return { kind: "commands", commands: reconcile(current, targetTree) }; +} diff --git a/packages/paywall-workspace/src/snapshot.ts b/packages/paywall-workspace/src/snapshot.ts new file mode 100644 index 000000000..14a0a4912 --- /dev/null +++ b/packages/paywall-workspace/src/snapshot.ts @@ -0,0 +1,49 @@ +import type { + CodeComponentSnapshotNode, + SnapshotNode, +} from "@voidhash/paywall-renderer-web-core"; + +/** + * Pure snapshot readers over the decoded paywall document (the array of root + * snapshots produced by `PaywallDesignerDocument.decode`). Reimplemented here — + * rather than importing the store-coupled designer selectors — so the workspace + * projection stays free of any Zustand/React dependency and runs unchanged in a + * Worker or Node. + */ + +/** + * A code-component definition read from a document snapshot. `path` is the + * component's IDENTITY — the canonical document-relative path + * `components/.tsx` stored on the `codeComponent` node. `id` is the + * CRDT node id (still needed to target designer actions at a specific node). + */ +export interface WorkspaceComponentDefinition { + readonly id: string; + readonly path: string; + readonly source: string; +} + +/** + * The `codeComponent` definition nodes of a document, read from the singleton + * `library` node under the (single) document root. Returns an empty list when + * the document has no root or no library yet. + */ +export function readComponentDefinitions( + snapshot: readonly SnapshotNode[], +): WorkspaceComponentDefinition[] { + const root = snapshot[0]; + if (root === undefined) { + return []; + } + const library = root.children.find((child) => child.type === "library"); + if (library === undefined) { + return []; + } + return library.children.flatMap((node) => + node.type === "codeComponent" ? [toDefinition(node)] : [], + ); +} + +function toDefinition(node: CodeComponentSnapshotNode): WorkspaceComponentDefinition { + return { id: node.id, path: node.data.path, source: node.data.source }; +} diff --git a/packages/paywall-workspace/src/write.test.ts b/packages/paywall-workspace/src/write.test.ts new file mode 100644 index 000000000..ea76716c9 --- /dev/null +++ b/packages/paywall-workspace/src/write.test.ts @@ -0,0 +1,236 @@ +import { applyBatch, type TreeValue } from "@voidhash/mimic-core"; +import { PaywallDesignerDocument } from "@voidhash/mimic-schema"; +import type { SnapshotNode } from "@voidhash/paywall-renderer-web-core"; +import { describe, expect, test } from "vite-plus/test"; + +import { readComponentDefinitions } from "./snapshot.ts"; +import { + lowerComponentDelete, + lowerComponentMove, + uniqueComponentFileName, + validateComponentFileName, +} from "./write.ts"; + +const enc = (input: unknown): TreeValue => + PaywallDesignerDocument.encode(input as never) as TreeValue; +const decode = (tree: TreeValue): readonly SnapshotNode[] => + PaywallDesignerDocument.decode(tree)! as readonly SnapshotNode[]; + +const docWithComponent = (source: string): TreeValue => + enc([ + { + type: "root", + name: "Paywall", + children: [ + { type: "screen", name: "Main" }, + { + type: "library", + children: [{ type: "codeComponent", path: "components/hero.tsx", source }], + }, + ], + }, + ]); + +/** + * A document root whose library holds two `codeComponent` definitions plus a + * `component` INSTANCE node under the screen referencing the first one (via + * `componentPath`) — the shape a delete must NOT cascade into, and a move MUST + * re-point. + */ +const docWithTwoComponentsAndInstance = (): TreeValue => + enc([ + { + type: "root", + name: "Paywall", + children: [ + { + type: "screen", + name: "Main", + children: [ + { + type: "component", + componentSource: "local", + componentPath: "components/hero.tsx", + }, + ], + }, + { + type: "library", + children: [ + { + type: "codeComponent", + path: "components/hero.tsx", + source: "export const Hero = () => null;", + }, + { + type: "codeComponent", + path: "components/promo.tsx", + source: "export const Promo = () => null;", + }, + ], + }, + ], + }, + ]); + +/** The doc-relative path of a component instance node (local instances only). */ +const instanceComponentPath = (tree: TreeValue): string | undefined => { + const node = tree.nodes.find( + (n) => n.value.fields.type?.kind === "string" && n.value.fields.type.value === "component", + ); + const field = node?.value.fields.componentPath; + return field && field.kind === "string" ? field.value : undefined; +}; + +describe("lowerComponentMove", () => { + test("repaths the definition AND re-points every local instance", () => { + const live = docWithTwoComponentsAndInstance(); + const result = lowerComponentMove(live, "components/hero.tsx", "components/hero-banner.tsx"); + expect(result.kind).toBe("commands"); + if (result.kind !== "commands") return; + + const applied = applyBatch(live, result.commands) as TreeValue; + // The definition is at the new path; the old path is gone. + expect(readComponentDefinitions(decode(applied)).map((d) => d.path).sort()).toEqual([ + "components/hero-banner.tsx", + "components/promo.tsx", + ]); + // The instance node was re-pointed (no orphaning). + expect(instanceComponentPath(applied)).toBe("components/hero-banner.tsx"); + }); + + test("re-points instances in one command batch (no orphaned reference)", () => { + const live = docWithTwoComponentsAndInstance(); + const result = lowerComponentMove(live, "components/hero.tsx", "components/hero-banner.tsx"); + if (result.kind !== "commands") throw new Error("expected commands"); + // The single reconcile batch carries both the definition repath and the + // instance re-point — applying it leaves no reference to the old path. + const applied = applyBatch(live, result.commands) as TreeValue; + const referencesOldPath = applied.nodes.some( + (n) => + n.value.fields.componentPath?.kind === "string" && + n.value.fields.componentPath.value === "components/hero.tsx", + ); + const definesOldPath = applied.nodes.some( + (n) => + n.value.fields.type?.kind === "string" && + n.value.fields.type.value === "codeComponent" && + n.value.fields.path?.kind === "string" && + n.value.fields.path.value === "components/hero.tsx", + ); + expect(referencesOldPath).toBe(false); + expect(definesOldPath).toBe(false); + }); + + test("a move to the current path is a zero-command no-op", () => { + const live = docWithComponent("export const Hero = () => null;"); + const result = lowerComponentMove(live, "components/hero.tsx", "components/hero.tsx"); + expect(result).toEqual({ kind: "commands", commands: [] }); + }); + + test("rejects a move whose source path has no component", () => { + const live = docWithComponent("export const Hero = () => null;"); + const result = lowerComponentMove(live, "components/ghost.tsx", "components/ghost-2.tsx"); + expect(result.kind).toBe("rejected"); + }); + + test("rejects a move to a path that already exists", () => { + const live = docWithTwoComponentsAndInstance(); + const result = lowerComponentMove(live, "components/hero.tsx", "components/promo.tsx"); + expect(result.kind).toBe("rejected"); + if (result.kind === "rejected") { + expect(result.diagnostics[0]!.message).toContain("already exists"); + } + }); + + test("rejects a move to an invalid file name", () => { + const live = docWithComponent("export const Hero = () => null;"); + const result = lowerComponentMove(live, "components/hero.tsx", "components/hero.ts"); + expect(result.kind).toBe("rejected"); + }); +}); +describe("lowerComponentDelete", () => { + test("removes only the target codeComponent node, leaving other definitions", () => { + const live = docWithTwoComponentsAndInstance(); + const result = lowerComponentDelete(live, "components/promo.tsx"); + expect(result.kind).toBe("commands"); + if (result.kind !== "commands") return; + + const applied = applyBatch(live, result.commands) as TreeValue; + const definitions = readComponentDefinitions(decode(applied)); + expect(definitions.map((d) => d.path)).toEqual(["components/hero.tsx"]); + }); + + test("does NOT cascade-delete component instance nodes referencing the deleted component", () => { + const live = docWithTwoComponentsAndInstance(); + const result = lowerComponentDelete(live, "components/hero.tsx"); + if (result.kind !== "commands") throw new Error("expected commands"); + + const applied = applyBatch(live, result.commands) as TreeValue; + // The `component` INSTANCE node under the screen survives (degrades to a + // placeholder in the designer) — matching the browser's removeCodeComponent. + const instanceSurvives = applied.nodes.some( + (node) => + node.value.fields.type?.kind === "string" && node.value.fields.type.value === "component", + ); + expect(instanceSurvives).toBe(true); + // Only the definition is gone. + expect(readComponentDefinitions(decode(applied)).map((d) => d.path)).toEqual([ + "components/promo.tsx", + ]); + }); + + test("rejects an unknown path", () => { + const live = docWithComponent("export const Hero = () => null;"); + const result = lowerComponentDelete(live, "components/ghost.tsx"); + expect(result.kind).toBe("rejected"); + }); +}); + +describe("validateComponentFileName", () => { + test("accepts a well-formed .tsx basename", () => { + expect(validateComponentFileName("hero.tsx")).toBeUndefined(); + expect(validateComponentFileName("pricing-option.tsx")).toBeUndefined(); + expect(validateComponentFileName("pricing.card.tsx")).toBeUndefined(); + }); + + test("rejects a missing/wrong extension", () => { + expect(validateComponentFileName("hero")).toContain(".tsx"); + expect(validateComponentFileName("hero.ts")).toContain(".tsx"); + }); + + test("rejects an empty base name", () => { + expect(validateComponentFileName(".tsx")).toContain("base name"); + }); + + test("rejects path separators and traversal", () => { + expect(validateComponentFileName("sub/hero.tsx")).toContain("path separator"); + expect(validateComponentFileName("sub\\hero.tsx")).toContain("path separator"); + expect(validateComponentFileName("..tsx")).toContain(".."); + }); + + test("rejects unsupported characters", () => { + expect(validateComponentFileName("hero widget.tsx")).toContain("unsupported"); + expect(validateComponentFileName("héro.tsx")).toContain("unsupported"); + }); +}); + +describe("uniqueComponentFileName", () => { + test("returns the base name when there is no collision", () => { + expect(uniqueComponentFileName("hero.tsx", ["promo.tsx"])).toBe("hero.tsx"); + }); + + test("appends -2, -3 before the extension on collision", () => { + expect(uniqueComponentFileName("hero.tsx", ["hero.tsx"])).toBe("hero-2.tsx"); + expect(uniqueComponentFileName("hero.tsx", ["hero.tsx", "hero-2.tsx"])).toBe("hero-3.tsx"); + }); + + test("uniqueness is case-insensitive", () => { + expect(uniqueComponentFileName("Hero.tsx", ["hero.tsx"])).toBe("Hero-2.tsx"); + }); + + test("treats an extensionless base as a stem", () => { + expect(uniqueComponentFileName("hero", [])).toBe("hero.tsx"); + expect(uniqueComponentFileName("hero", ["hero.tsx"])).toBe("hero-2.tsx"); + }); +}); diff --git a/packages/paywall-workspace/src/write.ts b/packages/paywall-workspace/src/write.ts new file mode 100644 index 000000000..32f5204d9 --- /dev/null +++ b/packages/paywall-workspace/src/write.ts @@ -0,0 +1,273 @@ +import { + stringValue, + treeValue, + type Command, + type ObjectValue, + type TreeNode, + type TreeValue, + type Value, +} from "@voidhash/mimic-core"; +import { reconcile } from "@voidhash/mimic-schema"; + +import { fileNameFromDocRelative } from "./paths.ts"; + +/** + * A single write diagnostic (workspace-level, before any compile phase). A + * rejection carries error-level diagnostics; an accepted write may carry + * info-level ones (e.g. a duplicate inline id that was silently re-minted, §5 + * edge rule 1). `severity` defaults to `error` when absent (rejections). + */ +export interface WriteDiagnostic { + readonly message: string; + readonly severity?: "error" | "info"; +} + +/** + * Result of lowering a file write: either the minimal CRDT command batch to + * apply to the document (optionally with info-level `diagnostics` that ride + * along — e.g. a duplicate inline id that was re-minted), or a rejection + * carrying error diagnostics. + */ +export type ApplyFileWriteResult = + | { + readonly kind: "commands"; + readonly commands: Command[]; + readonly diagnostics?: WriteDiagnostic[]; + } + | { readonly kind: "rejected"; readonly diagnostics: WriteDiagnostic[] }; + +const rejected = (message: string): ApplyFileWriteResult => ({ + kind: "rejected", + diagnostics: [{ message }], +}); + +/** + * The character set a component file basename may use: an identifier optionally + * with dashes/dots (e.g. `pricing-option.tsx`, `pricing.card.tsx`). Rules out + * whitespace and shell/path metacharacters that would break addressing. + */ +const COMPONENT_FILENAME_CHARSET = /^[A-Za-z0-9._-]+$/; + +/** + * Validate a component file basename (`.tsx`) — the identity segment of + * a component path. Returns an error message when invalid, or `undefined` when + * acceptable. + * + * Rules: non-empty basename before the extension, must end `.tsx`, no path + * separators (`/` or `\`) or `..` traversal, and a sensible charset + * (alphanumerics, `-`, `_`, `.`). Enforced at the write boundary since the mimic + * `path` schema field is a permissive string. + */ +export function validateComponentFileName(fileName: string): string | undefined { + if (fileName.includes("/") || fileName.includes("\\")) { + return `Component file name "${fileName}" must not contain a path separator.`; + } + if (fileName.includes("..")) { + return `Component file name "${fileName}" must not contain "..".`; + } + if (!fileName.endsWith(".tsx")) { + return `Component file name "${fileName}" must end with ".tsx".`; + } + if (fileName.length <= ".tsx".length) { + return `Component file name "${fileName}" is missing a base name before ".tsx".`; + } + if (!COMPONENT_FILENAME_CHARSET.test(fileName)) { + return `Component file name "${fileName}" contains unsupported characters (use letters, digits, "-", "_", ".").`; + } + return undefined; +} + +/** + * Derive a component file basename unique among `existingFileNames`, matching the + * browser's `uniqueCodeComponentSlug` suffixing but keyed on the file name: + * uniqueness is case-INSENSITIVE (the workspace never allows two component files + * whose names differ only in case), and a collision appends `-2`, `-3`, … BEFORE + * the `.tsx` extension until unused. Pure so a server-side component create (a + * `write_file` to a new `components/.tsx` path) mints the same file name + * the browser create would. + * + * `base` is a full `.tsx` file name; a `base` without a `.tsx` + * extension is treated as an extensionless basename and suffixed as + * `-2.tsx`. + */ +export function uniqueComponentFileName(base: string, existingFileNames: Iterable): string { + const existing = new Set(); + for (const name of existingFileNames) { + existing.add(name.toLowerCase()); + } + const { stem, ext } = splitFileName(base); + const candidate = `${stem}${ext}`; + if (!existing.has(candidate.toLowerCase())) { + return candidate; + } + let index = 2; + while (existing.has(`${stem}-${index}${ext}`.toLowerCase())) { + index += 1; + } + return `${stem}-${index}${ext}`; +} + +/** Split a file name into its `stem` and `.tsx` `ext` (`ext` empty when absent). */ +function splitFileName(fileName: string): { readonly stem: string; readonly ext: string } { + return fileName.endsWith(".tsx") + ? { stem: fileName.slice(0, -".tsx".length), ext: ".tsx" } + : { stem: fileName, ext: ".tsx" }; +} + +/** + * Lower a component MOVE (a rename in the file sense) to the minimal command + * batch: rewrite the target `codeComponent` node's `path` AND re-point every + * local `component` instance node whose `componentPath` matches the old path, so + * no instance is orphaned. `fromPath`/`toPath` are canonical document-relative + * paths (`components/.tsx`). + * + * A move is the new identity of a component (component identity is the path), so + * unlike the old display-name rename it changes both the definition's path and + * every reference to it — in ONE reconcile batch. A move whose `toPath` already + * names another component is rejected (a path collision would merge two + * components' identities). A move to the current path yields a zero-command + * no-op via `reconcile`. + */ +export function lowerComponentMove( + liveTree: TreeValue, + fromPath: string, + toPath: string, +): ApplyFileWriteResult { + const validation = validateComponentFileName(fileNameFromDocRelative(toPath)); + if (validation !== undefined) { + return rejected(`Cannot move component to "${toPath}": ${validation}`); + } + if (fromPath !== toPath && liveTree.nodes.some((node) => codeComponentPath(node) === toPath)) { + return rejected(`Cannot move component to "${toPath}": a component already exists there.`); + } + const target = moveComponent(liveTree, fromPath, toPath); + if (target === undefined) { + return rejected(`No component at "${fromPath}" to move.`); + } + return { kind: "commands", commands: reconcile(liveTree, target) }; +} + +/** + * Pure tree→tree transform for a move: return the target tree with the + * `codeComponent` for `fromPath` repathed to `toPath` and every local instance + * node referencing `fromPath` re-pointed to `toPath` — or `undefined` when no + * such definition exists. Used by {@link lowerComponentMove}. Does NOT guard + * against a `toPath` collision (callers do). + */ +function moveComponent( + liveTree: TreeValue, + fromPath: string, + toPath: string, +): TreeValue | undefined { + const node = liveTree.nodes.find((candidate) => codeComponentPath(candidate) === fromPath); + if (node === undefined) { + return undefined; + } + if (fromPath === toPath) { + return liveTree; + } + return treeValue( + liveTree.nodes.map((candidate) => { + if (candidate.id === node.id) { + return withComponentPath(candidate, toPath); + } + // Re-point local instance nodes referencing the moved component so the + // reference survives the identity change (no orphaned instances). + if (localInstanceComponentPath(candidate) === fromPath) { + return withInstanceComponentPath(candidate, toPath); + } + return candidate; + }), + ); +} + +/** + * Lower a component DELETE to the minimal command batch: remove the target + * `codeComponent` node from the library. + * + * This mirrors the browser's `removeCodeComponent` EXACTLY: it removes ONLY the + * `codeComponent` definition node and does NOT cascade-delete `component` + * instance nodes that reference it. Canvas instances referencing the deleted + * definition degrade to placeholders in the designer (existing behavior — + * `use-component-node-warnings` surfaces a `local-component-missing` warning), + * so leaving them in place is the correct match. `reconcile` emits the single + * `tree.remove` for the omitted node (leaf, no children — `codeComponent` nodes + * hold no render subtree). `path` is the component's canonical + * document-relative identity. + */ +export function lowerComponentDelete( + liveTree: TreeValue, + path: string, +): ApplyFileWriteResult { + const target = deleteComponent(liveTree, path); + if (target === undefined) { + return rejected(`No component at "${path}" to delete.`); + } + return { kind: "commands", commands: reconcile(liveTree, target) }; +} + +/** + * Pure tree→tree transform for a delete: return the target tree with the + * `codeComponent` for `path` removed (ONLY the definition node — never cascading + * into `component` instances), or `undefined` when no such component exists. + * Used by {@link lowerComponentDelete}. + */ +function deleteComponent(liveTree: TreeValue, path: string): TreeValue | undefined { + const node = liveTree.nodes.find((candidate) => codeComponentPath(candidate) === path); + if (node === undefined) { + return undefined; + } + return treeValue(liveTree.nodes.filter((candidate) => candidate.id !== node.id)); +} + +/** + * The canonical `path` of a `codeComponent` tree node, or `undefined` for other + * nodes. This is the component's identity. + */ +function codeComponentPath(node: TreeNode): string | undefined { + return stringField(node.value, "type") === "codeComponent" + ? stringField(node.value, "path") + : undefined; +} + +/** + * The `componentPath` a LOCAL `component` instance node references, or + * `undefined` for non-component / catalog-instance nodes. Used to re-point + * instances on a component move. + */ +function localInstanceComponentPath(node: TreeNode): string | undefined { + if (stringField(node.value, "type") !== "component") { + return undefined; + } + return stringField(node.value, "componentSource") === "local" + ? stringField(node.value, "componentPath") + : undefined; +} + +/** Rewrite a `codeComponent` node's `path` field to `path`, preserving all other fields. */ +function withComponentPath(node: TreeNode, path: string): TreeNode { + return { + ...node, + value: { + kind: "object", + fields: { ...node.value.fields, path: stringValue(path) }, + }, + }; +} + +/** Rewrite a `component` instance node's `componentPath` field, preserving all others. */ +function withInstanceComponentPath(node: TreeNode, componentPath: string): TreeNode { + return { + ...node, + value: { + kind: "object", + fields: { ...node.value.fields, componentPath: stringValue(componentPath) }, + }, + }; +} + +/** Reads a string field off an object value, or `undefined` when absent/non-string. */ +function stringField(object: ObjectValue, key: string): string | undefined { + const field: Value | undefined = object.fields[key]; + return field && field.kind === "string" ? field.value : undefined; +} diff --git a/packages/paywall-workspace/tsconfig.json b/packages/paywall-workspace/tsconfig.json new file mode 100644 index 000000000..3a6c52735 --- /dev/null +++ b/packages/paywall-workspace/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@voidhash/tsconfig/internal-package-typescript-6.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"], + "exclude": ["**/node_modules/**"] +} diff --git a/packages/paywall-workspace/vitest.mts b/packages/paywall-workspace/vitest.mts new file mode 100644 index 000000000..926ffb62d --- /dev/null +++ b/packages/paywall-workspace/vitest.mts @@ -0,0 +1,11 @@ +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + exclude: ["./node_modules/**"], + include: ["./**/*.test.ts"], + reporters: ["verbose"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab4d86578..446b11fa4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,9 +27,15 @@ catalogs: better-auth: specifier: ^1.6.23 version: 1.6.23 + vite: + specifier: ^7.3.5 + version: 7.3.5 vite-tsconfig-paths: specifier: ^5.1.4 version: 5.1.4 + zod: + specifier: ^4.1.13 + version: 4.3.6 overrides: react: 19.1.0 @@ -500,6 +506,37 @@ importers: specifier: ^3.2.7 version: 3.2.7(@types/debug@4.1.12)(@types/node@25.3.3)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + packages/ai-shared: + dependencies: + '@voidhash/mimic-core': + specifier: workspace:* + version: link:../mimic-core + '@voidhash/mimic-schema': + specifier: workspace:* + version: link:../mimic-schema + '@voidhash/paywall-builtins': + specifier: workspace:* + version: link:../paywall-builtins + zod: + specifier: 'catalog:' + version: 4.3.6 + devDependencies: + '@voidhash/tsconfig': + specifier: workspace:* + version: link:../tsconfig + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite-plus: + specifier: 0.1.23 + version: 0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@6.0.3)(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/debug@4.1.12)(@types/node@25.3.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + packages/api-contracts: dependencies: effect: @@ -694,6 +731,37 @@ importers: specifier: ^3.2.7 version: 3.2.7(@types/debug@4.1.12)(@types/node@25.3.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + packages/paywall-build: + dependencies: + '@voidhash/paywalls': + specifier: workspace:* + version: link:../../libraries/paywalls + typescript: + specifier: 6.0.3 + version: 6.0.3 + devDependencies: + '@types/node': + specifier: ^20 + version: 20.19.43 + '@voidhash/tsconfig': + specifier: workspace:* + version: link:../tsconfig + esbuild: + specifier: ^0.25.10 + version: 0.25.12 + tsx: + specifier: ^4.19.2 + version: 4.21.0 + vite-plus: + specifier: 0.1.23 + version: 0.1.23(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@6.0.3)(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/debug@4.1.12)(@types/node@20.19.43)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + packages/paywall-builtins: dependencies: '@voidhash/mimic-schema': @@ -722,6 +790,105 @@ importers: specifier: ^3.2.7 version: 3.2.7(@types/debug@4.1.12)(@types/node@20.19.43)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + packages/paywall-renderer-preact: + dependencies: + '@voidhash/mimic-schema': + specifier: workspace:* + version: link:../mimic-schema + '@voidhash/paywall-renderer-web-core': + specifier: workspace:* + version: link:../paywall-renderer-web-core + '@voidhash/paywalls': + specifier: workspace:* + version: link:../../libraries/paywalls + preact: + specifier: 10.29.2 + version: 10.29.2 + preact-render-to-string: + specifier: 6.7.0 + version: 6.7.0(preact@10.29.2) + devDependencies: + '@voidhash/paywall-builtins': + specifier: workspace:* + version: link:../paywall-builtins + '@voidhash/tsconfig': + specifier: workspace:* + version: link:../tsconfig + esbuild: + specifier: 0.25.12 + version: 0.25.12 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 'catalog:' + version: 7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vite-plus: + specifier: 0.1.23 + version: 0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/debug@4.1.12)(@types/node@25.3.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + + packages/paywall-renderer-web-core: + dependencies: + '@voidhash/mimic-schema': + specifier: workspace:* + version: link:../mimic-schema + '@voidhash/paywalls': + specifier: workspace:* + version: link:../../libraries/paywalls + csstype: + specifier: ^3.1.3 + version: 3.2.3 + devDependencies: + '@voidhash/tsconfig': + specifier: workspace:* + version: link:../tsconfig + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite-plus: + specifier: 0.1.23 + version: 0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/debug@4.1.12)(@types/node@25.3.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + + packages/paywall-workspace: + dependencies: + '@voidhash/ai-shared': + specifier: workspace:* + version: link:../ai-shared + '@voidhash/mimic-core': + specifier: workspace:* + version: link:../mimic-core + '@voidhash/mimic-schema': + specifier: workspace:* + version: link:../mimic-schema + '@voidhash/paywall-renderer-web-core': + specifier: workspace:* + version: link:../paywall-renderer-web-core + devDependencies: + '@types/node': + specifier: ^20 + version: 20.19.43 + '@voidhash/tsconfig': + specifier: workspace:* + version: link:../tsconfig + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite-plus: + specifier: 0.1.23 + version: 0.1.23(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@6.0.3)(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/debug@4.1.12)(@types/node@20.19.43)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + packages/platform: devDependencies: '@voidhash/tsconfig': @@ -8653,6 +8820,14 @@ packages: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} + preact-render-to-string@6.7.0: + resolution: {integrity: sha512-Z4WR8fmLMRpdYqJ9i7vrlXSsSrxVJydwrkEXHapexfARbWfGb7vGcnvNQnIzN0cXciMVOlz/XLoiMCi9gUsy9Q==} + peerDependencies: + preact: '>=10 || >= 11.0.0-0' + + preact@10.29.2: + resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -14938,6 +15113,23 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@voidzero-dev/vite-plus-core@0.1.23(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3)': + dependencies: + '@oxc-project/runtime': 0.133.0 + '@oxc-project/types': 0.133.0 + lightningcss: 1.32.0 + postcss: 8.5.10 + optionalDependencies: + '@types/node': 20.19.43 + esbuild: 0.25.12 + fsevents: 2.3.3 + jiti: 2.6.1 + terser: 5.44.1 + tsx: 4.21.0 + typescript: 6.0.3 + unrun: 0.2.39(synckit@0.11.11) + yaml: 2.8.3 + '@voidzero-dev/vite-plus-core@0.1.23(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3)': dependencies: '@oxc-project/runtime': 0.133.0 @@ -14955,6 +15147,23 @@ snapshots: unrun: 0.2.39(synckit@0.11.11) yaml: 2.8.3 + '@voidzero-dev/vite-plus-core@0.1.23(@types/node@25.3.3)(esbuild@0.25.12)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3)': + dependencies: + '@oxc-project/runtime': 0.133.0 + '@oxc-project/types': 0.133.0 + lightningcss: 1.32.0 + postcss: 8.5.10 + optionalDependencies: + '@types/node': 25.3.3 + esbuild: 0.25.12 + fsevents: 2.3.3 + jiti: 2.6.1 + terser: 5.44.1 + tsx: 4.21.0 + typescript: 6.0.3 + unrun: 0.2.39(synckit@0.11.11) + yaml: 2.8.3 + '@voidzero-dev/vite-plus-core@0.1.23(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(typescript@5.8.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3)': dependencies: '@oxc-project/runtime': 0.133.0 @@ -14989,6 +15198,23 @@ snapshots: unrun: 0.2.39(synckit@0.11.11) yaml: 2.8.3 + '@voidzero-dev/vite-plus-core@0.1.23(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3)': + dependencies: + '@oxc-project/runtime': 0.133.0 + '@oxc-project/types': 0.133.0 + lightningcss: 1.32.0 + postcss: 8.5.10 + optionalDependencies: + '@types/node': 25.3.3 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.6.1 + terser: 5.44.1 + tsx: 4.21.0 + typescript: 6.0.3 + unrun: 0.2.39(synckit@0.11.11) + yaml: 2.8.3 + '@voidzero-dev/vite-plus-darwin-arm64@0.1.23': optional: true @@ -15007,6 +15233,48 @@ snapshots: '@voidzero-dev/vite-plus-linux-x64-musl@0.1.23': optional: true + '@voidzero-dev/vite-plus-test@0.1.23(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@voidzero-dev/vite-plus-core': 0.1.23(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) + es-module-lexer: 1.7.0 + obug: 2.1.1 + pixelmatch: 7.2.0 + pngjs: 7.0.0 + sirv: 3.0.2 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + vite: 7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + ws: 8.21.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 20.19.43 + jsdom: 26.1.0 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@tsdown/css' + - '@tsdown/exe' + - '@vitejs/devtools' + - bufferutil + - esbuild + - jiti + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - yaml + '@voidzero-dev/vite-plus-test@0.1.23(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)': dependencies: '@standard-schema/spec': 1.1.0 @@ -15049,11 +15317,11 @@ snapshots: - utf-8-validate - yaml - '@voidzero-dev/vite-plus-test@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.8.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)': + '@voidzero-dev/vite-plus-test@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@voidzero-dev/vite-plus-core': 0.1.23(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(typescript@5.8.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) + '@voidzero-dev/vite-plus-core': 0.1.23(@types/node@25.3.3)(esbuild@0.25.12)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) es-module-lexer: 1.7.0 obug: 2.1.1 pixelmatch: 7.2.0 @@ -15091,11 +15359,11 @@ snapshots: - utf-8-validate - yaml - '@voidzero-dev/vite-plus-test@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)': + '@voidzero-dev/vite-plus-test@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.8.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@voidzero-dev/vite-plus-core': 0.1.23(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) + '@voidzero-dev/vite-plus-core': 0.1.23(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(typescript@5.8.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) es-module-lexer: 1.7.0 obug: 2.1.1 pixelmatch: 7.2.0 @@ -15133,35 +15401,119 @@ snapshots: - utf-8-validate - yaml - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.23': - optional: true - - '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.23': - optional: true - - '@xmldom/xmldom@0.8.13': {} - - abab@2.0.6: {} - - abort-controller@3.0.0: - dependencies: - event-target-shim: 5.0.1 - - accepts@1.3.8: - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - - acorn-globals@7.0.1: - dependencies: - acorn: 8.16.0 - acorn-walk: 8.3.4 - - acorn-jsx@5.3.2(acorn@8.15.0): + '@voidzero-dev/vite-plus-test@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)': dependencies: - acorn: 8.15.0 - - acorn-walk@8.3.4: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@voidzero-dev/vite-plus-core': 0.1.23(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) + es-module-lexer: 1.7.0 + obug: 2.1.1 + pixelmatch: 7.2.0 + pngjs: 7.0.0 + sirv: 3.0.2 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + vite: 7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + ws: 8.21.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 25.3.3 + jsdom: 26.1.0 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@tsdown/css' + - '@tsdown/exe' + - '@vitejs/devtools' + - bufferutil + - esbuild + - jiti + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - yaml + + '@voidzero-dev/vite-plus-test@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@voidzero-dev/vite-plus-core': 0.1.23(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) + es-module-lexer: 1.7.0 + obug: 2.1.1 + pixelmatch: 7.2.0 + pngjs: 7.0.0 + sirv: 3.0.2 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + vite: 7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + ws: 8.21.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 25.3.3 + jsdom: 26.1.0 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@tsdown/css' + - '@tsdown/exe' + - '@vitejs/devtools' + - bufferutil + - esbuild + - jiti + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - yaml + + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.23': + optional: true + + '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.23': + optional: true + + '@xmldom/xmldom@0.8.13': {} + + abab@2.0.6: {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-globals@7.0.1: + dependencies: + acorn: 8.16.0 + acorn-walk: 8.3.4 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn-walk@8.3.4: dependencies: acorn: 8.16.0 @@ -18969,6 +19321,31 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + oxfmt@0.52.0(vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)): + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.52.0 + '@oxfmt/binding-android-arm64': 0.52.0 + '@oxfmt/binding-darwin-arm64': 0.52.0 + '@oxfmt/binding-darwin-x64': 0.52.0 + '@oxfmt/binding-freebsd-x64': 0.52.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.52.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.52.0 + '@oxfmt/binding-linux-arm64-gnu': 0.52.0 + '@oxfmt/binding-linux-arm64-musl': 0.52.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-musl': 0.52.0 + '@oxfmt/binding-linux-s390x-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-musl': 0.52.0 + '@oxfmt/binding-openharmony-arm64': 0.52.0 + '@oxfmt/binding-win32-arm64-msvc': 0.52.0 + '@oxfmt/binding-win32-ia32-msvc': 0.52.0 + '@oxfmt/binding-win32-x64-msvc': 0.52.0 + vite-plus: 0.1.23(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + oxfmt@0.52.0(vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)): dependencies: tinypool: 2.1.0 @@ -18994,6 +19371,31 @@ snapshots: '@oxfmt/binding-win32-x64-msvc': 0.52.0 vite-plus: 0.1.23(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + oxfmt@0.52.0(vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)): + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.52.0 + '@oxfmt/binding-android-arm64': 0.52.0 + '@oxfmt/binding-darwin-arm64': 0.52.0 + '@oxfmt/binding-darwin-x64': 0.52.0 + '@oxfmt/binding-freebsd-x64': 0.52.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.52.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.52.0 + '@oxfmt/binding-linux-arm64-gnu': 0.52.0 + '@oxfmt/binding-linux-arm64-musl': 0.52.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-musl': 0.52.0 + '@oxfmt/binding-linux-s390x-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-musl': 0.52.0 + '@oxfmt/binding-openharmony-arm64': 0.52.0 + '@oxfmt/binding-win32-arm64-msvc': 0.52.0 + '@oxfmt/binding-win32-ia32-msvc': 0.52.0 + '@oxfmt/binding-win32-x64-msvc': 0.52.0 + vite-plus: 0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + oxfmt@0.52.0(vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.8.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)): dependencies: tinypool: 2.1.0 @@ -19044,6 +19446,31 @@ snapshots: '@oxfmt/binding-win32-x64-msvc': 0.52.0 vite-plus: 0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + oxfmt@0.52.0(vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)): + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.52.0 + '@oxfmt/binding-android-arm64': 0.52.0 + '@oxfmt/binding-darwin-arm64': 0.52.0 + '@oxfmt/binding-darwin-x64': 0.52.0 + '@oxfmt/binding-freebsd-x64': 0.52.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.52.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.52.0 + '@oxfmt/binding-linux-arm64-gnu': 0.52.0 + '@oxfmt/binding-linux-arm64-musl': 0.52.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-musl': 0.52.0 + '@oxfmt/binding-linux-s390x-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-musl': 0.52.0 + '@oxfmt/binding-openharmony-arm64': 0.52.0 + '@oxfmt/binding-win32-arm64-msvc': 0.52.0 + '@oxfmt/binding-win32-ia32-msvc': 0.52.0 + '@oxfmt/binding-win32-x64-msvc': 0.52.0 + vite-plus: 0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + oxlint-tsgolint@0.23.0: optionalDependencies: '@oxlint-tsgolint/darwin-arm64': 0.23.0 @@ -19053,6 +19480,30 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 0.23.0 '@oxlint-tsgolint/win32-x64': 0.23.0 + oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.67.0 + '@oxlint/binding-android-arm64': 1.67.0 + '@oxlint/binding-darwin-arm64': 1.67.0 + '@oxlint/binding-darwin-x64': 1.67.0 + '@oxlint/binding-freebsd-x64': 1.67.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.67.0 + '@oxlint/binding-linux-arm-musleabihf': 1.67.0 + '@oxlint/binding-linux-arm64-gnu': 1.67.0 + '@oxlint/binding-linux-arm64-musl': 1.67.0 + '@oxlint/binding-linux-ppc64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-musl': 1.67.0 + '@oxlint/binding-linux-s390x-gnu': 1.67.0 + '@oxlint/binding-linux-x64-gnu': 1.67.0 + '@oxlint/binding-linux-x64-musl': 1.67.0 + '@oxlint/binding-openharmony-arm64': 1.67.0 + '@oxlint/binding-win32-arm64-msvc': 1.67.0 + '@oxlint/binding-win32-ia32-msvc': 1.67.0 + '@oxlint/binding-win32-x64-msvc': 1.67.0 + oxlint-tsgolint: 0.23.0 + vite-plus: 0.1.23(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.67.0 @@ -19077,6 +19528,30 @@ snapshots: oxlint-tsgolint: 0.23.0 vite-plus: 0.1.23(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.67.0 + '@oxlint/binding-android-arm64': 1.67.0 + '@oxlint/binding-darwin-arm64': 1.67.0 + '@oxlint/binding-darwin-x64': 1.67.0 + '@oxlint/binding-freebsd-x64': 1.67.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.67.0 + '@oxlint/binding-linux-arm-musleabihf': 1.67.0 + '@oxlint/binding-linux-arm64-gnu': 1.67.0 + '@oxlint/binding-linux-arm64-musl': 1.67.0 + '@oxlint/binding-linux-ppc64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-musl': 1.67.0 + '@oxlint/binding-linux-s390x-gnu': 1.67.0 + '@oxlint/binding-linux-x64-gnu': 1.67.0 + '@oxlint/binding-linux-x64-musl': 1.67.0 + '@oxlint/binding-openharmony-arm64': 1.67.0 + '@oxlint/binding-win32-arm64-msvc': 1.67.0 + '@oxlint/binding-win32-ia32-msvc': 1.67.0 + '@oxlint/binding-win32-x64-msvc': 1.67.0 + oxlint-tsgolint: 0.23.0 + vite-plus: 0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.8.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.67.0 @@ -19125,6 +19600,30 @@ snapshots: oxlint-tsgolint: 0.23.0 vite-plus: 0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.67.0 + '@oxlint/binding-android-arm64': 1.67.0 + '@oxlint/binding-darwin-arm64': 1.67.0 + '@oxlint/binding-darwin-x64': 1.67.0 + '@oxlint/binding-freebsd-x64': 1.67.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.67.0 + '@oxlint/binding-linux-arm-musleabihf': 1.67.0 + '@oxlint/binding-linux-arm64-gnu': 1.67.0 + '@oxlint/binding-linux-arm64-musl': 1.67.0 + '@oxlint/binding-linux-ppc64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-musl': 1.67.0 + '@oxlint/binding-linux-s390x-gnu': 1.67.0 + '@oxlint/binding-linux-x64-gnu': 1.67.0 + '@oxlint/binding-linux-x64-musl': 1.67.0 + '@oxlint/binding-openharmony-arm64': 1.67.0 + '@oxlint/binding-win32-arm64-msvc': 1.67.0 + '@oxlint/binding-win32-ia32-msvc': 1.67.0 + '@oxlint/binding-win32-x64-msvc': 1.67.0 + oxlint-tsgolint: 0.23.0 + vite-plus: 0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -19254,6 +19753,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact-render-to-string@6.7.0(preact@10.29.2): + dependencies: + preact: 10.29.2 + + preact@10.29.2: {} + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.0: @@ -20882,6 +21387,56 @@ snapshots: - tsx - yaml + vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3): + dependencies: + '@oxc-project/types': 0.133.0 + '@oxlint/plugins': 1.61.0 + '@voidzero-dev/vite-plus-core': 0.1.23(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) + '@voidzero-dev/vite-plus-test': 0.1.23(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + oxfmt: 0.52.0(vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)) + oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)) + oxlint-tsgolint: 0.23.0 + optionalDependencies: + '@voidzero-dev/vite-plus-darwin-arm64': 0.1.23 + '@voidzero-dev/vite-plus-darwin-x64': 0.1.23 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.23 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.23 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.23 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.23 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.23 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.23 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@tsdown/css' + - '@tsdown/exe' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vite + - yaml + vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3): dependencies: '@oxc-project/types': 0.133.0 @@ -20932,6 +21487,56 @@ snapshots: - vite - yaml + vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3): + dependencies: + '@oxc-project/types': 0.133.0 + '@oxlint/plugins': 1.61.0 + '@voidzero-dev/vite-plus-core': 0.1.23(@types/node@25.3.3)(esbuild@0.25.12)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) + '@voidzero-dev/vite-plus-test': 0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + oxfmt: 0.52.0(vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)) + oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)) + oxlint-tsgolint: 0.23.0 + optionalDependencies: + '@voidzero-dev/vite-plus-darwin-arm64': 0.1.23 + '@voidzero-dev/vite-plus-darwin-x64': 0.1.23 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.23 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.23 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.23 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.23 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.23 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.23 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@tsdown/css' + - '@tsdown/exe' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vite + - yaml + vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.8.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3): dependencies: '@oxc-project/types': 0.133.0 @@ -21032,6 +21637,56 @@ snapshots: - vite - yaml + vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3): + dependencies: + '@oxc-project/types': 0.133.0 + '@oxlint/plugins': 1.61.0 + '@voidzero-dev/vite-plus-core': 0.1.23(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3) + '@voidzero-dev/vite-plus-test': 0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + oxfmt: 0.52.0(vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)) + oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.23(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)) + oxlint-tsgolint: 0.23.0 + optionalDependencies: + '@voidzero-dev/vite-plus-darwin-arm64': 0.1.23 + '@voidzero-dev/vite-plus-darwin-x64': 0.1.23 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.23 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.23 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.23 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.23 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.23 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.23 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@tsdown/css' + - '@tsdown/exe' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vite + - yaml + vite-tsconfig-paths@5.1.4(typescript@5.6.3)(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: debug: 4.4.3 @@ -21065,6 +21720,17 @@ snapshots: - supports-color - typescript + vite-tsconfig-paths@5.1.4(typescript@6.0.3)(vite@7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@6.0.3) + optionalDependencies: + vite: 7.3.5(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + - typescript + vite@7.3.5(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.28.1 From a34f76c15de46ad0b703159611f8c634610090ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Sat, 11 Jul 2026 21:31:06 +0200 Subject: [PATCH 055/129] feat(mimic): publish standalone applications --- apps/mimic-admin/LICENSE.md | 661 ++++ apps/mimic-admin/README.md | 3 + apps/mimic-admin/package.json | 65 + .../src/components/app-sidebar.tsx | 127 + .../src/components/auth-context.tsx | 34 + .../src/components/database-context.tsx | 47 + .../src/components/sdk-context.tsx | 47 + .../src/components/ui/alert-dialog.tsx | 111 + apps/mimic-admin/src/components/ui/badge.tsx | 36 + apps/mimic-admin/src/components/ui/button.tsx | 59 + apps/mimic-admin/src/components/ui/card.tsx | 66 + apps/mimic-admin/src/components/ui/dialog.tsx | 102 + .../src/components/ui/dropdown-menu.tsx | 197 ++ apps/mimic-admin/src/components/ui/input.tsx | 18 + apps/mimic-admin/src/components/ui/label.tsx | 24 + .../src/components/ui/scroll-area.tsx | 48 + apps/mimic-admin/src/components/ui/select.tsx | 158 + .../src/components/ui/separator.tsx | 26 + apps/mimic-admin/src/components/ui/sonner.tsx | 20 + apps/mimic-admin/src/components/ui/table.tsx | 119 + apps/mimic-admin/src/components/ui/tabs.tsx | 53 + .../src/components/ui/textarea.tsx | 17 + .../mimic-admin/src/components/ui/tooltip.tsx | 27 + apps/mimic-admin/src/env.d.ts | 1 + apps/mimic-admin/src/index.html | 12 + apps/mimic-admin/src/lib/auth.ts | 29 + apps/mimic-admin/src/lib/queries.ts | 239 ++ apps/mimic-admin/src/lib/utils.ts | 6 + apps/mimic-admin/src/main.tsx | 13 + apps/mimic-admin/src/routeTree.gen.ts | 285 ++ apps/mimic-admin/src/router.tsx | 29 + apps/mimic-admin/src/routes/__root.tsx | 22 + .../$collectionId/documents/$documentId.tsx | 294 ++ .../collections/$collectionId/index.tsx | 259 ++ .../collections/$collectionId/schema.tsx | 92 + .../src/routes/_app/_layout/databases.tsx | 183 ++ .../src/routes/_app/_layout/index.tsx | 7 + .../src/routes/_app/_layout/migrations.tsx | 905 ++++++ .../src/routes/_app/_layout/observability.tsx | 30 + .../src/routes/_app/_layout/route.tsx | 21 + .../src/routes/_app/_layout/users.tsx | 320 ++ apps/mimic-admin/src/routes/_app/route.tsx | 27 + apps/mimic-admin/src/routes/login.tsx | 96 + apps/mimic-admin/src/styles/globals.css | 165 + apps/mimic-admin/tsconfig.json | 26 + apps/mimic-admin/vite.config.ts | 13 + apps/mimic-cli/LICENSE.md | 661 ++++ apps/mimic-cli/README.md | 5 + apps/mimic-cli/build.ts | 78 + apps/mimic-cli/package.json | 62 + apps/mimic-cli/src/cli/commands/generate.ts | 58 + apps/mimic-cli/src/cli/commands/init.ts | 47 + apps/mimic-cli/src/cli/commands/migrate.ts | 391 +++ apps/mimic-cli/src/cli/index.ts | 48 + apps/mimic-cli/src/index.ts | 133 + apps/mimic-cli/src/services/ConfigLoader.ts | 65 + .../src/services/MigrationBundler.ts | 162 + .../mimic-cli/src/services/MigrationLoader.ts | 178 ++ apps/mimic-cli/src/utils/error-formatter.ts | 23 + apps/mimic-cli/src/utils/js-loading/_es5.ts | 1 + .../src/utils/js-loading/config-loader.ts | 71 + apps/mimic-cli/src/utils/package-root.ts | 6 + apps/mimic-cli/tests/config-loader.test.ts | 62 + apps/mimic-cli/tests/generate.test.ts | 47 + apps/mimic-cli/tests/migration-loader.test.ts | 128 + apps/mimic-cli/tsconfig.json | 24 + apps/mimic-cli/vitest.mts | 7 + examples/mimic-example/.env.example | 7 + examples/mimic-example/LICENSE.md | 21 + examples/mimic-example/README.md | 3 + examples/mimic-example/index.html | 12 + examples/mimic-example/package.json | 54 + examples/mimic-example/postcss.config.mjs | 5 + .../src/components/kanban/AddCardForm.tsx | 110 + .../src/components/kanban/AddColumnForm.tsx | 107 + .../src/components/kanban/Card.tsx | 70 + .../src/components/kanban/Column.tsx | 228 ++ .../src/components/kanban/EditCardModal.tsx | 186 ++ .../src/components/kanban/KanbanBoard.tsx | 326 ++ .../src/components/kanban/index.ts | 6 + .../src/context/KanbanContext.tsx | 104 + examples/mimic-example/src/env.d.ts | 3 + examples/mimic-example/src/lib/commands.ts | 333 +++ examples/mimic-example/src/lib/document.ts | 39 + .../mimic-example/src/lib/serverConfig.ts | 1 + examples/mimic-example/src/lib/store.ts | 28 + examples/mimic-example/src/main.tsx | 26 + examples/mimic-example/src/posts.tsx | 33 + examples/mimic-example/src/routeTree.gen.ts | 77 + examples/mimic-example/src/routes/__root.tsx | 38 + examples/mimic-example/src/routes/about.tsx | 14 + examples/mimic-example/src/routes/index.tsx | 46 + examples/mimic-example/src/server/app.ts | 138 + examples/mimic-example/src/server/index.ts | 18 + examples/mimic-example/src/shared/index.ts | 38 + examples/mimic-example/src/styles.css | 21 + examples/mimic-example/src/types/kanban.ts | 45 + examples/mimic-example/tsconfig.json | 17 + examples/mimic-example/tsconfig.server.json | 9 + examples/mimic-example/vite.config.js | 8 + pnpm-lock.yaml | 2655 ++++++++++++----- 101 files changed, 11428 insertions(+), 794 deletions(-) create mode 100644 apps/mimic-admin/LICENSE.md create mode 100644 apps/mimic-admin/README.md create mode 100644 apps/mimic-admin/package.json create mode 100644 apps/mimic-admin/src/components/app-sidebar.tsx create mode 100644 apps/mimic-admin/src/components/auth-context.tsx create mode 100644 apps/mimic-admin/src/components/database-context.tsx create mode 100644 apps/mimic-admin/src/components/sdk-context.tsx create mode 100644 apps/mimic-admin/src/components/ui/alert-dialog.tsx create mode 100644 apps/mimic-admin/src/components/ui/badge.tsx create mode 100644 apps/mimic-admin/src/components/ui/button.tsx create mode 100644 apps/mimic-admin/src/components/ui/card.tsx create mode 100644 apps/mimic-admin/src/components/ui/dialog.tsx create mode 100644 apps/mimic-admin/src/components/ui/dropdown-menu.tsx create mode 100644 apps/mimic-admin/src/components/ui/input.tsx create mode 100644 apps/mimic-admin/src/components/ui/label.tsx create mode 100644 apps/mimic-admin/src/components/ui/scroll-area.tsx create mode 100644 apps/mimic-admin/src/components/ui/select.tsx create mode 100644 apps/mimic-admin/src/components/ui/separator.tsx create mode 100644 apps/mimic-admin/src/components/ui/sonner.tsx create mode 100644 apps/mimic-admin/src/components/ui/table.tsx create mode 100644 apps/mimic-admin/src/components/ui/tabs.tsx create mode 100644 apps/mimic-admin/src/components/ui/textarea.tsx create mode 100644 apps/mimic-admin/src/components/ui/tooltip.tsx create mode 100644 apps/mimic-admin/src/env.d.ts create mode 100644 apps/mimic-admin/src/index.html create mode 100644 apps/mimic-admin/src/lib/auth.ts create mode 100644 apps/mimic-admin/src/lib/queries.ts create mode 100644 apps/mimic-admin/src/lib/utils.ts create mode 100644 apps/mimic-admin/src/main.tsx create mode 100644 apps/mimic-admin/src/routeTree.gen.ts create mode 100644 apps/mimic-admin/src/router.tsx create mode 100644 apps/mimic-admin/src/routes/__root.tsx create mode 100644 apps/mimic-admin/src/routes/_app/_layout/collections/$collectionId/documents/$documentId.tsx create mode 100644 apps/mimic-admin/src/routes/_app/_layout/collections/$collectionId/index.tsx create mode 100644 apps/mimic-admin/src/routes/_app/_layout/collections/$collectionId/schema.tsx create mode 100644 apps/mimic-admin/src/routes/_app/_layout/databases.tsx create mode 100644 apps/mimic-admin/src/routes/_app/_layout/index.tsx create mode 100644 apps/mimic-admin/src/routes/_app/_layout/migrations.tsx create mode 100644 apps/mimic-admin/src/routes/_app/_layout/observability.tsx create mode 100644 apps/mimic-admin/src/routes/_app/_layout/route.tsx create mode 100644 apps/mimic-admin/src/routes/_app/_layout/users.tsx create mode 100644 apps/mimic-admin/src/routes/_app/route.tsx create mode 100644 apps/mimic-admin/src/routes/login.tsx create mode 100644 apps/mimic-admin/src/styles/globals.css create mode 100644 apps/mimic-admin/tsconfig.json create mode 100644 apps/mimic-admin/vite.config.ts create mode 100644 apps/mimic-cli/LICENSE.md create mode 100644 apps/mimic-cli/README.md create mode 100644 apps/mimic-cli/build.ts create mode 100644 apps/mimic-cli/package.json create mode 100644 apps/mimic-cli/src/cli/commands/generate.ts create mode 100644 apps/mimic-cli/src/cli/commands/init.ts create mode 100644 apps/mimic-cli/src/cli/commands/migrate.ts create mode 100644 apps/mimic-cli/src/cli/index.ts create mode 100644 apps/mimic-cli/src/index.ts create mode 100644 apps/mimic-cli/src/services/ConfigLoader.ts create mode 100644 apps/mimic-cli/src/services/MigrationBundler.ts create mode 100644 apps/mimic-cli/src/services/MigrationLoader.ts create mode 100644 apps/mimic-cli/src/utils/error-formatter.ts create mode 100644 apps/mimic-cli/src/utils/js-loading/_es5.ts create mode 100644 apps/mimic-cli/src/utils/js-loading/config-loader.ts create mode 100644 apps/mimic-cli/src/utils/package-root.ts create mode 100644 apps/mimic-cli/tests/config-loader.test.ts create mode 100644 apps/mimic-cli/tests/generate.test.ts create mode 100644 apps/mimic-cli/tests/migration-loader.test.ts create mode 100644 apps/mimic-cli/tsconfig.json create mode 100644 apps/mimic-cli/vitest.mts create mode 100644 examples/mimic-example/.env.example create mode 100644 examples/mimic-example/LICENSE.md create mode 100644 examples/mimic-example/README.md create mode 100644 examples/mimic-example/index.html create mode 100644 examples/mimic-example/package.json create mode 100644 examples/mimic-example/postcss.config.mjs create mode 100644 examples/mimic-example/src/components/kanban/AddCardForm.tsx create mode 100644 examples/mimic-example/src/components/kanban/AddColumnForm.tsx create mode 100644 examples/mimic-example/src/components/kanban/Card.tsx create mode 100644 examples/mimic-example/src/components/kanban/Column.tsx create mode 100644 examples/mimic-example/src/components/kanban/EditCardModal.tsx create mode 100644 examples/mimic-example/src/components/kanban/KanbanBoard.tsx create mode 100644 examples/mimic-example/src/components/kanban/index.ts create mode 100644 examples/mimic-example/src/context/KanbanContext.tsx create mode 100644 examples/mimic-example/src/env.d.ts create mode 100644 examples/mimic-example/src/lib/commands.ts create mode 100644 examples/mimic-example/src/lib/document.ts create mode 100644 examples/mimic-example/src/lib/serverConfig.ts create mode 100644 examples/mimic-example/src/lib/store.ts create mode 100644 examples/mimic-example/src/main.tsx create mode 100644 examples/mimic-example/src/posts.tsx create mode 100644 examples/mimic-example/src/routeTree.gen.ts create mode 100644 examples/mimic-example/src/routes/__root.tsx create mode 100644 examples/mimic-example/src/routes/about.tsx create mode 100644 examples/mimic-example/src/routes/index.tsx create mode 100644 examples/mimic-example/src/server/app.ts create mode 100644 examples/mimic-example/src/server/index.ts create mode 100644 examples/mimic-example/src/shared/index.ts create mode 100644 examples/mimic-example/src/styles.css create mode 100644 examples/mimic-example/src/types/kanban.ts create mode 100644 examples/mimic-example/tsconfig.json create mode 100644 examples/mimic-example/tsconfig.server.json create mode 100644 examples/mimic-example/vite.config.js diff --git a/apps/mimic-admin/LICENSE.md b/apps/mimic-admin/LICENSE.md new file mode 100644 index 000000000..be3f7b28e --- /dev/null +++ b/apps/mimic-admin/LICENSE.md @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/apps/mimic-admin/README.md b/apps/mimic-admin/README.md new file mode 100644 index 000000000..047fa76a2 --- /dev/null +++ b/apps/mimic-admin/README.md @@ -0,0 +1,3 @@ +# @voidhash/mimic-admin + +Browser-based operator console for authenticating to a Mimic host and inspecting its databases, collections, schemas, and documents. diff --git a/apps/mimic-admin/package.json b/apps/mimic-admin/package.json new file mode 100644 index 000000000..db3d20691 --- /dev/null +++ b/apps/mimic-admin/package.json @@ -0,0 +1,65 @@ +{ + "name": "@voidhash/mimic-admin", + "version": "1.0.0-beta.19", + "private": true, + "description": "Operator console for inspecting and administering Mimic databases.", + "keywords": [ + "admin", + "database", + "mimic", + "voidhash" + ], + "homepage": "https://voidhash.com/docs", + "bugs": { + "url": "https://github.com/voidhashcom/voidhash/issues" + }, + "license": "AGPL-3.0-only", + "author": "Voidhash (https://voidhash.com)", + "repository": { + "type": "git", + "url": "https://github.com/voidhashcom/voidhash", + "directory": "apps/mimic-admin" + }, + "type": "module", + "scripts": { + "dev": "vp dev --config vite.config.ts", + "build": "vp build --config vite.config.ts", + "preview": "vp preview --config vite.config.ts", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-scroll-area": "^1.2.8", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-tooltip": "^1.2.7", + "@tanstack/react-query": "^5.80.7", + "@tanstack/react-query-devtools": "^5.80.7", + "@tanstack/react-router": "1.163.3", + "@voidhash/mimic-core": "workspace:*", + "@voidhash/mimic-server": "workspace:*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.513.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "sonner": "^2.0.3", + "tailwind-merge": "^3.3.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.8", + "@types/react": "19.1.17", + "@types/react-dom": "19.1.9", + "@vitejs/plugin-react": "^4.5.2", + "tailwindcss": "^4.1.8", + "tw-animate-css": "^1.3.4", + "typescript": "6.0.3", + "vite": "catalog:", + "vite-plus": "0.1.23" + } +} diff --git a/apps/mimic-admin/src/components/app-sidebar.tsx b/apps/mimic-admin/src/components/app-sidebar.tsx new file mode 100644 index 000000000..8e3e1d318 --- /dev/null +++ b/apps/mimic-admin/src/components/app-sidebar.tsx @@ -0,0 +1,127 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useMatchRoute } from "@tanstack/react-router"; +import { Database, FileText, Activity, Workflow, Users, LogOut } from "lucide-react"; + +import { useAuth } from "@/components/auth-context"; +import { useDatabase } from "@/components/database-context"; +import { useMimicSdk } from "@/components/sdk-context"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { collectionsQuery, databasesQuery } from "@/lib/queries"; + +export function AppSidebar() { + const { credentials, logout } = useAuth(); + const sdk = useMimicSdk(); + const { selectedDatabaseId, setSelectedDatabaseId } = useDatabase(); + const matchRoute = useMatchRoute(); + + const { data: databases } = useQuery(databasesQuery(sdk)); + const { data: collections } = useQuery(collectionsQuery(sdk, selectedDatabaseId ?? "")); + + const navItems = [ + { to: "/databases" as const, label: "Databases", icon: Database }, + { to: "/migrations" as const, label: "Migrations", icon: Workflow }, + { to: "/users" as const, label: "Users", icon: Users }, + { to: "/observability" as const, label: "Observability", icon: Activity }, + ]; + + return ( +
+
+

Mimic Admin

+ +
+ + + +
+ +
+ + + + {selectedDatabaseId && collections && collections.length > 0 && ( + <> + +
+ Collections +
+ +
+ {collections.map((col) => { + const isActive = !!matchRoute({ + to: "/collections/$collectionId", + params: { collectionId: col.id }, + }); + return ( + + + {col.name} + + ); + })} +
+
+ + )} + +
+
+
{credentials.serverUrl}
+
{credentials.username}
+
+
+
+ ); +} diff --git a/apps/mimic-admin/src/components/auth-context.tsx b/apps/mimic-admin/src/components/auth-context.tsx new file mode 100644 index 000000000..c2d837b23 --- /dev/null +++ b/apps/mimic-admin/src/components/auth-context.tsx @@ -0,0 +1,34 @@ +import { createContext, useCallback, useContext } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { type Credentials, clearCredentials, getCredentials } from "@/lib/auth"; + +interface AuthContextValue { + credentials: Credentials; + logout: () => void; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ + children, + credentials, +}: { + children: React.ReactNode; + credentials: Credentials; +}) { + const navigate = useNavigate(); + const logout = useCallback(() => { + clearCredentials(); + navigate({ to: "/login" }); + }, [navigate]); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return ctx; +} diff --git a/apps/mimic-admin/src/components/database-context.tsx b/apps/mimic-admin/src/components/database-context.tsx new file mode 100644 index 000000000..7d5ac4672 --- /dev/null +++ b/apps/mimic-admin/src/components/database-context.tsx @@ -0,0 +1,47 @@ +import { createContext, useCallback, useContext, useState } from "react"; + +interface DatabaseContextValue { + selectedDatabaseId: string | null; + setSelectedDatabaseId: (id: string | null) => void; +} + +const DatabaseContext = createContext(null); + +const STORAGE_KEY = "mimic-admin-selected-database"; + +export function DatabaseProvider({ children }: { children: React.ReactNode }) { + const [selectedDatabaseId, setSelectedDatabaseIdState] = useState(() => { + try { + return localStorage.getItem(STORAGE_KEY); + } catch { + return null; + } + }); + + const setSelectedDatabaseId = useCallback((id: string | null) => { + setSelectedDatabaseIdState(id); + try { + if (id) { + localStorage.setItem(STORAGE_KEY, id); + } else { + localStorage.removeItem(STORAGE_KEY); + } + } catch { + // ignore storage errors + } + }, []); + + return ( + + {children} + + ); +} + +export function useDatabase(): DatabaseContextValue { + const ctx = useContext(DatabaseContext); + if (!ctx) { + throw new Error("useDatabase must be used within a DatabaseProvider"); + } + return ctx; +} diff --git a/apps/mimic-admin/src/components/sdk-context.tsx b/apps/mimic-admin/src/components/sdk-context.tsx new file mode 100644 index 000000000..26bba88de --- /dev/null +++ b/apps/mimic-admin/src/components/sdk-context.tsx @@ -0,0 +1,47 @@ +import { createContext, useContext, useMemo } from "react"; +import { MimicSDK } from "@voidhash/mimic-server"; + +import type { Credentials } from "@/lib/auth"; + +const SdkContext = createContext(null); + +/** + * Builds a single `MimicSDK` instance per (serverUrl, username, password) + * tuple. The instance lives for the lifetime of the page — we deliberately + * do NOT call `sdk.dispose()` from a `useEffect` cleanup. React StrictMode + * runs cleanup-then-setup once on mount in dev to surface lifecycle bugs; + * disposing the runtime there closes the scope while `useMemo` still holds + * the same SDK reference, which would abort any in-flight RPC requests + * (visible as HTTP 499 / "All fibers interrupted" on the server). + * + * The runtime backs an HTTP `RpcClient.Protocol` (fetch-based, no persistent + * connections), so there's nothing urgent to clean up — the browser tears + * down on navigation/unload anyway. + */ +export function MimicSdkProvider({ + credentials, + children, +}: { + credentials: Credentials; + children: React.ReactNode; +}) { + const sdk = useMemo( + () => + new MimicSDK({ + url: credentials.serverUrl, + username: credentials.username, + password: credentials.password, + }), + [credentials.serverUrl, credentials.username, credentials.password], + ); + + return {children}; +} + +export function useMimicSdk(): MimicSDK { + const sdk = useContext(SdkContext); + if (!sdk) { + throw new Error("useMimicSdk must be used within a MimicSdkProvider"); + } + return sdk; +} diff --git a/apps/mimic-admin/src/components/ui/alert-dialog.tsx b/apps/mimic-admin/src/components/ui/alert-dialog.tsx new file mode 100644 index 000000000..ec8159f22 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/alert-dialog.tsx @@ -0,0 +1,111 @@ +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import type * as React from "react"; + +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const AlertDialog = AlertDialogPrimitive.Root; +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ); +} + +function AlertDialogHeader({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +function AlertDialogFooter({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/apps/mimic-admin/src/components/ui/badge.tsx b/apps/mimic-admin/src/components/ui/badge.tsx new file mode 100644 index 000000000..4fd9bde2e --- /dev/null +++ b/apps/mimic-admin/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import { type VariantProps, cva } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow", + secondary: + "border-transparent bg-secondary text-secondary-foreground", + destructive: + "border-transparent bg-destructive text-white shadow", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/apps/mimic-admin/src/components/ui/button.tsx b/apps/mimic-admin/src/components/ui/button.tsx new file mode 100644 index 000000000..fe109aff0 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/button.tsx @@ -0,0 +1,59 @@ +import { Slot } from "@radix-ui/react-slot"; +import { type VariantProps, cva } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: ButtonProps) { + const Comp = asChild ? Slot : "button"; + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/apps/mimic-admin/src/components/ui/card.tsx b/apps/mimic-admin/src/components/ui/card.tsx new file mode 100644 index 000000000..15c7b8945 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/card.tsx @@ -0,0 +1,66 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +function CardHeader({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +function CardTitle({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +function CardDescription({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +function CardContent({ + className, + ...props +}: React.HTMLAttributes) { + return
; +} + +function CardFooter({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/apps/mimic-admin/src/components/ui/dialog.tsx b/apps/mimic-admin/src/components/ui/dialog.tsx new file mode 100644 index 000000000..56c4e8295 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/dialog.tsx @@ -0,0 +1,102 @@ +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + {children} + + + Close + + + + ); +} + +function DialogHeader({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/apps/mimic-admin/src/components/ui/dropdown-menu.tsx b/apps/mimic-admin/src/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..4e7d0e8ef --- /dev/null +++ b/apps/mimic-admin/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,197 @@ +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuItem({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.HTMLAttributes) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/apps/mimic-admin/src/components/ui/input.tsx b/apps/mimic-admin/src/components/ui/input.tsx new file mode 100644 index 000000000..aae2067cd --- /dev/null +++ b/apps/mimic-admin/src/components/ui/input.tsx @@ -0,0 +1,18 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/apps/mimic-admin/src/components/ui/label.tsx b/apps/mimic-admin/src/components/ui/label.tsx new file mode 100644 index 000000000..896ce10c7 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as LabelPrimitive from "@radix-ui/react-label"; +import { type VariantProps, cva } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", +); + +function Label({ + className, + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ); +} + +export { Label }; diff --git a/apps/mimic-admin/src/components/ui/scroll-area.tsx b/apps/mimic-admin/src/components/ui/scroll-area.tsx new file mode 100644 index 000000000..5ab5448c5 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ); +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { ScrollArea, ScrollBar }; diff --git a/apps/mimic-admin/src/components/ui/select.tsx b/apps/mimic-admin/src/components/ui/select.tsx new file mode 100644 index 000000000..14b72c172 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/select.tsx @@ -0,0 +1,158 @@ +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Select = SelectPrimitive.Root; +const SelectGroup = SelectPrimitive.Group; +const SelectValue = SelectPrimitive.Value; + +function SelectTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + span]:line-clamp-1", + className, + )} + {...props} + > + {children} + + + + + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectContent({ + className, + children, + position = "popper", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/apps/mimic-admin/src/components/ui/separator.tsx b/apps/mimic-admin/src/components/ui/separator.tsx new file mode 100644 index 000000000..2bb74e0ae --- /dev/null +++ b/apps/mimic-admin/src/components/ui/separator.tsx @@ -0,0 +1,26 @@ +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/apps/mimic-admin/src/components/ui/sonner.tsx b/apps/mimic-admin/src/components/ui/sonner.tsx new file mode 100644 index 000000000..0cd8b368b --- /dev/null +++ b/apps/mimic-admin/src/components/ui/sonner.tsx @@ -0,0 +1,20 @@ +import { Toaster as Sonner } from "sonner"; + +function Toaster() { + return ( + + ); +} + +export { Toaster }; diff --git a/apps/mimic-admin/src/components/ui/table.tsx b/apps/mimic-admin/src/components/ui/table.tsx new file mode 100644 index 000000000..52f0da435 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/table.tsx @@ -0,0 +1,119 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Table({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ + + ); +} + +function TableHeader({ + className, + ...props +}: React.HTMLAttributes) { + return ; +} + +function TableBody({ + className, + ...props +}: React.HTMLAttributes) { + return ( + + ); +} + +function TableFooter({ + className, + ...props +}: React.HTMLAttributes) { + return ( + tr]:last:border-b-0", + className, + )} + {...props} + /> + ); +} + +function TableRow({ + className, + ...props +}: React.HTMLAttributes) { + return ( + + ); +} + +function TableHead({ + className, + ...props +}: React.ThHTMLAttributes) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className, + )} + {...props} + /> + ); +} + +function TableCell({ + className, + ...props +}: React.TdHTMLAttributes) { + return ( + [role=checkbox]]:translate-y-[2px]", + className, + )} + {...props} + /> + ); +} + +function TableCaption({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/apps/mimic-admin/src/components/ui/tabs.tsx b/apps/mimic-admin/src/components/ui/tabs.tsx new file mode 100644 index 000000000..7ecdbbdc7 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Tabs = TabsPrimitive.Root; + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/mimic-admin/src/components/ui/textarea.tsx b/apps/mimic-admin/src/components/ui/textarea.tsx new file mode 100644 index 000000000..94459a387 --- /dev/null +++ b/apps/mimic-admin/src/components/ui/textarea.tsx @@ -0,0 +1,17 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +