From fe2da4228a041e60a3cf03b76c3349cd0d7a541c Mon Sep 17 00:00:00 2001 From: Mike Interlandi <43190101+Minterl@users.noreply.github.com> Date: Sat, 18 Apr 2026 15:15:02 -0400 Subject: [PATCH 1/9] replace statefile with state --- src/core/backend/src/backend.ts | 70 +++++++++++++++++++++------------ src/core/backend/src/index.ts | 4 +- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/src/core/backend/src/backend.ts b/src/core/backend/src/backend.ts index 7312fac..219100f 100644 --- a/src/core/backend/src/backend.ts +++ b/src/core/backend/src/backend.ts @@ -1,22 +1,20 @@ -import { ProviderSet, Statefile } from '@hookplane/core' -import { ResultAsync } from 'neverthrow' - -export type StatefileData = Statefile['data'] +import type { ProviderSet, State } from '@hookplane/core' +import type { ResultAsync } from 'neverthrow' export interface Backend { /** Name of the backend */ readonly name: string - statefile: { - /** Reads the raw statefile data from storage */ - read(): ResultAsync + state: { + /** Reads state data */ + read

(providers: P): ResultAsync, BackendError> - /** Writes a validated statefile to storage */ + /** Writes a state to storage */ write

( - data: Statefile

, + data: State

, ): ResultAsync - /** Deletes the statefile from storage */ + /** Deletes the state from storage */ delete(): ResultAsync } @@ -32,34 +30,35 @@ export interface Backend { } } -export type StatefileOperation = 'read' | 'write' | 'delete' +export type BackendOperation = 'read' | 'write' | 'delete' export type BackendError = | NotFoundError | PermissionDeniedError | WriteRejectedError | ServerError + | InternalError | UnknownError export interface NotFoundError { kind: 'BackendError' name: 'NotFoundError' message: string - while: StatefileOperation + while: BackendOperation } export interface PermissionDeniedError { kind: 'BackendError' name: 'PermissionDeniedError' message: string - while: StatefileOperation + while: BackendOperation } export interface WriteRejectedError { kind: 'BackendError' name: 'WriteRejectedError' message: string - while: StatefileOperation + while: BackendOperation } export interface ServerError { @@ -67,29 +66,38 @@ export interface ServerError { name: 'ServerError' message: string statusCode?: number - while: StatefileOperation + while: BackendOperation +} + +export interface InternalError { + kind: 'BackendError' + name: 'InternalError' + message: string + while: BackendOperation + cause?: unknown } export interface UnknownError { kind: 'BackendError' name: 'UnknownError' message: string - while: StatefileOperation + while: BackendOperation cause?: unknown } export interface BackendDescriptor { readonly name: string init?: (config: TConfig) => Promise - statefile: { - read(params: { + state: { + read

(params: { config: TConfig state: TState - }): ResultAsync + providers: P + }): ResultAsync, BackendError> write

(params: { config: TConfig state: TState - data: Statefile

+ data: State

}): ResultAsync delete(params: { config: TConfig @@ -118,18 +126,28 @@ export interface BackendDescriptor { export function describeBackend( desc: BackendDescriptor, -): (config: TConfig) => Promise { - return async (config: TConfig) => { +): (config: TConfig) => () => Promise { + return (config: TConfig) => async () => { let state = {} as TState if (desc.init) { state = await desc.init(config) } return { name: desc.name, - statefile: { - read: () => desc.statefile.read({ config, state }), - write: (data) => desc.statefile.write({ config, state, data }), - delete: () => desc.statefile.delete({ config, state }), + state: { + read:

(providers: P) => + desc.state.read

({ + config, + state, + providers: providers, + }), + write:

(data: State

) => + desc.state.write

({ + config, + state, + data, + }), + delete: () => desc.state.delete({ config, state }), }, signingSecret: { read: (id) => desc.signingSecret.read({ config, state, id }), diff --git a/src/core/backend/src/index.ts b/src/core/backend/src/index.ts index 6497030..09f1576 100644 --- a/src/core/backend/src/index.ts +++ b/src/core/backend/src/index.ts @@ -1,11 +1,10 @@ export { createLocalBackend, type LocalBackendConfig } from './local.js' export { createTempBackend } from './temp.js' export type { - StatefileData, Backend, BackendDescriptor, BackendError, - StatefileOperation, + BackendOperation, } from './backend.js' export { describeBackend } from './backend.js' export type { @@ -13,5 +12,6 @@ export type { PermissionDeniedError, WriteRejectedError, ServerError, + InternalError, UnknownError, } from './backend.js' From 179a96aa273179ff8ca5072de28d4b4f0a53f28f Mon Sep 17 00:00:00 2001 From: Mike Interlandi <43190101+Minterl@users.noreply.github.com> Date: Sat, 18 Apr 2026 15:40:12 -0400 Subject: [PATCH 2/9] refactor local --- src/core/backend/src/local.test.ts | 251 +++++++++++++++++++++-------- src/core/backend/src/local.ts | 84 ++++++---- src/core/backend/src/temp.ts | 11 +- 3 files changed, 247 insertions(+), 99 deletions(-) diff --git a/src/core/backend/src/local.test.ts b/src/core/backend/src/local.test.ts index bccbdfe..09bcbdb 100644 --- a/src/core/backend/src/local.test.ts +++ b/src/core/backend/src/local.test.ts @@ -1,24 +1,99 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { createLocalBackend } from '~/local.js' -import { StatefileData } from '~/backend.js' +import { Backend, ProviderNotFoundError } from '~/backend.js' import { - Statefile, ProviderSet, + Provider, + EventDefinition, + EndpointHandle, createEndpointHandle, createEndpointUrl, + State, } from '@hookplane/core' import * as fs from 'node:fs/promises' import * as path from 'node:path' import * as os from 'node:os' +function createFakeProvider(name: string, events: string[]): Provider { + const eventsRecord = {} as Record> + for (const eventName of events) { + eventsRecord[eventName] = { + __phantom: undefined, + } as EventDefinition + } + return { + name, + config: {}, + state: null, + events: eventsRecord, + setup: () => { + throw new Error('not implemented') + }, + createEndpoint: () => { + throw new Error('not implemented') + }, + readEndpoint: () => { + throw new Error('not implemented') + }, + updateEndpoint: () => { + throw new Error('not implemented') + }, + deleteEndpoint: () => { + throw new Error('not implemented') + }, + indexEndpoints: () => { + throw new Error('not implemented') + }, + } +} + +function createFakeStatefileData( + providerName: string, + handle: string, + url: string, + events: string[], +) { + return { + version: 1, + providerStates: { + [providerName]: { + [handle]: { + state: { + url, + events, + config: {}, + }, + }, + }, + }, + } +} + describe('createLocalBackend', () => { let tmpDir: string + let statefilePath: string let signingSecretPath: string + const fakeProviderName = 'stripe' + const fakeEvents = ['payment.succeeded', 'payment.failed'] + const fakeHandle = createEndpointHandle('endpoint-1')._unsafeUnwrap() + const fakeUrl = createEndpointUrl( + 'https://example.com/webhook', + )._unsafeUnwrap() + + const fakeProvider = createFakeProvider(fakeProviderName, fakeEvents) + const fakeProviders: ProviderSet = { [fakeProviderName]: fakeProvider } + + const mockStatefileData = createFakeStatefileData( + fakeProviderName, + fakeHandle, + fakeUrl, + fakeEvents, + ) + beforeAll(async () => { - tmpDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'statefile-driver-test-'), - ) + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hookplane-test-')) + statefilePath = path.join(tmpDir, 'statefile.json') signingSecretPath = path.join(tmpDir, 'secrets.json') }) @@ -26,112 +101,147 @@ describe('createLocalBackend', () => { await fs.rm(tmpDir, { recursive: true, force: true }) }) - const createDriver = ( - statefilePath?: string, - ): ReturnType => - createLocalBackend({ - statefilePath: statefilePath || path.join(tmpDir, 'state.json'), + const createDriver = async (): Promise => { + const makeBackend = createLocalBackend({ + statefilePath, signingSecretPath, }) - - const mockData: StatefileData = { - version: 1, - providerStates: { - stripe: { - [createEndpointHandle('endpoint-1')._unsafeUnwrap()]: { - state: { - url: createEndpointUrl( - 'https://example.com/webhook', - )._unsafeUnwrap(), - events: ['payment.succeeded'], - config: {}, - }, - }, - }, - }, - } - - const mockStatefile: Statefile = { - data: mockData, - toState: () => { - throw new Error('Not implemented in test') - }, + return makeBackend() } - describe('statefile.read', () => { + describe('state.read', () => { it('returns ok with parsed data when file exists', async () => { - const filePath = path.join(tmpDir, 'state.json') - await fs.writeFile(filePath, JSON.stringify(mockData), 'utf-8') + await fs.writeFile( + statefilePath, + JSON.stringify(mockStatefileData), + 'utf-8', + ) - const driver = await createDriver(filePath) - const result = await driver.statefile.read() + const driver = await createDriver() + const result = await driver.state.read(fakeProviders) expect(result.isOk()).toBe(true) - expect(result._unsafeUnwrap()).toEqual(mockData) + const state = result._unsafeUnwrap() + expect(state.providerStates[fakeProviderName]).toBeDefined() + const providerState = state.providerStates[fakeProviderName]! + expect(providerState.has(fakeHandle)).toBe(true) }) it('returns NotFoundError when file does not exist', async () => { - const filePath = path.join(tmpDir, 'nonexistent.json') - const driver = await createDriver(filePath) - const result = await driver.statefile.read() + const driver = await createLocalBackend({ + statefilePath: path.join(tmpDir, 'doesnotexist.json'), + signingSecretPath, + })() + const result = await driver.state.read(fakeProviders) expect(result.isErr()).toBe(true) expect(result._unsafeUnwrapErr().name).toBe('NotFoundError') expect(result._unsafeUnwrapErr().while).toBe('read') }) - it('returns UnknownError when file contains invalid json', async () => { - const filePath = path.join(tmpDir, 'invalid.json') - await fs.writeFile(filePath, 'not valid json', 'utf-8') + it('returns InternalError when file contains invalid json', async () => { + await fs.writeFile(statefilePath, 'not valid json', 'utf-8') - const driver = await createDriver(filePath) - const result = await driver.statefile.read() + const driver = await createDriver() + const result = await driver.state.read(fakeProviders) expect(result.isErr()).toBe(true) - expect(result._unsafeUnwrapErr().name).toBe('UnknownError') + expect(result._unsafeUnwrapErr().name).toBe('InternalError') expect(result._unsafeUnwrapErr().while).toBe('read') }) + + it('returns InternalError when statefile references unknown provider', async () => { + const badData = createFakeStatefileData( + 'unknown-provider', + fakeHandle, + fakeUrl, + fakeEvents, + ) + await fs.writeFile(statefilePath, JSON.stringify(badData), 'utf-8') + + const driver = await createDriver() + const result = await driver.state.read(fakeProviders) + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr().name).toBe('InternalError') + }) }) - describe('statefile.write', () => { + describe('state.write', () => { it('creates file with json data', async () => { - const filePath = path.join(tmpDir, 'write-test.json') - const driver = await createDriver(filePath) - const result = await driver.statefile.write(mockStatefile) + const endpointState = { + url: fakeUrl, + events: fakeEvents, + config: {}, + } + const providerState = new Map( + [[fakeHandle, endpointState]], + ) + const state: State = { + providers: fakeProviders, + providerStates: { + [fakeProviderName]: providerState, + }, + } + + const driver = await createDriver() + const result = await driver.state.write(state) expect(result.isOk()).toBe(true) - const contents = await fs.readFile(filePath, 'utf-8') + const contents = await fs.readFile(statefilePath, 'utf-8') const parsed = JSON.parse(contents) - expect(parsed).toEqual(mockData) + expect(parsed.version).toBe(1) + expect(parsed.providerStates[fakeProviderName]).toBeDefined() + expect( + parsed.providerStates[fakeProviderName][fakeHandle], + ).toBeDefined() }) - it('overwrites existing file', async () => { - const filePath = path.join(tmpDir, 'overwrite.json') - await fs.writeFile(filePath, '{"old": "data"}', 'utf-8') + it('roundtrips data through write and read', async () => { + const endpointState = { + url: fakeUrl, + events: fakeEvents, + config: {}, + } + const providerState = new Map( + [[fakeHandle, endpointState]], + ) + const state: State = { + providers: fakeProviders, + providerStates: { + [fakeProviderName]: providerState, + }, + } - const driver = await createDriver(filePath) - const result = await driver.statefile.write(mockStatefile) + const driver = await createDriver() + await driver.state.write(state) + const result = await driver.state.read(fakeProviders) expect(result.isOk()).toBe(true) - const contents = await fs.readFile(filePath, 'utf-8') - const parsed = JSON.parse(contents) - expect(parsed).toEqual(mockData) + const loadedState = result._unsafeUnwrap() + expect(loadedState.providerStates[fakeProviderName]).toBeDefined() + const loadedProviderState = + loadedState.providerStates[fakeProviderName]! + expect(loadedProviderState.has(fakeHandle)).toBe(true) + + const loadedEndpoint = loadedProviderState.get(fakeHandle)! + expect(loadedEndpoint.url).toBe(fakeUrl) + expect(loadedEndpoint.events).toEqual(fakeEvents) }) }) - describe('statefile.delete', () => { + describe('state.delete', () => { it('removes existing file', async () => { - const filePath = path.join(tmpDir, 'delete-me.json') - await fs.writeFile(filePath, '{}', 'utf-8') + await fs.writeFile(statefilePath, '{}', 'utf-8') - const driver = await createDriver(filePath) - const result = await driver.statefile.delete() + const driver = await createDriver() + const result = await driver.state.delete() expect(result.isOk()).toBe(true) - const exists = await fs.access(filePath).then( + const exists = await fs.access(statefilePath).then( () => true, () => false, ) @@ -139,9 +249,8 @@ describe('createLocalBackend', () => { }) it('returns ok when file does not exist (idempotent delete)', async () => { - const filePath = path.join(tmpDir, 'never-existed.json') - const driver = await createDriver(filePath) - const result = await driver.statefile.delete() + const driver = await createDriver() + const result = await driver.state.delete() expect(result.isOk()).toBe(true) }) diff --git a/src/core/backend/src/local.ts b/src/core/backend/src/local.ts index 9e7ac8f..794dd7e 100644 --- a/src/core/backend/src/local.ts +++ b/src/core/backend/src/local.ts @@ -1,14 +1,18 @@ import { describeBackend, - type StatefileOperation, type BackendError, - type StatefileData, NotFoundError, PermissionDeniedError, WriteRejectedError, + InternalError, UnknownError, + BackendOperation, } from './backend.js' -import { Statefile, ProviderSet } from '@hookplane/core' +import { + parseStatefile, + fromState, + StatefileError, +} from '@hookplane/core' import { ResultAsync } from 'neverthrow' import { readFile, writeFile, unlink } from 'node:fs/promises' import { z } from 'zod' @@ -24,7 +28,7 @@ const SigningSecretFileSchema = z.object({ function toBackendError( e: unknown, - operation: StatefileOperation, + operation: BackendOperation, path: string, ): BackendError { const error = e as NodeJS.ErrnoException & { kind?: string } @@ -55,6 +59,15 @@ function toBackendError( while: operation, } satisfies WriteRejectedError } + if (e instanceof SyntaxError) { + return { + kind: 'BackendError', + name: 'InternalError', + message: `invalid JSON: ${e.message}`, + while: operation, + cause: e, + } satisfies InternalError + } return { kind: 'BackendError', name: 'UnknownError', @@ -64,23 +77,14 @@ function toBackendError( } satisfies UnknownError } -async function readStatefile( - config: LocalBackendConfig, -): Promise { - const contents = await readFile(config.statefilePath, 'utf-8') - // TODO fix this here - return JSON.parse(contents) -} - -async function writeStatefile

( - config: LocalBackendConfig, - data: Statefile

, -): Promise { - await writeFile( - config.statefilePath, - JSON.stringify(data.data, null, 2), - 'utf-8', - ) +function statefileErrorToBackendError(error: StatefileError): InternalError { + return { + kind: 'BackendError', + name: 'InternalError', + message: error.message, + while: 'read', + cause: error, + } satisfies InternalError } async function deleteStatefile(config: LocalBackendConfig): Promise { @@ -168,14 +172,40 @@ async function deleteSigningSecret( export const createLocalBackend = describeBackend({ name: 'local-file', - statefile: { - read: ({ config }) => - ResultAsync.fromPromise(readStatefile(config), (e) => - toBackendError(e, 'read', config.statefilePath), + state: { + read: ({ config, providers }) => + ResultAsync.fromPromise( + (async () => { + const contents = await readFile( + config.statefilePath, + 'utf-8', + ) + const parsed = JSON.parse(contents) + const statefileResult = parseStatefile(parsed, providers) + if (statefileResult.isErr()) { + throw statefileErrorToBackendError( + statefileResult.error, + ) + } + const stateResult = statefileResult.value.toState() + if (stateResult.isErr()) { + throw statefileErrorToBackendError(stateResult.error) + } + return stateResult.value + })(), + (e) => toBackendError(e, 'read', config.statefilePath), ), write: ({ config, data }) => - ResultAsync.fromPromise(writeStatefile(config, data), (e) => - toBackendError(e, 'write', config.statefilePath), + ResultAsync.fromPromise( + (async () => { + const statefile = fromState(1, data) + await writeFile( + config.statefilePath, + JSON.stringify(statefile.data, null, 2), + 'utf-8', + ) + })(), + (e) => toBackendError(e, 'write', config.statefilePath), ), delete: ({ config }) => ResultAsync.fromPromise(deleteStatefile(config), (e) => diff --git a/src/core/backend/src/temp.ts b/src/core/backend/src/temp.ts index 4e72beb..29be866 100644 --- a/src/core/backend/src/temp.ts +++ b/src/core/backend/src/temp.ts @@ -3,6 +3,7 @@ import { rmSync } from 'fs' import path from 'path' import os from 'os' import { createLocalBackend } from './local.js' +import { ProviderSet, State } from '@hookplane/core' export async function createTempBackend() { const tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), 'hookplane-test-')) @@ -25,7 +26,15 @@ export async function createTempBackend() { const backend = await createLocalBackend({ statefilePath, signingSecretPath, - }) + })() + + const state = { + providers: {}, + providerStates: {}, + } satisfies State + // This is really not for production use-cases, + // so it's fine to have this throw. + await backend.state.write(state).then((r) => r._unsafeUnwrap()) return backend } From dd00cfe812a1196d8e3c736b8af93eeb807a2db9 Mon Sep 17 00:00:00 2001 From: Mike Interlandi <43190101+Minterl@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:22:21 -0400 Subject: [PATCH 3/9] add providernotfounderror --- src/core/backend/src/backend.ts | 13 ++++++++++++- src/core/backend/src/index.ts | 1 + src/core/backend/src/local.test.ts | 7 +++++-- src/core/backend/src/local.ts | 13 ++++++++++++- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/core/backend/src/backend.ts b/src/core/backend/src/backend.ts index 219100f..05074ae 100644 --- a/src/core/backend/src/backend.ts +++ b/src/core/backend/src/backend.ts @@ -7,7 +7,9 @@ export interface Backend { state: { /** Reads state data */ - read

(providers: P): ResultAsync, BackendError> + read

( + providers: P, + ): ResultAsync, BackendError> /** Writes a state to storage */ write

( @@ -38,6 +40,7 @@ export type BackendError = | WriteRejectedError | ServerError | InternalError + | ProviderNotFoundError | UnknownError export interface NotFoundError { @@ -77,6 +80,14 @@ export interface InternalError { cause?: unknown } +export interface ProviderNotFoundError { + kind: 'BackendError' + name: 'ProviderNotFoundError' + message: string + while: BackendOperation + provider: string +} + export interface UnknownError { kind: 'BackendError' name: 'UnknownError' diff --git a/src/core/backend/src/index.ts b/src/core/backend/src/index.ts index 09f1576..0215f03 100644 --- a/src/core/backend/src/index.ts +++ b/src/core/backend/src/index.ts @@ -13,5 +13,6 @@ export type { WriteRejectedError, ServerError, InternalError, + ProviderNotFoundError, UnknownError, } from './backend.js' diff --git a/src/core/backend/src/local.test.ts b/src/core/backend/src/local.test.ts index 09bcbdb..2ba264f 100644 --- a/src/core/backend/src/local.test.ts +++ b/src/core/backend/src/local.test.ts @@ -150,7 +150,7 @@ describe('createLocalBackend', () => { expect(result._unsafeUnwrapErr().while).toBe('read') }) - it('returns InternalError when statefile references unknown provider', async () => { + it('returns ProviderNotFoundError when statefile references unknown provider', async () => { const badData = createFakeStatefileData( 'unknown-provider', fakeHandle, @@ -163,7 +163,10 @@ describe('createLocalBackend', () => { const result = await driver.state.read(fakeProviders) expect(result.isErr()).toBe(true) - expect(result._unsafeUnwrapErr().name).toBe('InternalError') + expect(result._unsafeUnwrapErr().name).toBe('ProviderNotFoundError') + expect( + (result._unsafeUnwrapErr() as ProviderNotFoundError).provider, + ).toBe('unknown-provider') }) }) diff --git a/src/core/backend/src/local.ts b/src/core/backend/src/local.ts index 794dd7e..ec54049 100644 --- a/src/core/backend/src/local.ts +++ b/src/core/backend/src/local.ts @@ -5,6 +5,7 @@ import { PermissionDeniedError, WriteRejectedError, InternalError, + ProviderNotFoundError, UnknownError, BackendOperation, } from './backend.js' @@ -12,6 +13,7 @@ import { parseStatefile, fromState, StatefileError, + type ProviderNotFoundError as StatefileProviderNotFoundError, } from '@hookplane/core' import { ResultAsync } from 'neverthrow' import { readFile, writeFile, unlink } from 'node:fs/promises' @@ -77,7 +79,16 @@ function toBackendError( } satisfies UnknownError } -function statefileErrorToBackendError(error: StatefileError): InternalError { +function statefileErrorToBackendError(error: StatefileError): BackendError { + if (error.name === 'ProviderNotFoundError') { + return { + kind: 'BackendError', + name: 'ProviderNotFoundError', + message: error.message, + while: 'read', + provider: (error as StatefileProviderNotFoundError).provider, + } satisfies ProviderNotFoundError + } return { kind: 'BackendError', name: 'InternalError', From 08ff0a2a2adbaa15eb56ad29292b3d125ad3ccfa Mon Sep 17 00:00:00 2001 From: Mike Interlandi <43190101+Minterl@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:09:26 -0400 Subject: [PATCH 4/9] refactor cli --- src/core/hookplane/src/cli/logger.ts | 8 +++++++- src/core/hookplane/src/cli/utils/backend.ts | 18 +++++++----------- src/core/hookplane/src/cli/utils/states.ts | 7 ------- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/core/hookplane/src/cli/logger.ts b/src/core/hookplane/src/cli/logger.ts index 65c6f8a..33460e1 100644 --- a/src/core/hookplane/src/cli/logger.ts +++ b/src/core/hookplane/src/cli/logger.ts @@ -49,8 +49,14 @@ const warnPrefix = styleText('yellow', 'warn') const errorPrefix = styleText('red', 'error') export const logger = { + dir: (name: string, obj: object) => { + if (shouldLog('debug')) { + console.log(debugPrefix, `${name}: `) + console.dir(obj) + } + }, debug: (...msg: unknown[]) => { - if (shouldLog('debug')) console.debug(debugPrefix, ...msg) + if (shouldLog('debug')) console.log(debugPrefix, ...msg) }, info: (...msg: unknown[]) => { if (shouldLog('info')) console.log(infoPrefix, ...msg) diff --git a/src/core/hookplane/src/cli/utils/backend.ts b/src/core/hookplane/src/cli/utils/backend.ts index 5044965..e17fcc4 100644 --- a/src/core/hookplane/src/cli/utils/backend.ts +++ b/src/core/hookplane/src/cli/utils/backend.ts @@ -1,33 +1,29 @@ import { err, ok, Result } from 'neverthrow' import { Backend } from '@hookplane/backend' -import { parseStatefile, ProviderSet, State, sync } from '@hookplane/core' +import { ProviderSet, State, sync } from '@hookplane/core' +import { logger } from '../logger.js' export async function getPrior

( backend: Backend, providers: P, ): Promise, Error>> { - const data = await backend.statefile.read() - if (data.isErr()) { - return err(new Error(data.error.message)) - } - const statefile = parseStatefile(data.value, providers) - if (statefile.isErr()) { - return err(new Error(statefile.error.message)) - } - const prior = statefile.value.toState() + logger.debug('getting prior state') + const prior = await backend.state.read(providers) if (prior.isErr()) { return err(new Error(prior.error.message)) } - + logger.dir('prior state', prior.value) return ok(prior.value) } export async function getActual

( providers: P, ): Promise, Error>> { + logger.debug('getting actual state') const actual = await sync(providers) if (actual.isErr()) { return err(new Error(actual.error.message)) } + logger.dir('actual state', actual.value) return ok(actual.value) } diff --git a/src/core/hookplane/src/cli/utils/states.ts b/src/core/hookplane/src/cli/utils/states.ts index 6d7cb9b..2022c41 100644 --- a/src/core/hookplane/src/cli/utils/states.ts +++ b/src/core/hookplane/src/cli/utils/states.ts @@ -1,6 +1,5 @@ import { err, ok, Result } from 'neverthrow' import { - bootstrap, endpointUrlHeuristic, match, ProviderSet, @@ -20,12 +19,6 @@ export async function states( hookplane: Hookplane, ): Promise> { const backend = hookplane.backend - const bootstrapContent = bootstrap() - const writeResult = await backend.statefile.write(bootstrapContent) - if (writeResult.isErr()) { - return err(new Error(writeResult.error.message)) - } - const desiredUnknown = hookplane.state const priorResult = await getPrior(backend, desiredUnknown.providers) From 5239c73a764ee7a565a84351609cb31685144ed5 Mon Sep 17 00:00:00 2001 From: Mike Interlandi <43190101+Minterl@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:08:05 -0400 Subject: [PATCH 5/9] fix a huge bug in planning --- src/core/core/src/plan.ts | 23 +++++- src/core/core/test/plan.test.ts | 90 ++++++++++++++++++++++++ src/core/hookplane/src/cli/utils/plan.ts | 4 +- 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/src/core/core/src/plan.ts b/src/core/core/src/plan.ts index 58a5e6b..9a11e4b 100644 --- a/src/core/core/src/plan.ts +++ b/src/core/core/src/plan.ts @@ -101,16 +101,34 @@ type UpdateStep

= { state: EndpointState

} +/** + * Options to be passed to `createPlan`. + */ +export type CreatePlanOptions = { + /** + * If true, the plan will contain create steps for non-orphan endpoint handles. + * You want this for "sync" plans (drift reconciliation), but not "target" plans. + * + * If those terms don't make sense, it's probably because the way the CLI/client structure has changed. + * In that event, update these docs. + * + * @default false + */ + createNonOrphanHandles?: boolean +} + /** * Creates a `Plan` for updating a state from one state to another. * * @param left The left state. * @param right The right state. + * @param opts Optional @see CreatePlanOptions * @returns A `Plan` for updating the left state to the right state. */ function createPlan, R extends State>( left: L, right: R, + opts?: CreatePlanOptions, ): Result, PlanError> { const comparison = createComparison(left, right) const providers = comparison.providers // Merged providers @@ -129,7 +147,7 @@ function createPlan, R extends State>( } satisfies PlanError) } providerPlans[providerKey] = new Map() - const steps = normalizeAndDiff(provider, providerComparison) + const steps = normalizeAndDiff(provider, providerComparison, opts?.createNonOrphanHandles || false) if (steps.isErr()) { return err(steps.error) } @@ -297,6 +315,7 @@ function mergeProviders( function normalizeAndDiff

( provider: Provider, { left, right }: ProviderComparison

, + createNonOrphanHandles: boolean, ): Result>, PlanError> { const steps: Set> = new Set() @@ -342,7 +361,7 @@ function normalizeAndDiff

( } for (const [rightHandle, rightState] of right) { - if (!endpointHandleIsOrphan(rightHandle)) { + if (!endpointHandleIsOrphan(rightHandle) && !createNonOrphanHandles) { continue } // TODO assert that left shouldn't have this one diff --git a/src/core/core/test/plan.test.ts b/src/core/core/test/plan.test.ts index c45d58e..cb93945 100644 --- a/src/core/core/test/plan.test.ts +++ b/src/core/core/test/plan.test.ts @@ -178,6 +178,96 @@ describe('plan', () => { }) }) + it('generates create for non-orphan handles when createNonOrphanHandles is true', () => { + const left: State = { + providers: { + testProvider: TestProvider({ + storeUrl: 'storeurl', + storeKey: 'storekey', + }), + }, + providerStates: { + testProvider: new Map(), + }, + } + const right: State = { + providers: { + testProvider: TestProvider({ + storeUrl: 'storeurl', + storeKey: 'storekey', + }), + }, + providerStates: { + testProvider: new Map([ + [ + createEndpointHandle('handle-0')._unsafeUnwrap(), + { + url: createEndpointUrl( + 'https://localhost/', + )._unsafeUnwrap(), + events: ['testEvent'], + config: {}, + }, + ], + ]), + }, + } + + const result = createPlan(left, right, { createNonOrphanHandles: true }) + expect(result.isOk()).toBe(true) + const plan = result._unsafeUnwrap() + expect(plan.providerPlans.testProvider!).toHaveLength(1) + expect(plan.providerPlans.testProvider!.get(createStepId(0))).toEqual({ + kind: 'create', + state: { + url: createEndpointUrl('https://localhost/')._unsafeUnwrap(), + events: ['testEvent'], + config: {}, + }, + }) + }) + + it('does not generate create for non-orphan handles by default', () => { + const left: State = { + providers: { + testProvider: TestProvider({ + storeUrl: 'storeurl', + storeKey: 'storekey', + }), + }, + providerStates: { + testProvider: new Map(), + }, + } + const right: State = { + providers: { + testProvider: TestProvider({ + storeUrl: 'storeurl', + storeKey: 'storekey', + }), + }, + providerStates: { + testProvider: new Map([ + [ + createEndpointHandle('handle-0')._unsafeUnwrap(), + { + url: createEndpointUrl( + 'https://localhost/', + )._unsafeUnwrap(), + events: ['testEvent'], + config: {}, + }, + ], + ]), + }, + } + + const result = createPlan(left, right) + expect(result.isOk()).toBe(true) + const plan = result._unsafeUnwrap() + expect(plan.providerPlans.testProvider!).toHaveLength(0) + }) + it('generates update when changing endpoint url', () => { const left: State = { providers: { diff --git a/src/core/hookplane/src/cli/utils/plan.ts b/src/core/hookplane/src/cli/utils/plan.ts index 84d159b..f213972 100644 --- a/src/core/hookplane/src/cli/utils/plan.ts +++ b/src/core/hookplane/src/cli/utils/plan.ts @@ -11,7 +11,9 @@ export async function plan( actual: State, desired: State, ): Promise> { - const syncPlanResult = createPlan(prior, actual) + const syncPlanResult = createPlan(prior, actual, { + createNonOrphanHandles: true, + }) if (syncPlanResult.isErr()) { return err(new Error(syncPlanResult.error.message)) } From ad40ac03552673f19ec9c526ae29225eaec36396 Mon Sep 17 00:00:00 2001 From: Mike Interlandi <43190101+Minterl@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:36:27 -0400 Subject: [PATCH 6/9] fit ser/des into remote --- src/core/remote/src/backend.ts | 63 +++++++++++++++++++++++++++++----- src/core/remote/src/trpc.ts | 5 +-- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/core/remote/src/backend.ts b/src/core/remote/src/backend.ts index bcd8196..8663e96 100644 --- a/src/core/remote/src/backend.ts +++ b/src/core/remote/src/backend.ts @@ -1,15 +1,23 @@ import { type BackendError, + InternalError, type NotFoundError, type PermissionDeniedError, type ServerError, type UnknownError, type WriteRejectedError, + type ProviderNotFoundError, describeBackend, } from '@hookplane/backend' import { createTRPCClient, httpBatchLink, TRPCClient } from '@trpc/client' import { createAuthorizedHeaders, RemoteBackendRouter } from './trpc.js' import { ResultAsync } from 'neverthrow' +import { + fromState, + parseStatefile, + type StatefileError, + ProviderNotFoundError as ProviderNotFoundStatefileError, +} from '@hookplane/core' const DEFAULT_HOOKPLANE_URL = 'https://api.hookplane.com/v1/state' @@ -38,34 +46,51 @@ export const createRemoteBackend = describeBackend< }) return { client } }, - statefile: { - read: ({ state: { client } }) => - ResultAsync.fromPromise(client.statefile.read.query(), (e) => - toBackendError(e, 'read'), - ), - write: ({ state: { client }, data: statefile }) => + state: { + read: ({ state: { client }, providers }) => + ResultAsync.fromPromise( + (async () => { + const data = await client.statefile.read.query() + return parseStatefile(data, providers) + .map(s => s.toState()) + .andThen(r => r) + .mapErr(statefileErrorToBackendError) + })(), + (e) => toBackendError(e, 'read'), + ) + .andThen(r => r), + + write: ({ state: { client }, data }) => ResultAsync.fromPromise( // This could be done shorthand like ....mutate(statefile), // but this is more explicit. - client.statefile.write.mutate({ data: statefile['data'] }), + (async () => { + const statefile = fromState(1, data) + await client.statefile.write.mutate({ data: statefile.data }) + })(), (e) => toBackendError(e, 'write'), ), + delete: ({ state: { client } }) => - ResultAsync.fromPromise(client.statefile.delete.mutate(), (e) => - toBackendError(e, 'delete'), + ResultAsync.fromPromise( + client.statefile.delete.mutate(), + (e) => toBackendError(e, 'delete'), ), }, + signingSecret: { read: ({ state: { client }, id }) => ResultAsync.fromPromise( client.signingSecret.read.query({ id }), (e) => toBackendError(e, 'read'), ), + write: ({ state: { client }, id, data }) => ResultAsync.fromPromise( client.signingSecret.write.mutate({ id, data }), (e) => toBackendError(e, 'write'), ), + delete: ({ state: { client }, id }) => ResultAsync.fromPromise( client.signingSecret.delete.mutate({ id }), @@ -143,3 +168,23 @@ function toBackendError( while: operation, } satisfies UnknownError } + +// TODO: this should be very strictly tested +function statefileErrorToBackendError(error: StatefileError): BackendError { + if (error.name === 'ProviderNotFoundError') { + return { + kind: 'BackendError', + name: 'ProviderNotFoundError', + message: error.message, + while: 'read', + provider: (error as ProviderNotFoundStatefileError).provider, + } satisfies ProviderNotFoundError + } + return { + kind: 'BackendError', + name: 'InternalError', + message: error.message, + while: 'read', + cause: error, + } satisfies InternalError +} diff --git a/src/core/remote/src/trpc.ts b/src/core/remote/src/trpc.ts index 8c91325..0fe01ff 100644 --- a/src/core/remote/src/trpc.ts +++ b/src/core/remote/src/trpc.ts @@ -1,9 +1,10 @@ import { initTRPC, TRPCError } from '@trpc/server' -import { StatefileSchema } from '@hookplane/core' +import { ProviderSet, Statefile, StatefileSchema } from '@hookplane/core' import z from 'zod' -import { StatefileData } from '@hookplane/backend' import type { CreateHTTPContextOptions } from '@trpc/server/adapters/standalone' +type StatefileData = Statefile['data'] + export type RouterDescriptor = { createContext: ( opts: CreateHTTPContextOptions, From bae9ec655aa9dc0944ade59b0db348b76e780f7f Mon Sep 17 00:00:00 2001 From: Mike Interlandi <43190101+Minterl@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:10:18 -0400 Subject: [PATCH 7/9] format --- src/core/core/src/plan.ts | 6 +++++- src/core/hookplane/src/cli/logger.ts | 2 +- src/core/hookplane/src/cli/utils/plan.ts | 2 +- src/core/remote/src/backend.ts | 22 +++++++++++----------- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/core/core/src/plan.ts b/src/core/core/src/plan.ts index 9a11e4b..576ea73 100644 --- a/src/core/core/src/plan.ts +++ b/src/core/core/src/plan.ts @@ -147,7 +147,11 @@ function createPlan, R extends State>( } satisfies PlanError) } providerPlans[providerKey] = new Map() - const steps = normalizeAndDiff(provider, providerComparison, opts?.createNonOrphanHandles || false) + const steps = normalizeAndDiff( + provider, + providerComparison, + opts?.createNonOrphanHandles || false, + ) if (steps.isErr()) { return err(steps.error) } diff --git a/src/core/hookplane/src/cli/logger.ts b/src/core/hookplane/src/cli/logger.ts index 33460e1..d901bbe 100644 --- a/src/core/hookplane/src/cli/logger.ts +++ b/src/core/hookplane/src/cli/logger.ts @@ -51,7 +51,7 @@ const errorPrefix = styleText('red', 'error') export const logger = { dir: (name: string, obj: object) => { if (shouldLog('debug')) { - console.log(debugPrefix, `${name}: `) + console.log(debugPrefix, `${name}: `) console.dir(obj) } }, diff --git a/src/core/hookplane/src/cli/utils/plan.ts b/src/core/hookplane/src/cli/utils/plan.ts index f213972..1280795 100644 --- a/src/core/hookplane/src/cli/utils/plan.ts +++ b/src/core/hookplane/src/cli/utils/plan.ts @@ -12,7 +12,7 @@ export async function plan( desired: State, ): Promise> { const syncPlanResult = createPlan(prior, actual, { - createNonOrphanHandles: true, + createNonOrphanHandles: true, }) if (syncPlanResult.isErr()) { return err(new Error(syncPlanResult.error.message)) diff --git a/src/core/remote/src/backend.ts b/src/core/remote/src/backend.ts index 8663e96..c4d3fbc 100644 --- a/src/core/remote/src/backend.ts +++ b/src/core/remote/src/backend.ts @@ -52,13 +52,12 @@ export const createRemoteBackend = describeBackend< (async () => { const data = await client.statefile.read.query() return parseStatefile(data, providers) - .map(s => s.toState()) - .andThen(r => r) + .map((s) => s.toState()) + .andThen((r) => r) .mapErr(statefileErrorToBackendError) - })(), + })(), (e) => toBackendError(e, 'read'), - ) - .andThen(r => r), + ).andThen((r) => r), write: ({ state: { client }, data }) => ResultAsync.fromPromise( @@ -66,15 +65,16 @@ export const createRemoteBackend = describeBackend< // but this is more explicit. (async () => { const statefile = fromState(1, data) - await client.statefile.write.mutate({ data: statefile.data }) - })(), + await client.statefile.write.mutate({ + data: statefile.data, + }) + })(), (e) => toBackendError(e, 'write'), ), delete: ({ state: { client } }) => - ResultAsync.fromPromise( - client.statefile.delete.mutate(), - (e) => toBackendError(e, 'delete'), + ResultAsync.fromPromise(client.statefile.delete.mutate(), (e) => + toBackendError(e, 'delete'), ), }, @@ -84,7 +84,7 @@ export const createRemoteBackend = describeBackend< client.signingSecret.read.query({ id }), (e) => toBackendError(e, 'read'), ), - + write: ({ state: { client }, id, data }) => ResultAsync.fromPromise( client.signingSecret.write.mutate({ id, data }), From 89c93d4f243c47ba4a70168e2b51779b91ae3292 Mon Sep 17 00:00:00 2001 From: Mike Interlandi <43190101+Minterl@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:53:09 -0400 Subject: [PATCH 8/9] add docstring for createTempBackend --- src/core/backend/src/temp.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/core/backend/src/temp.ts b/src/core/backend/src/temp.ts index 29be866..4593e61 100644 --- a/src/core/backend/src/temp.ts +++ b/src/core/backend/src/temp.ts @@ -5,6 +5,13 @@ import os from 'os' import { createLocalBackend } from './local.js' import { ProviderSet, State } from '@hookplane/core' +/** + * Creates a `Backend` backed by a tempfile. + * This is a simple wrapper over `createLocalBackend` that's not + * desiged for any production use-cases. + * + * @see createLocalBackend + */ export async function createTempBackend() { const tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), 'hookplane-test-')) const statefilePath = path.join(tmpdir, 'statefile.json') From 17cba89267d13d2d9ecb2c0a59efb9236a815f65 Mon Sep 17 00:00:00 2001 From: Mike Interlandi <43190101+Minterl@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:02:49 -0400 Subject: [PATCH 9/9] changeset --- src/core/backend/CHANGELOG.md | 11 +++++++++++ src/core/backend/package.json | 2 +- src/core/core/CHANGELOG.md | 6 ++++++ src/core/core/package.json | 2 +- src/core/hookplane/CHANGELOG.md | 8 ++++++++ src/core/hookplane/package.json | 2 +- src/core/provider/CHANGELOG.md | 7 +++++++ src/core/provider/package.json | 2 +- src/core/remote/CHANGELOG.md | 13 +++++++++++++ src/core/remote/package.json | 2 +- src/providers/stripe/CHANGELOG.md | 6 ++++++ src/providers/stripe/package.json | 2 +- 12 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 src/core/remote/CHANGELOG.md diff --git a/src/core/backend/CHANGELOG.md b/src/core/backend/CHANGELOG.md index 3d49cc0..dcea153 100644 --- a/src/core/backend/CHANGELOG.md +++ b/src/core/backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @hookplane/backend +## 0.2.0 + +### Minor Changes + +- Refactor backend interface to use plain states + +### Patch Changes + +- Updated dependencies + - @hookplane/core@0.1.1 + ## 0.1.0 ### Minor Changes diff --git a/src/core/backend/package.json b/src/core/backend/package.json index 0f9316e..0724995 100644 --- a/src/core/backend/package.json +++ b/src/core/backend/package.json @@ -1,7 +1,7 @@ { "name": "@hookplane/backend", "type": "module", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "description": "Uniform backend interface for hookplane", "repository": "github:interlandi-io/hookplane", diff --git a/src/core/core/CHANGELOG.md b/src/core/core/CHANGELOG.md index b82795c..aa0ca82 100644 --- a/src/core/core/CHANGELOG.md +++ b/src/core/core/CHANGELOG.md @@ -1,5 +1,11 @@ # @hookplane/core +## 0.1.1 + +### Patch Changes + +- Refactor backend interface to use plain states + ## 0.1.0 ### Minor Changes diff --git a/src/core/core/package.json b/src/core/core/package.json index f5cd2cc..6a043f1 100644 --- a/src/core/core/package.json +++ b/src/core/core/package.json @@ -1,6 +1,6 @@ { "name": "@hookplane/core", - "version": "0.1.0", + "version": "0.1.1", "license": "Apache-2.0", "description": "Core runtime components for hookplane", "repository": "github:interlandi-io/hookplane", diff --git a/src/core/hookplane/CHANGELOG.md b/src/core/hookplane/CHANGELOG.md index cc986b8..547e7b3 100644 --- a/src/core/hookplane/CHANGELOG.md +++ b/src/core/hookplane/CHANGELOG.md @@ -1,5 +1,13 @@ # hookplane +## 0.1.1 + +### Patch Changes + +- Updated dependencies + - @hookplane/backend@0.2.0 + - @hookplane/core@0.1.1 + ## 0.1.0 ### Minor Changes diff --git a/src/core/hookplane/package.json b/src/core/hookplane/package.json index 5a3aa14..b9c71a9 100644 --- a/src/core/hookplane/package.json +++ b/src/core/hookplane/package.json @@ -1,7 +1,7 @@ { "name": "hookplane", "type": "module", - "version": "0.1.0", + "version": "0.1.1", "license": "Apache-2.0", "description": "Hookplane SDK and CLI", "repository": "github:interlandi-io/hookplane", diff --git a/src/core/provider/CHANGELOG.md b/src/core/provider/CHANGELOG.md index 5b04cbf..cfaa095 100644 --- a/src/core/provider/CHANGELOG.md +++ b/src/core/provider/CHANGELOG.md @@ -1,5 +1,12 @@ # @hookplane/provider +## 1.0.1 + +### Patch Changes + +- Updated dependencies + - @hookplane/core@0.1.1 + ## 1.0.0 ### Minor Changes diff --git a/src/core/provider/package.json b/src/core/provider/package.json index 4d5d238..45d0f14 100644 --- a/src/core/provider/package.json +++ b/src/core/provider/package.json @@ -1,6 +1,6 @@ { "name": "@hookplane/provider", - "version": "1.0.0", + "version": "1.0.1", "license": "Apache-2.0", "description": "Toolkit for building hookplane providers", "repository": "github:interlandi-io/hookplane", diff --git a/src/core/remote/CHANGELOG.md b/src/core/remote/CHANGELOG.md new file mode 100644 index 0000000..31b4645 --- /dev/null +++ b/src/core/remote/CHANGELOG.md @@ -0,0 +1,13 @@ +# @hookplane/remote + +## 0.2.0 + +### Minor Changes + +- Refactor backend interface to use plain states + +### Patch Changes + +- Updated dependencies + - @hookplane/backend@0.2.0 + - @hookplane/core@0.1.1 diff --git a/src/core/remote/package.json b/src/core/remote/package.json index 34f9016..a7cfc0e 100644 --- a/src/core/remote/package.json +++ b/src/core/remote/package.json @@ -1,7 +1,7 @@ { "name": "@hookplane/remote", "type": "module", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "description": "Remote backend contract for Hookplane", "repository": "github:interlandi-io/hookplane", diff --git a/src/providers/stripe/CHANGELOG.md b/src/providers/stripe/CHANGELOG.md index 2517d06..58fa368 100644 --- a/src/providers/stripe/CHANGELOG.md +++ b/src/providers/stripe/CHANGELOG.md @@ -1,5 +1,11 @@ # @hookplane/stripe +## 1.0.1 + +### Patch Changes + +- @hookplane/provider@1.0.1 + ## 1.0.0 ### Minor Changes diff --git a/src/providers/stripe/package.json b/src/providers/stripe/package.json index f53e674..2cc2d3c 100644 --- a/src/providers/stripe/package.json +++ b/src/providers/stripe/package.json @@ -1,6 +1,6 @@ { "name": "@hookplane/stripe", - "version": "1.0.0", + "version": "1.0.1", "license": "Apache-2.0", "description": "Stripe provider for hookplane", "repository": "github:interlandi-io/hookplane",