From d0ddb1e2f0a83fe6f325e3c6182118d7c476a12b Mon Sep 17 00:00:00 2001 From: Mohamed Melouk <42706279+cobraprojects@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:58:23 +0300 Subject: [PATCH 1/2] Refactor core packages and simplify shared logic --- bun.lock | 3 +- packages/adapter-next/src/runtime.ts | 26 +- packages/adapter-next/tests/client.test.ts | 407 ++----- .../src/realtime-definition-transform.ts | 111 +- .../src/runtime/composables/client-errors.ts | 6 +- .../src/runtime/composables/forms.ts | 16 +- .../src/runtime/composables/object.ts | 7 + .../src/runtime/composables/realtime.ts | 14 +- .../src/runtime/plugins/realtime.client.ts | 5 +- .../src/runtime/server/auto-imports/holo.ts | 17 +- .../src/runtime/server/imports/storage.ts | 2 +- packages/adapter-sveltekit/src/client.ts | 62 +- packages/adapter-sveltekit/src/preprocess.ts | 60 +- .../adapter-sveltekit/src/reactive-view.ts | 65 ++ .../src/realtime-definition-transform.ts | 58 +- packages/adapter-sveltekit/src/realtime.ts | 75 +- .../adapter-sveltekit/src/transform-utils.ts | 51 + packages/auth-clerk/src/index.ts | 127 +-- packages/auth-clerk/src/jwt.ts | 81 +- packages/auth-clerk/src/request.ts | 6 + packages/auth-social-apple/package.json | 1 + packages/auth-social-apple/src/index.ts | 91 +- packages/auth-social-discord/src/index.ts | 40 +- packages/auth-social-facebook/package.json | 3 +- packages/auth-social-facebook/src/index.ts | 42 +- packages/auth-social-github/src/index.ts | 41 +- packages/auth-social-google/src/index.ts | 55 +- packages/auth-social-linkedin/src/index.ts | 42 +- .../tests/package.test.ts | 82 +- packages/auth-social/src/index.ts | 221 ++-- packages/auth-workos/src/contracts.ts | 12 +- packages/auth-workos/src/index.ts | 234 +--- packages/auth/src/next/server.ts | 42 +- packages/auth/src/nuxt/server.ts | 45 +- packages/auth/src/runtime.ts | 6 +- packages/auth/src/runtime/jwt.ts | 117 ++ packages/auth/src/runtime/optionalSecurity.ts | 35 +- .../auth/src/runtime/requestNormalization.ts | 162 +++ packages/auth/src/runtime/setCookieParser.ts | 15 +- packages/auth/src/sveltekit/client.ts | 10 +- packages/auth/src/sveltekit/server.ts | 41 +- packages/auth/tests/framework.test.ts | 69 +- packages/auth/tests/package.test.ts | 127 +-- packages/authorization/src/runtime.ts | 70 +- packages/broadcast/src/auth.ts | 44 +- packages/broadcast/src/contracts.ts | 59 +- packages/broadcast/src/json.ts | 67 ++ packages/broadcast/src/runtime.ts | 52 +- packages/broadcast/src/worker.ts | 50 +- packages/broadcast/tests/worker.test.ts | 1000 +---------------- packages/cache-db/src/index.ts | 112 +- packages/cache-db/tests/package.test.ts | 8 - packages/cache-redis/src/index.ts | 45 +- packages/cache/src/db.ts | 182 +-- packages/cache/src/facade.ts | 109 +- packages/cache/src/flexible.ts | 133 +++ packages/cache/src/optional-driver.ts | 221 ++++ packages/cache/src/query-bridge.ts | 99 +- packages/cache/src/redis.ts | 212 +--- packages/cli/package.json | 1 + packages/cli/src/cache-migrations.ts | 72 +- packages/cli/src/cli-internals.ts | 220 ---- packages/cli/src/cli.ts | 51 +- packages/cli/src/dev.ts | 187 ++- packages/cli/src/generators.ts | 32 - packages/cli/src/media-migrations.ts | 26 +- packages/cli/src/project/discovery-helpers.ts | 52 +- .../cli/src/project/scaffold/dependencies.ts | 75 +- .../project/scaffold/framework-renderers.ts | 5 +- packages/cli/src/queue-migrations.ts | 5 +- packages/cli/src/runtime-worker.ts | 375 +++++++ packages/cli/src/runtime.ts | 281 +---- packages/cli/src/security.ts | 6 +- packages/cli/tests/cli.test.ts | 966 ++++++++-------- packages/cli/tsconfig.json | 1 + packages/cli/tsup.config.ts | 1 + packages/cli/vitest.config.ts | 1 + packages/config/src/access.ts | 20 +- packages/config/src/defaults.ts | 174 ++- packages/core/tests/package.test.ts | 12 +- packages/create-holo-js/tsconfig.json | 3 + packages/db-postgres/src/index.ts | 18 +- 82 files changed, 2817 insertions(+), 4962 deletions(-) create mode 100644 packages/adapter-nuxt/src/runtime/composables/object.ts create mode 100644 packages/adapter-sveltekit/src/reactive-view.ts create mode 100644 packages/adapter-sveltekit/src/transform-utils.ts create mode 100644 packages/auth-clerk/src/request.ts create mode 100644 packages/auth/src/runtime/jwt.ts create mode 100644 packages/auth/src/runtime/requestNormalization.ts create mode 100644 packages/broadcast/src/json.ts create mode 100644 packages/cache/src/flexible.ts create mode 100644 packages/cache/src/optional-driver.ts delete mode 100644 packages/cli/src/cli-internals.ts create mode 100644 packages/cli/src/runtime-worker.ts diff --git a/bun.lock b/bun.lock index 9492f277..631ec77d 100644 --- a/bun.lock +++ b/bun.lock @@ -453,6 +453,7 @@ "name": "@holo-js/auth-social-apple", "version": "0.1.9", "dependencies": { + "@holo-js/auth": "catalog:", "@holo-js/auth-social": "catalog:", "@holo-js/config": "catalog:", }, @@ -482,7 +483,6 @@ "version": "0.1.9", "dependencies": { "@holo-js/auth-social": "catalog:", - "@holo-js/config": "catalog:", }, "devDependencies": { "@types/node": "catalog:", @@ -639,6 +639,7 @@ }, "dependencies": { "@clack/prompts": "catalog:", + "@holo-js/cache-db": "catalog:", "@holo-js/config": "catalog:", "@holo-js/core": "catalog:", "@holo-js/db": "catalog:", diff --git a/packages/adapter-next/src/runtime.ts b/packages/adapter-next/src/runtime.ts index cf70292a..e9929657 100644 --- a/packages/adapter-next/src/runtime.ts +++ b/packages/adapter-next/src/runtime.ts @@ -228,24 +228,22 @@ export function createNextHoloHelpers { + const runtime = await resolveRuntime() + return { + projectRoot: runtime.projectRoot, + config: runtime.loadedConfig, + registry: runtime.registry, + runtime, + } + } + return { async getApp() { - const runtime = await resolveRuntime() - return { - projectRoot: runtime.projectRoot, - config: runtime.loadedConfig, - registry: runtime.registry, - runtime, - } + return await resolveRuntimeProjection() }, async getProject() { - const runtime = await resolveRuntime() - return { - projectRoot: runtime.projectRoot, - config: runtime.loadedConfig, - registry: runtime.registry, - runtime, - } + return await resolveRuntimeProjection() }, async getSession() { const runtime = await resolveRuntime() diff --git a/packages/adapter-next/tests/client.test.ts b/packages/adapter-next/tests/client.test.ts index a2880973..3a6f7266 100644 --- a/packages/adapter-next/tests/client.test.ts +++ b/packages/adapter-next/tests/client.test.ts @@ -8,6 +8,15 @@ type MockReactContext = { readonly Provider: (props: { readonly value: TValue, readonly children?: unknown }) => unknown } +type ReactHookState = { + currentHookIndex: number + hookValues: unknown[] +} + +type ReactHookStateOptions = { + readonly onStateChange?: (index: number, value: unknown) => void +} + function createReactMock(overrides: Readonly>): Readonly> { return { createContext(defaultValue: TValue): MockReactContext { @@ -38,6 +47,57 @@ function createReactMock(overrides: Readonly>): Readonly } } +function createReactHookState(): ReactHookState { + return { + currentHookIndex: 0, + hookValues: [], + } +} + +function createReactHookStateMock( + state: ReactHookState, + options: ReactHookStateOptions = {}, +): Readonly> { + return createReactMock({ + useCallback unknown>(callback: TCallback) { + return callback + }, + useEffect(effect: () => void | (() => void)) { + return effect() + }, + useRef(initialValue?: TValue) { + const index = state.currentHookIndex++ + + if (!(index in state.hookValues)) { + state.hookValues[index] = { current: initialValue } + } + + return state.hookValues[index] as { current: TValue | undefined } + }, + useState(initialState: TValue | (() => TValue)) { + const index = state.currentHookIndex++ + + if (!(index in state.hookValues)) { + state.hookValues[index] = typeof initialState === 'function' + ? (initialState as () => TValue)() + : initialState + } + + const setState = (next: TValue | ((previous: TValue) => TValue)) => { + const previous = state.hookValues[index] as TValue + const value = typeof next === 'function' + ? (next as (previous: TValue) => TValue)(previous) + : next + + state.hookValues[index] = value + options.onStateChange?.(index, value) + } + + return [state.hookValues[index] as TValue, setState] as const + }, + }) +} + describe('@holo-js/adapter-next client', () => { afterEach(() => { vi.resetModules() @@ -108,15 +168,7 @@ describe('@holo-js/adapter-next client', () => { type PostFormData = { readonly title: string } - type ReactState = { - currentHookIndex: number - hookValues: unknown[] - } - - ;(globalThis as unknown as { __holoNextHttpFormState?: ReactState }).__holoNextHttpFormState = { - currentHookIndex: 0, - hookValues: [], - } + const state = createReactHookState() let capturedSubmitter: UseFormOptions['submitter'] const forbidden = Object.assign(new Error('Only the author can update posts.'), { @@ -135,41 +187,7 @@ describe('@holo-js/adapter-next client', () => { }), })) - vi.doMock('react', () => createReactMock({ - useEffect(effect: () => void | (() => void)) { - return effect() - }, - useRef(initialValue?: TValue) { - const state = (globalThis as unknown as { - __holoNextHttpFormState: ReactState - }).__holoNextHttpFormState - const index = state.currentHookIndex++ - - if (!(index in state.hookValues)) { - state.hookValues[index] = { current: initialValue } - } - - return state.hookValues[index] as { current: TValue | undefined } - }, - useState(initialState: TValue | (() => TValue)) { - const state = (globalThis as unknown as { - __holoNextHttpFormState: ReactState - }).__holoNextHttpFormState - const index = state.currentHookIndex++ - - if (!(index in state.hookValues)) { - state.hookValues[index] = typeof initialState === 'function' - ? (initialState as () => TValue)() - : initialState - } - - return [state.hookValues[index] as TValue, (next: TValue | ((previous: TValue) => TValue)) => { - state.hookValues[index] = typeof next === 'function' - ? (next as (previous: TValue) => TValue)(state.hookValues[index] as TValue) - : next - }] as const - }, - })) + vi.doMock('react', () => createReactHookStateMock(state)) const { useForm } = await import('../src/client') const post = schema({ @@ -189,9 +207,6 @@ describe('@holo-js/adapter-next client', () => { formData: new FormData(), } satisfies ClientSubmitContext)).rejects.toBe(forbidden) - const state = (globalThis as unknown as { - __holoNextHttpFormState: ReactState - }).__holoNextHttpFormState state.currentHookIndex = 0 expect(() => useForm(post, { @@ -555,17 +570,7 @@ describe('@holo-js/adapter-next client', () => { }) it('recreates the form instance when schema options change across rerenders', async () => { - type ReactState = { - rerenders: number[] - currentHookIndex: number - hookValues: unknown[] - } - - ;(globalThis as unknown as { __holoNextClientTestState?: ReactState }).__holoNextClientTestState = { - rerenders: [], - currentHookIndex: 0, - hookValues: [], - } + const state = createReactHookState() vi.doMock('@holo-js/forms/internal/client', () => ({ createFormClient: vi.fn((_schema, options: { initialValues?: { email?: string } }) => ({ @@ -578,48 +583,7 @@ describe('@holo-js/adapter-next client', () => { })), })) - vi.doMock('react', () => createReactMock({ - useCallback unknown>(callback: TCallback) { - return callback - }, - useEffect(effect: () => void | (() => void)) { - return effect() - }, - useRef(initialValue?: TValue) { - const state = (globalThis as unknown as { - __holoNextClientTestState: ReactState - }).__holoNextClientTestState - const index = state.currentHookIndex++ - - if (!(index in state.hookValues)) { - state.hookValues[index] = { current: initialValue } - } - - return state.hookValues[index] as { current: TValue | undefined } - }, - useState(initialState: TValue | (() => TValue)) { - const state = (globalThis as unknown as { - __holoNextClientTestState: ReactState - }).__holoNextClientTestState - const index = state.currentHookIndex++ - - if (!(index in state.hookValues)) { - state.hookValues[index] = typeof initialState === 'function' - ? (initialState as () => TValue)() - : initialState - } - - return [state.hookValues[index] as TValue, (next: TValue | ((previous: TValue) => TValue)) => { - const previous = state.hookValues[index] as TValue - state.hookValues[index] = typeof next === 'function' - ? (next as (previous: TValue) => TValue)(previous) - : next - if (index === 0 && typeof state.hookValues[index] === 'number') { - state.rerenders.push(state.hookValues[index] as number) - } - }] as const - }, - })) + vi.doMock('react', () => createReactHookStateMock(state)) const { useForm } = await import('../src/client') const login = schema({ @@ -633,9 +597,6 @@ describe('@holo-js/adapter-next client', () => { } const firstForm = useForm(login, firstOptions) - const state = (globalThis as unknown as { - __holoNextClientTestState: ReactState - }).__holoNextClientTestState state.currentHookIndex = 0 const secondForm = useForm(login, { @@ -649,15 +610,7 @@ describe('@holo-js/adapter-next client', () => { }) it('preserves the form instance across rerenders when option values are unchanged', async () => { - type ReactState = { - currentHookIndex: number - hookValues: unknown[] - } - - ;(globalThis as unknown as { __holoNextClientStableOptionsState?: ReactState }).__holoNextClientStableOptionsState = { - currentHookIndex: 0, - hookValues: [], - } + const state = createReactHookState() const createForm = vi.fn((_schema, options: { initialValues?: { email?: string } }) => ({ id: Symbol(options.initialValues?.email ?? 'empty'), @@ -673,40 +626,7 @@ describe('@holo-js/adapter-next client', () => { createFormClient: createForm, })) - vi.doMock('react', () => createReactMock({ - useCallback unknown>(callback: TCallback) { - return callback - }, - useEffect(effect: () => void | (() => void)) { - return effect() - }, - useRef(initialValue?: TValue) { - const state = (globalThis as unknown as { - __holoNextClientStableOptionsState: ReactState - }).__holoNextClientStableOptionsState - const index = state.currentHookIndex++ - - if (!(index in state.hookValues)) { - state.hookValues[index] = { current: initialValue } - } - - return state.hookValues[index] as { current: TValue | undefined } - }, - useState(initialState: TValue | (() => TValue)) { - const state = (globalThis as unknown as { - __holoNextClientStableOptionsState: ReactState - }).__holoNextClientStableOptionsState - const index = state.currentHookIndex++ - - if (!(index in state.hookValues)) { - state.hookValues[index] = typeof initialState === 'function' - ? (initialState as () => TValue)() - : initialState - } - - return [state.hookValues[index] as TValue, vi.fn()] as const - }, - })) + vi.doMock('react', () => createReactHookStateMock(state)) const { useForm } = await import('../src/client') const login = schema({ @@ -723,9 +643,6 @@ describe('@holo-js/adapter-next client', () => { }, }) - const state = (globalThis as unknown as { - __holoNextClientStableOptionsState: ReactState - }).__holoNextClientStableOptionsState state.currentHookIndex = 0 const secondForm = useForm(login, { @@ -741,15 +658,7 @@ describe('@holo-js/adapter-next client', () => { }) it('preserves the form instance across rerenders when submitter identity changes', async () => { - type ReactState = { - currentHookIndex: number - hookValues: unknown[] - } - - ;(globalThis as unknown as { __holoNextClientSubmitterState?: ReactState }).__holoNextClientSubmitterState = { - currentHookIndex: 0, - hookValues: [], - } + const state = createReactHookState() const createForm = vi.fn(() => ({ id: Symbol('form'), @@ -762,40 +671,7 @@ describe('@holo-js/adapter-next client', () => { createFormClient: createForm, })) - vi.doMock('react', () => createReactMock({ - useCallback unknown>(callback: TCallback) { - return callback - }, - useEffect(effect: () => void | (() => void)) { - return effect() - }, - useRef(initialValue?: TValue) { - const state = (globalThis as unknown as { - __holoNextClientSubmitterState: ReactState - }).__holoNextClientSubmitterState - const index = state.currentHookIndex++ - - if (!(index in state.hookValues)) { - state.hookValues[index] = { current: initialValue } - } - - return state.hookValues[index] as { current: TValue | undefined } - }, - useState(initialState: TValue | (() => TValue)) { - const state = (globalThis as unknown as { - __holoNextClientSubmitterState: ReactState - }).__holoNextClientSubmitterState - const index = state.currentHookIndex++ - - if (!(index in state.hookValues)) { - state.hookValues[index] = typeof initialState === 'function' - ? (initialState as () => TValue)() - : initialState - } - - return [state.hookValues[index] as TValue, vi.fn()] as const - }, - })) + vi.doMock('react', () => createReactHookStateMock(state)) const { useForm } = await import('../src/client') const login = schema({ @@ -813,9 +689,6 @@ describe('@holo-js/adapter-next client', () => { }), }) - const state = (globalThis as unknown as { - __holoNextClientSubmitterState: ReactState - }).__holoNextClientSubmitterState state.currentHookIndex = 0 const secondForm = useForm(login, { @@ -834,15 +707,7 @@ describe('@holo-js/adapter-next client', () => { }) it('uses the latest inline submitter without recreating the form instance', async () => { - type ReactState = { - currentHookIndex: number - hookValues: unknown[] - } - - ;(globalThis as unknown as { __holoNextClientSubmitterBridgeState?: ReactState }).__holoNextClientSubmitterBridgeState = { - currentHookIndex: 0, - hookValues: [], - } + const state = createReactHookState() const firstSubmitter = vi.fn(async () => ({ ok: true as const, @@ -874,40 +739,7 @@ describe('@holo-js/adapter-next client', () => { createFormClient: createForm, })) - vi.doMock('react', () => createReactMock({ - useCallback unknown>(callback: TCallback) { - return callback - }, - useEffect(effect: () => void | (() => void)) { - return effect() - }, - useRef(initialValue?: TValue) { - const state = (globalThis as unknown as { - __holoNextClientSubmitterBridgeState: ReactState - }).__holoNextClientSubmitterBridgeState - const index = state.currentHookIndex++ - - if (!(index in state.hookValues)) { - state.hookValues[index] = { current: initialValue } - } - - return state.hookValues[index] as { current: TValue | undefined } - }, - useState(initialState: TValue | (() => TValue)) { - const state = (globalThis as unknown as { - __holoNextClientSubmitterBridgeState: ReactState - }).__holoNextClientSubmitterBridgeState - const index = state.currentHookIndex++ - - if (!(index in state.hookValues)) { - state.hookValues[index] = typeof initialState === 'function' - ? (initialState as () => TValue)() - : initialState - } - - return [state.hookValues[index] as TValue, vi.fn()] as const - }, - })) + vi.doMock('react', () => createReactHookStateMock(state)) const { useForm } = await import('../src/client') const login = schema({ @@ -921,9 +753,6 @@ describe('@holo-js/adapter-next client', () => { submitter: firstSubmitter, }) - const state = (globalThis as unknown as { - __holoNextClientSubmitterBridgeState: ReactState - }).__holoNextClientSubmitterBridgeState state.currentHookIndex = 0 const secondForm = useForm(login, { @@ -942,15 +771,7 @@ describe('@holo-js/adapter-next client', () => { }) it('throws if a stale bridged submitter runs after submitter support is removed', async () => { - type ReactState = { - currentHookIndex: number - hookValues: unknown[] - } - - ;(globalThis as unknown as { __holoNextClientSubmitterRemovalState?: ReactState }).__holoNextClientSubmitterRemovalState = { - currentHookIndex: 0, - hookValues: [], - } + const state = createReactHookState() const capturedSubmitters: Array<((context: { values: { email: string } }) => Promise | unknown) | undefined> = [] @@ -970,40 +791,7 @@ describe('@holo-js/adapter-next client', () => { createFormClient: createForm, })) - vi.doMock('react', () => createReactMock({ - useCallback unknown>(callback: TCallback) { - return callback - }, - useEffect(effect: () => void | (() => void)) { - return effect() - }, - useRef(initialValue?: TValue) { - const state = (globalThis as unknown as { - __holoNextClientSubmitterRemovalState: ReactState - }).__holoNextClientSubmitterRemovalState - const index = state.currentHookIndex++ - - if (!(index in state.hookValues)) { - state.hookValues[index] = { current: initialValue } - } - - return state.hookValues[index] as { current: TValue | undefined } - }, - useState(initialState: TValue | (() => TValue)) { - const state = (globalThis as unknown as { - __holoNextClientSubmitterRemovalState: ReactState - }).__holoNextClientSubmitterRemovalState - const index = state.currentHookIndex++ - - if (!(index in state.hookValues)) { - state.hookValues[index] = typeof initialState === 'function' - ? (initialState as () => TValue)() - : initialState - } - - return [state.hookValues[index] as TValue, vi.fn()] as const - }, - })) + vi.doMock('react', () => createReactHookStateMock(state)) const { useForm } = await import('../src/client') const login = schema({ @@ -1021,9 +809,6 @@ describe('@holo-js/adapter-next client', () => { }), }) - const state = (globalThis as unknown as { - __holoNextClientSubmitterRemovalState: ReactState - }).__holoNextClientSubmitterRemovalState state.currentHookIndex = 0 useForm(login, { @@ -1041,15 +826,7 @@ describe('@holo-js/adapter-next client', () => { }) it('recreates the form instance when file-valued options change across rerenders', async () => { - type ReactState = { - currentHookIndex: number - hookValues: unknown[] - } - - ;(globalThis as unknown as { __holoNextClientFileOptionsState?: ReactState }).__holoNextClientFileOptionsState = { - currentHookIndex: 0, - hookValues: [], - } + const state = createReactHookState() const createForm = vi.fn((_schema, options: { initialValues?: { avatar?: File } }) => ({ id: Symbol(options.initialValues?.avatar?.name ?? 'empty'), @@ -1062,40 +839,7 @@ describe('@holo-js/adapter-next client', () => { createFormClient: createForm, })) - vi.doMock('react', () => createReactMock({ - useCallback unknown>(callback: TCallback) { - return callback - }, - useEffect(effect: () => void | (() => void)) { - return effect() - }, - useRef(initialValue?: TValue) { - const state = (globalThis as unknown as { - __holoNextClientFileOptionsState: ReactState - }).__holoNextClientFileOptionsState - const index = state.currentHookIndex++ - - if (!(index in state.hookValues)) { - state.hookValues[index] = { current: initialValue } - } - - return state.hookValues[index] as { current: TValue | undefined } - }, - useState(initialState: TValue | (() => TValue)) { - const state = (globalThis as unknown as { - __holoNextClientFileOptionsState: ReactState - }).__holoNextClientFileOptionsState - const index = state.currentHookIndex++ - - if (!(index in state.hookValues)) { - state.hookValues[index] = typeof initialState === 'function' - ? (initialState as () => TValue)() - : initialState - } - - return [state.hookValues[index] as TValue, vi.fn()] as const - }, - })) + vi.doMock('react', () => createReactHookStateMock(state)) const { useForm } = await import('../src/client') const upload = schema({ @@ -1108,9 +852,6 @@ describe('@holo-js/adapter-next client', () => { }, }) - const state = (globalThis as unknown as { - __holoNextClientFileOptionsState: ReactState - }).__holoNextClientFileOptionsState state.currentHookIndex = 0 const secondForm = useForm(upload, { diff --git a/packages/adapter-nuxt/src/realtime-definition-transform.ts b/packages/adapter-nuxt/src/realtime-definition-transform.ts index 9b5853db..45b9ea09 100644 --- a/packages/adapter-nuxt/src/realtime-definition-transform.ts +++ b/packages/adapter-nuxt/src/realtime-definition-transform.ts @@ -45,6 +45,25 @@ function skipBlockComment(source: string, index: number): number { return end === -1 ? source.length : end + 2 } +function skipSyntax(source: string, index: number): number | undefined { + const char = source[index] + const next = source[index + 1] + + if (char === '"' || char === '\'' || char === '`') { + return skipString(source, index, char) + } + + if (char === '/' && next === '/') { + return skipLineComment(source, index) + } + + if (char === '/' && next === '*') { + return skipBlockComment(source, index) + } + + return undefined +} + function findRealtimePropertyValueEnd(source: string, index: number): number { let cursor = index let braceDepth = 0 @@ -52,24 +71,13 @@ function findRealtimePropertyValueEnd(source: string, index: number): number { let parenDepth = 0 while (cursor < source.length) { - const char = source[cursor] - const next = source[cursor + 1] - - if (char === '"' || char === '\'' || char === '`') { - cursor = skipString(source, cursor, char) - continue - } - - if (char === '/' && next === '/') { - cursor = skipLineComment(source, cursor) - continue - } - - if (char === '/' && next === '*') { - cursor = skipBlockComment(source, cursor) + const syntaxEnd = skipSyntax(source, cursor) + if (typeof syntaxEnd === 'number') { + cursor = syntaxEnd continue } + const char = source[cursor] if (char === '{') { braceDepth += 1 } else if (char === '}') { @@ -100,24 +108,13 @@ function findClosingBrace(source: string, index: number): number { let depth = 0 while (cursor < source.length) { - const char = source[cursor] - const next = source[cursor + 1] - - if (char === '"' || char === '\'' || char === '`') { - cursor = skipString(source, cursor, char) - continue - } - - if (char === '/' && next === '/') { - cursor = skipLineComment(source, cursor) - continue - } - - if (char === '/' && next === '*') { - cursor = skipBlockComment(source, cursor) + const syntaxEnd = skipSyntax(source, cursor) + if (typeof syntaxEnd === 'number') { + cursor = syntaxEnd continue } + const char = source[cursor] if (char === '{') { depth += 1 } else if (char === '}') { @@ -153,21 +150,9 @@ function applyReplacements(source: string, replacements: readonly Replacement[]) function extractObjectPropertyValue(objectSource: string, propertyName: string): string | undefined { let cursor = 1 while (cursor < objectSource.length - 1) { - const char = objectSource[cursor] - const next = objectSource[cursor + 1] - - if (char === '"' || char === '\'' || char === '`') { - cursor = skipString(objectSource, cursor, char) - continue - } - - if (char === '/' && next === '/') { - cursor = skipLineComment(objectSource, cursor) - continue - } - - if (char === '/' && next === '*') { - cursor = skipBlockComment(objectSource, cursor) + const syntaxEnd = skipSyntax(objectSource, cursor) + if (typeof syntaxEnd === 'number') { + cursor = syntaxEnd continue } @@ -207,21 +192,9 @@ function collectRealtimeDefinitionExports(source: string): readonly RealtimeDefi let cursor = 0 while (cursor < source.length) { - const char = source[cursor] - const next = source[cursor + 1] - - if (char === '"' || char === '\'' || char === '`') { - cursor = skipString(source, cursor, char) - continue - } - - if (char === '/' && next === '/') { - cursor = skipLineComment(source, cursor) - continue - } - - if (char === '/' && next === '*') { - cursor = skipBlockComment(source, cursor) + const syntaxEnd = skipSyntax(source, cursor) + if (typeof syntaxEnd === 'number') { + cursor = syntaxEnd continue } @@ -286,21 +259,9 @@ export function stripRealtimeServerHandlers(source: string): string { let cursor = 0 while (cursor < source.length) { - const char = source[cursor] - const next = source[cursor + 1] - - if (char === '"' || char === '\'' || char === '`') { - cursor = skipString(source, cursor, char) - continue - } - - if (char === '/' && next === '/') { - cursor = skipLineComment(source, cursor) - continue - } - - if (char === '/' && next === '*') { - cursor = skipBlockComment(source, cursor) + const syntaxEnd = skipSyntax(source, cursor) + if (typeof syntaxEnd === 'number') { + cursor = syntaxEnd continue } diff --git a/packages/adapter-nuxt/src/runtime/composables/client-errors.ts b/packages/adapter-nuxt/src/runtime/composables/client-errors.ts index 35f19df2..71be73d5 100644 --- a/packages/adapter-nuxt/src/runtime/composables/client-errors.ts +++ b/packages/adapter-nuxt/src/runtime/composables/client-errors.ts @@ -1,4 +1,4 @@ -import { normalizeHoloHttpError, type NormalizedHoloHttpError } from '@holo-js/core/errors' +import type { NormalizedHoloHttpError } from '@holo-js/core/errors' type BrowserStyle = { cssText: string @@ -35,10 +35,6 @@ type NuxtClientGlobal = typeof globalThis & { showError?: (error: NuxtClientError) => void } -export function normalizeNuxtClientHttpError(error: unknown): NormalizedHoloHttpError | undefined { - return normalizeHoloHttpError(error) -} - export function renderNuxtClientHttpErrorPage(error: NormalizedHoloHttpError): void { const nuxtGlobal = globalThis as NuxtClientGlobal const nuxtError = { diff --git a/packages/adapter-nuxt/src/runtime/composables/forms.ts b/packages/adapter-nuxt/src/runtime/composables/forms.ts index 699cd55d..21509672 100644 --- a/packages/adapter-nuxt/src/runtime/composables/forms.ts +++ b/packages/adapter-nuxt/src/runtime/composables/forms.ts @@ -1,5 +1,6 @@ import { onScopeDispose, reactive, shallowRef, watchEffect } from 'vue' import { useCookie } from '#app' +import { normalizeHoloHttpError } from '@holo-js/core/errors' import type { FormSchema, InferFormData } from '@holo-js/forms' import { DEFAULT_VALIDATION_BAG, createErrorBag, type ValidationErrorBag } from '@holo-js/validation' import { @@ -8,7 +9,8 @@ import { type UseFormResult, createFormClient, } from '@holo-js/forms/internal/client' -import { normalizeNuxtClientHttpError, renderNuxtClientHttpErrorPage } from './client-errors' +import { renderNuxtClientHttpErrorPage } from './client-errors' +import { isPlainObject } from './object' export { type ClientSubmitContext, @@ -51,14 +53,6 @@ const ARRAY_MUTATION_METHODS = new Set([ 'unshift', ]) -function isPlainObject(value: unknown): value is Record { - return !!value - && typeof value === 'object' - && !Array.isArray(value) - && !(value instanceof Date) - && !(value instanceof Blob) -} - function isLeafValue(value: unknown): boolean { return value instanceof Date || value instanceof Blob @@ -132,11 +126,11 @@ function renderFormHttpFailure(result: unknown): void { } const httpError = result.ok === false && typeof result.status === 'number' - ? normalizeNuxtClientHttpError({ + ? normalizeHoloHttpError({ status: result.status, message: getHttpFailureMessage(result as FormHttpFailure), }) - : normalizeNuxtClientHttpError(result) + : normalizeHoloHttpError(result) if (!httpError || httpError.status === 422) { return diff --git a/packages/adapter-nuxt/src/runtime/composables/object.ts b/packages/adapter-nuxt/src/runtime/composables/object.ts new file mode 100644 index 00000000..372595bc --- /dev/null +++ b/packages/adapter-nuxt/src/runtime/composables/object.ts @@ -0,0 +1,7 @@ +export function isPlainObject(value: unknown): value is Record { + return Boolean(value) + && typeof value === 'object' + && !Array.isArray(value) + && !(value instanceof Date) + && !(value instanceof Blob) +} diff --git a/packages/adapter-nuxt/src/runtime/composables/realtime.ts b/packages/adapter-nuxt/src/runtime/composables/realtime.ts index 67288f7c..b60ad166 100644 --- a/packages/adapter-nuxt/src/runtime/composables/realtime.ts +++ b/packages/adapter-nuxt/src/runtime/composables/realtime.ts @@ -1,4 +1,5 @@ import { onScopeDispose, reactive } from 'vue' +import { normalizeHoloHttpError } from '@holo-js/core/errors' import { configureRealtimeClientRuntime, configureRealtimeClientTransport, @@ -14,10 +15,11 @@ import type { RealtimeQueryDefinition, RealtimeResultFor, } from '@holo-js/realtime' -import { normalizeNuxtClientHttpError, renderNuxtClientHttpErrorPage } from './client-errors' +import { renderNuxtClientHttpErrorPage } from './client-errors' +import { isPlainObject } from './object' function emitRealtimeError(error: unknown): void { - const httpError = normalizeNuxtClientHttpError(error) + const httpError = normalizeHoloHttpError(error) if (httpError) { renderNuxtClientHttpErrorPage(httpError) @@ -28,14 +30,6 @@ export const query = createRealtimeQuery export const mutation = createRealtimeMutation -function isPlainObject(value: unknown): value is Record { - return !!value - && typeof value === 'object' - && !Array.isArray(value) - && !(value instanceof Date) - && !(value instanceof Blob) -} - function replaceReactiveObject(target: Record, value: Record): void { for (const key of Object.keys(target)) { if (!(key in value)) { diff --git a/packages/adapter-nuxt/src/runtime/plugins/realtime.client.ts b/packages/adapter-nuxt/src/runtime/plugins/realtime.client.ts index c94d09cd..4d7c7d6d 100644 --- a/packages/adapter-nuxt/src/runtime/plugins/realtime.client.ts +++ b/packages/adapter-nuxt/src/runtime/plugins/realtime.client.ts @@ -1,4 +1 @@ -import { defineNuxtPlugin } from '#app' -import '../composables/realtime' - -export default defineNuxtPlugin(() => {}) +export { default } from './realtime' diff --git a/packages/adapter-nuxt/src/runtime/server/auto-imports/holo.ts b/packages/adapter-nuxt/src/runtime/server/auto-imports/holo.ts index bbf5f3de..d68304d1 100644 --- a/packages/adapter-nuxt/src/runtime/server/auto-imports/holo.ts +++ b/packages/adapter-nuxt/src/runtime/server/auto-imports/holo.ts @@ -1,11 +1,6 @@ -import { - holo as baseHolo, - useHoloDb as baseUseHoloDb, - useHoloDebug as baseUseHoloDebug, - useHoloEnv as baseUseHoloEnv, -} from '../../composables' - -export const holo = baseHolo -export const useHoloDb = baseUseHoloDb -export const useHoloDebug = baseUseHoloDebug -export const useHoloEnv = baseUseHoloEnv +export { + holo, + useHoloDb, + useHoloDebug, + useHoloEnv, +} from '../imports/holo' diff --git a/packages/adapter-nuxt/src/runtime/server/imports/storage.ts b/packages/adapter-nuxt/src/runtime/server/imports/storage.ts index 4c167dfd..c4c9b07d 100644 --- a/packages/adapter-nuxt/src/runtime/server/imports/storage.ts +++ b/packages/adapter-nuxt/src/runtime/server/imports/storage.ts @@ -1,4 +1,4 @@ export { Storage, useStorage, -} from '../utils/storage' +} from '@holo-js/storage/runtime' diff --git a/packages/adapter-sveltekit/src/client.ts b/packages/adapter-sveltekit/src/client.ts index 8a552da8..07a4f8e0 100644 --- a/packages/adapter-sveltekit/src/client.ts +++ b/packages/adapter-sveltekit/src/client.ts @@ -17,6 +17,7 @@ import { runWithBrowserFormElement, } from '@holo-js/forms/internal/client' import { normalizeSvelteKitClientHttpError, renderSvelteKitClientHttpErrorPage } from './client-errors' +import { createReactiveView } from './reactive-view' type InitialFormState = UseFormOptions['initialState'] type FlashedValidationPayload = FormFailurePayload & { @@ -738,62 +739,6 @@ async function hydrateActionFormState( }) } -function createReactiveView( - target: TValue, - subscribe: () => void, - cache: WeakMap, -): TValue { - const cached = cache.get(target) - - if (cached) { - return cached as TValue - } - - const proxy = new Proxy(Array.isArray(target) ? [] : {}, { - get(_shell, key) { - subscribe() - const value = Reflect.get(target as object, key) - - if (typeof value === 'function') { - return value.bind(target) - } - - if (isPlainObject(value)) { - return createReactiveView(value as object, subscribe, cache) - } - - return value - }, - set(_shell, key, value) { - return Reflect.set(target as object, key, value) - }, - ownKeys() { - subscribe() - return Reflect.ownKeys(target as object) - }, - getOwnPropertyDescriptor(_shell, key) { - subscribe() - const descriptor = Reflect.getOwnPropertyDescriptor(target as object, key) - - if (!descriptor) { - return undefined - } - - return { - ...descriptor, - configurable: true, - } - }, - has(_shell, key) { - subscribe() - return Reflect.has(target as object, key) - }, - }) - - cache.set(target, proxy) - return proxy as TValue -} - ensureSubmitListener() function createHttpHandledForm( @@ -874,7 +819,10 @@ export function useForm( }) const cache = new WeakMap() - return createReactiveView>>(form, subscribe, cache) + return createReactiveView>>(form, subscribe, cache, { + bindFunctions: true, + shouldWrapValue: isPlainObject, + }) } export function useValidationErrors>( diff --git a/packages/adapter-sveltekit/src/preprocess.ts b/packages/adapter-sveltekit/src/preprocess.ts index 867a7350..847e68f7 100644 --- a/packages/adapter-sveltekit/src/preprocess.ts +++ b/packages/adapter-sveltekit/src/preprocess.ts @@ -1,3 +1,11 @@ +import { + type Replacement, + applyReplacements, + skipBlockComment, + skipLineComment, + skipString, +} from './transform-utils' + type MarkupInput = { readonly content: string readonly filename?: string @@ -18,12 +26,6 @@ type ScriptBlock = { readonly content: string } -type Replacement = { - readonly start: number - readonly end: number - readonly text: string -} - const clientImportPattern = /import\s*\{([^}]*)\}\s*from\s*['"]@holo-js\/adapter-sveltekit\/client['"]/g const identifier = String.raw`[$A-Z_a-z][$\w]*` export const HOLO_SVELTE_PREPROCESS_NAME = 'holo-sveltekit' @@ -66,35 +68,6 @@ function collectScriptBlocks(content: string): readonly ScriptBlock[] { return blocks } -function skipString(source: string, index: number, quote: string): number { - let cursor = index + 1 - while (cursor < source.length) { - const char = source[cursor] - if (char === '\\') { - cursor += 2 - continue - } - - if (char === quote) { - return cursor + 1 - } - - cursor += 1 - } - - return cursor -} - -function skipLineComment(source: string, index: number): number { - const end = source.indexOf('\n', index + 2) - return end === -1 ? source.length : end + 1 -} - -function skipBlockComment(source: string, index: number): number { - const end = source.indexOf('*/', index + 2) - return end === -1 ? source.length : end + 2 -} - function skipTypeArguments(source: string, index: number): number { if (source[index] !== '<') { return index @@ -185,23 +158,6 @@ function findClosingParen(source: string, index: number): number { return -1 } -function applyReplacements(source: string, replacements: readonly Replacement[]): string { - if (replacements.length === 0) { - return source - } - - let output = '' - let cursor = 0 - const orderedReplacements = [...replacements].sort((left, right) => left.start - right.start) - for (const replacement of orderedReplacements) { - output += source.slice(cursor, replacement.start) - output += replacement.text - cursor = replacement.end - } - - return output + source.slice(cursor) -} - function transformScript(script: string): string { const aliases = collectUseFormAliases(script) if (aliases.length === 0) { diff --git a/packages/adapter-sveltekit/src/reactive-view.ts b/packages/adapter-sveltekit/src/reactive-view.ts new file mode 100644 index 00000000..f82d1286 --- /dev/null +++ b/packages/adapter-sveltekit/src/reactive-view.ts @@ -0,0 +1,65 @@ +type ReactiveViewOptions = { + readonly bindFunctions?: boolean + readonly preserveArrayLengthDescriptor?: boolean + readonly shouldWrapValue: (value: unknown) => value is object +} + +export function createReactiveView( + target: TValue, + subscribe: () => void, + cache: WeakMap, + options: ReactiveViewOptions, +): TValue { + const cached = cache.get(target) + if (cached) { + return cached as TValue + } + + const proxy = new Proxy(Array.isArray(target) ? [] : {}, { + get(_shell, key) { + subscribe() + const value = Reflect.get(target, key, target) + + if (options.bindFunctions && typeof value === 'function') { + return value.bind(target) + } + + if (options.shouldWrapValue(value)) { + return createReactiveView(value, subscribe, cache, options) + } + + return value + }, + set(_shell, key, value) { + return Reflect.set(target, key, value) + }, + ownKeys() { + subscribe() + return Reflect.ownKeys(target) + }, + getOwnPropertyDescriptor(_shell, key) { + subscribe() + const descriptor = Reflect.getOwnPropertyDescriptor(target, key) + + if (!descriptor) { + return undefined + } + + if (options.preserveArrayLengthDescriptor && Array.isArray(target) && key === 'length') { + return descriptor + } + + return { + ...descriptor, + configurable: true, + } + }, + has(_shell, key) { + subscribe() + return Reflect.has(target, key) + }, + }) + + cache.set(target, proxy) + return proxy as TValue +} diff --git a/packages/adapter-sveltekit/src/realtime-definition-transform.ts b/packages/adapter-sveltekit/src/realtime-definition-transform.ts index 993ae83b..73886fe8 100644 --- a/packages/adapter-sveltekit/src/realtime-definition-transform.ts +++ b/packages/adapter-sveltekit/src/realtime-definition-transform.ts @@ -1,8 +1,10 @@ -type Replacement = { - readonly start: number - readonly end: number - readonly text: string -} +import { + type Replacement, + applyReplacements, + skipBlockComment, + skipLineComment, + skipString, +} from './transform-utils' type RealtimeDefinitionExport = { readonly exportedName: string @@ -16,35 +18,6 @@ function isIdentifierBoundary(value: string | undefined): boolean { return !value || !/[$\w]/.test(value) } -function skipString(source: string, index: number, quote: string): number { - let cursor = index + 1 - while (cursor < source.length) { - const char = source[cursor] - if (char === '\\') { - cursor += 2 - continue - } - - if (char === quote) { - return cursor + 1 - } - - cursor += 1 - } - - return cursor -} - -function skipLineComment(source: string, index: number): number { - const end = source.indexOf('\n', index + 2) - return end === -1 ? source.length : end + 1 -} - -function skipBlockComment(source: string, index: number): number { - const end = source.indexOf('*/', index + 2) - return end === -1 ? source.length : end + 2 -} - function skipRegex(source: string, index: number): number { let cursor = index + 1 let inCharacterClass = false @@ -213,23 +186,6 @@ function findClosingBrace(source: string, index: number): number { return -1 } -function applyReplacements(source: string, replacements: readonly Replacement[]): string { - if (replacements.length === 0) { - return source - } - - let output = '' - let cursor = 0 - const ordered = [...replacements].sort((left, right) => left.start - right.start) - for (const replacement of ordered) { - output += source.slice(cursor, replacement.start) - output += replacement.text - cursor = replacement.end - } - - return output + source.slice(cursor) -} - function extractObjectPropertyValue(objectSource: string, propertyName: string): string | undefined { let cursor = 1 while (cursor < objectSource.length - 1) { diff --git a/packages/adapter-sveltekit/src/realtime.ts b/packages/adapter-sveltekit/src/realtime.ts index 16f3775d..ca729f80 100644 --- a/packages/adapter-sveltekit/src/realtime.ts +++ b/packages/adapter-sveltekit/src/realtime.ts @@ -15,6 +15,7 @@ import type { RealtimeResultFor, } from '@holo-js/realtime' import { normalizeSvelteKitClientHttpError, renderSvelteKitClientHttpErrorPage } from './client-errors' +import { createReactiveView } from './reactive-view' function emitRealtimeError(error: unknown): void { const httpError = normalizeSvelteKitClientHttpError(error) @@ -29,78 +30,40 @@ export const query = createRealtimeQuery export const mutation = createRealtimeMutation function isPlainObject(value: unknown): value is Record { - return !!value + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof Blob) } -function createReactiveView( - target: TValue, - subscribe: () => void, - cache: WeakMap, -): TValue { - const cached = cache.get(target) - if (cached) { - return cached as TValue - } - - const proxy = new Proxy(Array.isArray(target) ? [] : {}, { - get(_shell, key) { - subscribe() - const value = Reflect.get(target, key, target) - if (!value || typeof value !== 'object' || value instanceof Date || value instanceof Blob) { - return value - } - - return createReactiveView(value as object, subscribe, cache) - }, - set(_shell, key, value) { - return Reflect.set(target, key, value) - }, - ownKeys() { - subscribe() - return Reflect.ownKeys(target) - }, - getOwnPropertyDescriptor(_shell, key) { - subscribe() - const descriptor = Reflect.getOwnPropertyDescriptor(target, key) - - if (!descriptor) { - return undefined - } - - if (Array.isArray(target) && key === 'length') { - return descriptor - } - - return { - ...descriptor, - configurable: true, - } - }, - has(_shell, key) { - subscribe() - return Reflect.has(target, key) - }, - }) - - cache.set(target, proxy) - return proxy as TValue +function isReactiveObject(value: unknown): value is object { + return Boolean(value) + && typeof value === 'object' + && !(value instanceof Date) + && !(value instanceof Blob) } function createRealtimeReactiveValue(value: TValue, subscribe: () => void): TValue { if (value === undefined) { - return createReactiveView([], subscribe, new WeakMap()) as TValue + return createReactiveView([], subscribe, new WeakMap(), { + preserveArrayLengthDescriptor: true, + shouldWrapValue: isReactiveObject, + }) as TValue } if (Array.isArray(value)) { - return createReactiveView([...value], subscribe, new WeakMap()) as TValue + return createReactiveView([...value], subscribe, new WeakMap(), { + preserveArrayLengthDescriptor: true, + shouldWrapValue: isReactiveObject, + }) as TValue } if (isPlainObject(value)) { - return createReactiveView({ ...value }, subscribe, new WeakMap()) as TValue + return createReactiveView({ ...value }, subscribe, new WeakMap(), { + preserveArrayLengthDescriptor: true, + shouldWrapValue: isReactiveObject, + }) as TValue } return value diff --git a/packages/adapter-sveltekit/src/transform-utils.ts b/packages/adapter-sveltekit/src/transform-utils.ts new file mode 100644 index 00000000..5a974353 --- /dev/null +++ b/packages/adapter-sveltekit/src/transform-utils.ts @@ -0,0 +1,51 @@ +export type Replacement = { + readonly start: number + readonly end: number + readonly text: string +} + +export function skipString(source: string, index: number, quote: string): number { + let cursor = index + 1 + while (cursor < source.length) { + const char = source[cursor] + if (char === '\\') { + cursor += 2 + continue + } + + if (char === quote) { + return cursor + 1 + } + + cursor += 1 + } + + return cursor +} + +export function skipLineComment(source: string, index: number): number { + const end = source.indexOf('\n', index + 2) + return end === -1 ? source.length : end + 1 +} + +export function skipBlockComment(source: string, index: number): number { + const end = source.indexOf('*/', index + 2) + return end === -1 ? source.length : end + 2 +} + +export function applyReplacements(source: string, replacements: readonly Replacement[]): string { + if (replacements.length === 0) { + return source + } + + let output = '' + let cursor = 0 + const orderedReplacements = [...replacements].sort((left, right) => left.start - right.start) + for (const replacement of orderedReplacements) { + output += source.slice(cursor, replacement.start) + output += replacement.text + cursor = replacement.end + } + + return output + source.slice(cursor) +} diff --git a/packages/auth-clerk/src/index.ts b/packages/auth-clerk/src/index.ts index f674dd86..703beaf3 100644 --- a/packages/auth-clerk/src/index.ts +++ b/packages/auth-clerk/src/index.ts @@ -19,7 +19,6 @@ export type { ClerkLogoutResult, ClerkLogoutSession, ClerkProviderRuntime, - ClerkRequestHeaders, ClerkRequestInput, ClerkRequestLike, ClerkSyncStatus, @@ -41,9 +40,7 @@ import { type ClerkEmailAddress, type ClerkLogoutSession, type ClerkProviderRuntime, - type ClerkRequestHeaders, type ClerkRequestInput, - type ClerkRequestLike, type ClerkUserAttributes, type ClerkUserProfile, type ClerkVerifiedSession, @@ -60,6 +57,7 @@ import { type JwkKey, verifyJwtSignatureWithJwk, } from './jwt' +import { normalizeClerkRequest } from './request' type RuntimeAuthProviderAdapter = ReturnType['providers'][string] @@ -121,129 +119,6 @@ function throwUnconfigured(): never { throw new Error('[@holo-js/auth-clerk] Clerk auth runtime is not configured yet.') } -function isPlainHeaderRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype -} - -function appendKnownHeaders(headers: Headers, input: { readonly get?: (name: string) => string | null | undefined }): void { - for (const name of ['authorization', 'cookie', 'host', 'x-forwarded-host', 'x-forwarded-proto']) { - const value = input.get?.(name) - if (typeof value === 'string' && value) { - headers.set(name, value) - } - } -} - -function hasHeaderForEach(input: ClerkRequestHeaders): input is { readonly forEach: (callback: (value: string, key: string) => void) => void } { - return !Array.isArray(input) && 'forEach' in input && typeof input.forEach === 'function' -} - -function hasHeaderEntries(input: ClerkRequestHeaders): input is { readonly entries: () => Iterable } { - return !Array.isArray(input) && 'entries' in input && typeof input.entries === 'function' -} - -function hasHeaderGet(input: ClerkRequestHeaders): input is { readonly get: (name: string) => string | null | undefined } { - return !Array.isArray(input) && 'get' in input && typeof input.get === 'function' -} - -function normalizeRequestHeaders(input: ClerkRequestHeaders | undefined): Headers { - const headers = new Headers() - if (!input) { - return headers - } - - if (input instanceof Headers || Array.isArray(input)) { - new Headers(input).forEach((value, name) => headers.append(name, value)) - return headers - } - - if (hasHeaderForEach(input)) { - input.forEach((value, name) => headers.append(name, value)) - return headers - } - - if (hasHeaderEntries(input)) { - for (const [name, value] of input.entries()) { - headers.append(name, value) - } - return headers - } - - if (hasHeaderGet(input)) { - appendKnownHeaders(headers, input) - return headers - } - - if (isPlainHeaderRecord(input)) { - for (const [name, value] of Object.entries(input)) { - if (typeof value === 'string') { - headers.append(name, value) - continue - } - - if (Array.isArray(value)) { - const separator = name.toLowerCase() === 'cookie' ? '; ' : ',' - const joined = value.filter((entry): entry is string => typeof entry === 'string').join(separator) - if (joined) { - headers.append(name, joined) - } - } - } - } - - return headers -} - -function getRequestFromLikeInput(input: ClerkRequestLike): Request | undefined { - return input.request ?? input.web?.request ?? (input.req instanceof Request ? input.req : undefined) -} - -function getRequestLikeHeaders(input: ClerkRequestLike) { - return input.headers - ?? (typeof input.req === 'object' && !(input.req instanceof Request) ? input.req.headers : undefined) - ?? input.node?.req?.headers -} - -function getRequestLikeMethod(input: ClerkRequestLike): string { - return input.method - ?? (typeof input.req === 'object' && !(input.req instanceof Request) ? input.req.method : undefined) - ?? input.node?.req?.method - ?? 'GET' -} - -function getRequestLikeUrl(input: ClerkRequestLike, headers: Headers): string { - const url = (typeof input.url === 'string' ? input.url : input.url?.toString()) - ?? (typeof input.req === 'object' && !(input.req instanceof Request) ? input.req.url : undefined) - ?? input.node?.req?.url - ?? input.path - ?? '/' - - try { - return new URL(url).toString() - } catch { - const protocol = headers.get('x-forwarded-proto') ?? 'http' - const host = headers.get('x-forwarded-host') ?? headers.get('host') ?? 'localhost' - return new URL(url, `${protocol}://${host}`).toString() - } -} - -function normalizeClerkRequest(input: ClerkRequestInput): Request { - if (input instanceof Request) { - return input - } - - const request = getRequestFromLikeInput(input) - if (request) { - return request - } - - const headers = normalizeRequestHeaders(getRequestLikeHeaders(input)) - return new Request(getRequestLikeUrl(input, headers), { - method: getRequestLikeMethod(input), - headers, - }) -} - function getBindings(): ClerkAuthBindings { const clerkBindings = getRuntimeState().bindings if (!clerkBindings?.identityStore) { diff --git a/packages/auth-clerk/src/jwt.ts b/packages/auth-clerk/src/jwt.ts index 2047f3d8..7573fd2e 100644 --- a/packages/auth-clerk/src/jwt.ts +++ b/packages/auth-clerk/src/jwt.ts @@ -1,57 +1,30 @@ -import { createPublicKey, verify as verifySignature } from 'node:crypto' +import { authRuntimeInternals } from '@holo-js/auth' import type { AuthClerkProviderConfig } from '@holo-js/config' -export type JwkKey = Readonly> & { - readonly kid?: string -} +export type JwkKey = Parameters[1] export const CLERK_API_BASE_URL = 'https://api.clerk.com' const clerkJwksCache = new Map>() -function decodeJwtSegment(value: string, label: string): T { - try { - return JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as T - } catch { - throw new Error(`[@holo-js/auth-clerk] Clerk token ${label} was not valid JSON.`) - } -} - export function parseJwt(token: string): { readonly header: Readonly> readonly payload: Readonly> readonly signature: Buffer readonly signingInput: Buffer } { - const segments = token.split('.') - if (segments.length !== 3 || !segments[0] || !segments[1] || !segments[2]) { - throw new Error('[@holo-js/auth-clerk] Clerk token was not a valid JWT.') - } - - return { - header: decodeJwtSegment>>(segments[0], 'header'), - payload: decodeJwtSegment>>(segments[1], 'payload'), - signature: Buffer.from(segments[2], 'base64url'), - signingInput: Buffer.from(`${segments[0]}.${segments[1]}`, 'utf8'), - } + return authRuntimeInternals.jwt.parseJwt(token, { + errorPrefix: '[@holo-js/auth-clerk] Clerk token', + malformedMessage: '[@holo-js/auth-clerk] Clerk token was not a valid JWT.', + }) } export function verifyJwtSignatureWithJwk( token: ReturnType, jwk: JwkKey, ): boolean { - const algorithm = typeof token.header.alg === 'string' ? token.header.alg : '' - const key = createPublicKey({ key: jwk as never, format: 'jwk' }) - - switch (algorithm) { - case 'RS256': - return verifySignature('RSA-SHA256', token.signingInput, key, token.signature) - case 'RS384': - return verifySignature('RSA-SHA384', token.signingInput, key, token.signature) - case 'RS512': - return verifySignature('RSA-SHA512', token.signingInput, key, token.signature) - default: - throw new Error(`[@holo-js/auth-clerk] Unsupported Clerk JWT algorithm "${algorithm || 'unknown'}".`) - } + return authRuntimeInternals.jwt.verifyJwtSignatureWithJwk(token, jwk, { + unsupportedAlgorithmMessage: algorithm => `[@holo-js/auth-clerk] Unsupported Clerk JWT algorithm "${algorithm}".`, + }) } export function resolveClerkJwksUrl(config: AuthClerkProviderConfig): string { @@ -68,34 +41,10 @@ export async function fetchClerkJwks( jwksUrl: string, options: { readonly refresh?: boolean } = {}, ): Promise { - if (options.refresh) { - clerkJwksCache.delete(jwksUrl) - } - - const existing = clerkJwksCache.get(jwksUrl) - if (existing) { - return existing - } - - const pending = (async () => { - const response = await fetch(jwksUrl, { - headers: { - accept: 'application/json', - }, - }) - if (!response.ok) { - throw new Error(`[@holo-js/auth-clerk] Failed to load Clerk JWKS from "${jwksUrl}".`) - } - - const payload = await response.json() as { keys?: readonly JwkKey[] } - return payload.keys ?? [] - })() - - clerkJwksCache.set(jwksUrl, pending) - try { - return await pending - } catch (error) { - clerkJwksCache.delete(jwksUrl) - throw error - } + return authRuntimeInternals.jwt.fetchCachedJwks(jwksUrl, { + cache: clerkJwksCache, + requestUrl: jwksUrl, + refresh: options.refresh, + errorMessage: `[@holo-js/auth-clerk] Failed to load Clerk JWKS from "${jwksUrl}".`, + }) } diff --git a/packages/auth-clerk/src/request.ts b/packages/auth-clerk/src/request.ts new file mode 100644 index 00000000..fe1900cd --- /dev/null +++ b/packages/auth-clerk/src/request.ts @@ -0,0 +1,6 @@ +import { authRuntimeInternals } from '@holo-js/auth' +import type { ClerkRequestInput } from './contracts' + +export function normalizeClerkRequest(input: ClerkRequestInput): Request { + return authRuntimeInternals.normalizeRequestInput(input) +} diff --git a/packages/auth-social-apple/package.json b/packages/auth-social-apple/package.json index e06f5679..4df7ed6a 100644 --- a/packages/auth-social-apple/package.json +++ b/packages/auth-social-apple/package.json @@ -23,6 +23,7 @@ "test": "vitest --run" }, "dependencies": { + "@holo-js/auth": "catalog:", "@holo-js/auth-social": "catalog:", "@holo-js/config": "catalog:" }, diff --git a/packages/auth-social-apple/src/index.ts b/packages/auth-social-apple/src/index.ts index 6b33ba97..4255a3c5 100644 --- a/packages/auth-social-apple/src/index.ts +++ b/packages/auth-social-apple/src/index.ts @@ -1,24 +1,17 @@ -import { createPublicKey, type JsonWebKey, verify as verifySignature } from 'node:crypto' +import { authRuntimeInternals } from '@holo-js/auth' import type { SocialCallbackContext, SocialProviderProfile, SocialProviderRuntime, - SocialProviderTokens, SocialRedirectContext, } from '@holo-js/auth-social' +import { socialAuthInternals } from '@holo-js/auth-social' const APPLE_ISSUER = 'https://appleid.apple.com' const APPLE_JWKS_URL = 'https://appleid.apple.com/auth/keys' const APPLE_TOKEN_CLOCK_SKEW_MS = 60_000 -type JwkKey = Readonly & { - readonly kid?: string -} - -async function readJson(response: Response): Promise { - const text = await response.text() - return text ? JSON.parse(text) as unknown : {} -} +type JwkKey = Parameters[1] async function readAppleUserPayload(request: Request): Promise<{ readonly email?: string @@ -54,31 +47,16 @@ async function readAppleUserPayload(request: Request): Promise<{ } } -function decodeJwtSegment(value: string, label: string): T { - try { - return JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as T - } catch { - throw new Error(`[@holo-js/auth-social-apple] Apple id_token ${label} was not valid JSON.`) - } -} - function parseJwt(token: string): { readonly header: Readonly> readonly payload: Readonly> readonly signature: Buffer readonly signingInput: Buffer } { - const segments = token.split('.') - if (segments.length !== 3 || !segments[0] || !segments[1] || !segments[2]) { - throw new Error('[@holo-js/auth-social-apple] Apple id_token was malformed.') - } - - return { - header: decodeJwtSegment>>(segments[0], 'header'), - payload: decodeJwtSegment>>(segments[1], 'payload'), - signature: Buffer.from(segments[2], 'base64url'), - signingInput: Buffer.from(`${segments[0]}.${segments[1]}`, 'utf8'), - } + return authRuntimeInternals.jwt.parseJwt(token, { + errorPrefix: '[@holo-js/auth-social-apple] Apple id_token', + malformedMessage: '[@holo-js/auth-social-apple] Apple id_token was malformed.', + }) } function verifyJwtSignatureWithJwk( @@ -90,22 +68,17 @@ function verifyJwtSignatureWithJwk( throw new Error(`[@holo-js/auth-social-apple] Unsupported Apple id_token algorithm "${algorithm || 'unknown'}".`) } - const key = createPublicKey({ key: jwk, format: 'jwk' }) - return verifySignature('RSA-SHA256', token.signingInput, key, token.signature) + return authRuntimeInternals.jwt.verifyJwtSignatureWithJwk(token, jwk, { + unsupportedAlgorithmMessage: unsupportedAlgorithm => `[@holo-js/auth-social-apple] Unsupported Apple id_token algorithm "${unsupportedAlgorithm}".`, + }) } async function fetchAppleJwks(): Promise { - const response = await fetch(APPLE_JWKS_URL, { - headers: { - accept: 'application/json', - }, + return authRuntimeInternals.jwt.fetchCachedJwks(APPLE_JWKS_URL, { + cache: new Map(), + requestUrl: APPLE_JWKS_URL, + errorMessage: '[@holo-js/auth-social-apple] Failed to load Apple JWKS.', }) - if (!response.ok) { - throw new Error('[@holo-js/auth-social-apple] Failed to load Apple JWKS.') - } - - const payload = await response.json() as { keys?: readonly JwkKey[] } - return payload.keys ?? [] } function hasExpectedAudience(audience: unknown, clientId: string): boolean { @@ -169,45 +142,20 @@ async function verifyAppleIdToken( } async function exchangeToken(context: SocialCallbackContext): Promise> { - const body = new URLSearchParams({ - code: context.code, - client_id: context.config.clientId ?? '', - client_secret: context.config.clientSecret ?? '', - redirect_uri: context.config.redirectUri ?? '', - grant_type: 'authorization_code', - code_verifier: context.codeVerifier, - }) - const response = await fetch('https://appleid.apple.com/auth/token', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded', }, - body, + body: socialAuthInternals.createAuthorizationCodeTokenBody(context), }) if (!response.ok) { throw new Error('[@holo-js/auth-social-apple] Apple token exchange failed.') } - return await readJson(response) as Record -} - -function normalizeTokens(payload: Record): SocialProviderTokens { - const expiresIn = typeof payload.expires_in === 'number' - ? payload.expires_in - : typeof payload.expires_in === 'string' - ? Number.parseInt(payload.expires_in, 10) - : undefined - - return { - accessToken: String(payload.access_token ?? ''), - refreshToken: typeof payload.refresh_token === 'string' ? payload.refresh_token : undefined, - expiresAt: Number.isFinite(expiresIn) ? new Date(Date.now() + (expiresIn! * 1000)) : undefined, - idToken: payload.id_token, - tokenType: payload.token_type, - } + return await socialAuthInternals.readJsonResponse(response) as Record } function normalizeProfile( @@ -264,7 +212,12 @@ export const appleSocialProvider: SocialProviderRuntime = Object.freeze({ return { profile: normalizeProfile(claims, userPayload), - tokens: normalizeTokens(tokenPayload), + tokens: socialAuthInternals.normalizeOAuthTokens(tokenPayload, { + includeScope: false, + extra: { + idToken: tokenPayload.id_token, + }, + }), } }, }) diff --git a/packages/auth-social-discord/src/index.ts b/packages/auth-social-discord/src/index.ts index 35ec98f4..6ecd57ec 100644 --- a/packages/auth-social-discord/src/index.ts +++ b/packages/auth-social-discord/src/index.ts @@ -2,14 +2,9 @@ import type { SocialCallbackContext, SocialProviderProfile, SocialProviderRuntime, - SocialProviderTokens, SocialRedirectContext, } from '@holo-js/auth-social' - -async function readJson(response: Response): Promise { - const text = await response.text() - return text ? JSON.parse(text) as unknown : {} -} +import { socialAuthInternals } from '@holo-js/auth-social' function applyScopes(url: URL, config: SocialRedirectContext['config']): void { const scopes = config.scopes && config.scopes.length > 0 ? config.scopes : ['identify', 'email'] @@ -17,45 +12,20 @@ function applyScopes(url: URL, config: SocialRedirectContext['config']): void { } async function exchangeToken(context: SocialCallbackContext): Promise> { - const body = new URLSearchParams({ - code: context.code, - client_id: context.config.clientId ?? '', - client_secret: context.config.clientSecret ?? '', - redirect_uri: context.config.redirectUri ?? '', - grant_type: 'authorization_code', - code_verifier: context.codeVerifier, - }) - const response = await fetch('https://discord.com/api/oauth2/token', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded', }, - body, + body: socialAuthInternals.createAuthorizationCodeTokenBody(context), }) if (!response.ok) { throw new Error('[@holo-js/auth-social-discord] Discord token exchange failed.') } - return await readJson(response) as Record -} - -function normalizeTokens(payload: Record): SocialProviderTokens { - const expiresIn = typeof payload.expires_in === 'number' - ? payload.expires_in - : typeof payload.expires_in === 'string' - ? Number.parseInt(payload.expires_in, 10) - : undefined - - return { - accessToken: String(payload.access_token ?? ''), - refreshToken: typeof payload.refresh_token === 'string' ? payload.refresh_token : undefined, - expiresAt: Number.isFinite(expiresIn) ? new Date(Date.now() + (expiresIn! * 1000)) : undefined, - tokenType: payload.token_type, - scope: payload.scope, - } + return await socialAuthInternals.readJsonResponse(response) as Record } function normalizeProfile(payload: Record): SocialProviderProfile { @@ -102,10 +72,10 @@ export const discordSocialProvider: SocialProviderRuntime = Object.freeze({ throw new Error('[@holo-js/auth-social-discord] Discord user request failed.') } - const payload = await readJson(response) as Record + const payload = await socialAuthInternals.readJsonResponse(response) as Record return { profile: normalizeProfile(payload), - tokens: normalizeTokens(tokenPayload), + tokens: socialAuthInternals.normalizeOAuthTokens(tokenPayload), } }, }) diff --git a/packages/auth-social-facebook/package.json b/packages/auth-social-facebook/package.json index f755ecc2..7af78c72 100644 --- a/packages/auth-social-facebook/package.json +++ b/packages/auth-social-facebook/package.json @@ -23,8 +23,7 @@ "test": "vitest --run --coverage" }, "dependencies": { - "@holo-js/auth-social": "catalog:", - "@holo-js/config": "catalog:" + "@holo-js/auth-social": "catalog:" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/auth-social-facebook/src/index.ts b/packages/auth-social-facebook/src/index.ts index 09eac216..67abd9f6 100644 --- a/packages/auth-social-facebook/src/index.ts +++ b/packages/auth-social-facebook/src/index.ts @@ -2,14 +2,9 @@ import type { SocialCallbackContext, SocialProviderProfile, SocialProviderRuntime, - SocialProviderTokens, SocialRedirectContext, } from '@holo-js/auth-social' - -async function readJson(response: Response): Promise { - const text = await response.text() - return text ? JSON.parse(text) as unknown : {} -} +import { socialAuthInternals } from '@holo-js/auth-social' function applyScopes(url: URL, config: SocialRedirectContext['config']): void { const configuredScopes = config.scopes @@ -18,40 +13,22 @@ function applyScopes(url: URL, config: SocialRedirectContext['config']): void { } async function exchangeToken(context: SocialCallbackContext): Promise> { - const body = new URLSearchParams({ - client_id: context.config.clientId ?? '', - client_secret: context.config.clientSecret ?? '', - redirect_uri: context.config.redirectUri ?? '', - code: context.code, - }) - const response = await fetch('https://graph.facebook.com/oauth/access_token', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded', }, - body, + body: socialAuthInternals.createAuthorizationCodeTokenBody(context, { + includeCodeVerifier: false, + includeGrantType: false, + }), }) if (!response.ok) { throw new Error('[@holo-js/auth-social-facebook] Facebook token exchange failed.') } - return await readJson(response) as Record -} - -function normalizeTokens(payload: Record): SocialProviderTokens { - const expiresIn = typeof payload.expires_in === 'number' - ? payload.expires_in - : typeof payload.expires_in === 'string' - ? Number.parseInt(payload.expires_in, 10) - : undefined - - return { - accessToken: String(payload.access_token ?? ''), - expiresAt: Number.isFinite(expiresIn) ? new Date(Date.now() + (expiresIn! * 1000)) : undefined, - tokenType: payload.token_type, - } + return await socialAuthInternals.readJsonResponse(response) as Record } function normalizeProfile(payload: Record): SocialProviderProfile { @@ -98,10 +75,13 @@ export const facebookSocialProvider: SocialProviderRuntime = Object.freeze({ throw new Error('[@holo-js/auth-social-facebook] Facebook user request failed.') } - const payload = await readJson(response) as Record + const payload = await socialAuthInternals.readJsonResponse(response) as Record return { profile: normalizeProfile(payload), - tokens: normalizeTokens(tokenPayload), + tokens: socialAuthInternals.normalizeOAuthTokens(tokenPayload, { + includeRefreshToken: false, + includeScope: false, + }), } }, }) diff --git a/packages/auth-social-github/src/index.ts b/packages/auth-social-github/src/index.ts index 3526f61b..8e2eb3ad 100644 --- a/packages/auth-social-github/src/index.ts +++ b/packages/auth-social-github/src/index.ts @@ -2,52 +2,33 @@ import type { SocialCallbackContext, SocialProviderProfile, SocialProviderRuntime, - SocialProviderTokens, SocialRedirectContext, } from '@holo-js/auth-social' +import { socialAuthInternals } from '@holo-js/auth-social' function applyScopes(url: URL, config: SocialRedirectContext['config']): void { const scopes = config.scopes?.length ? config.scopes : ['read:user', 'user:email'] url.searchParams.set('scope', scopes.join(' ')) } -async function readJson(response: Response): Promise { - const text = await response.text() - return text ? JSON.parse(text) as unknown : {} -} - async function exchangeToken(context: SocialCallbackContext): Promise> { - const body = new URLSearchParams({ - code: context.code, - client_id: context.config.clientId ?? '', - client_secret: context.config.clientSecret ?? '', - redirect_uri: context.config.redirectUri ?? '', - }) - const response = await fetch('https://github.com/login/oauth/access_token', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded', }, - body, + body: socialAuthInternals.createAuthorizationCodeTokenBody(context, { + includeCodeVerifier: false, + includeGrantType: false, + }), }) if (!response.ok) { throw new Error('[@holo-js/auth-social-github] GitHub token exchange failed.') } - return await readJson(response) as Record -} - -function normalizeTokens(payload: Record): SocialProviderTokens { - return { - accessToken: String(payload.access_token ?? ''), - refreshToken: typeof payload.refresh_token === 'string' ? payload.refresh_token : undefined, - refreshTokenExpiresIn: payload.refresh_token_expires_in, - tokenType: payload.token_type, - scope: payload.scope, - } + return await socialAuthInternals.readJsonResponse(response) as Record } function normalizeProfile( @@ -102,12 +83,16 @@ export const githubSocialProvider: SocialProviderRuntime = Object.freeze({ throw new Error('[@holo-js/auth-social-github] GitHub email request failed.') } - const profilePayload = await readJson(profileResponse) as Record - const emailsPayload = await readJson(emailsResponse) as readonly Record[] + const profilePayload = await socialAuthInternals.readJsonResponse(profileResponse) as Record + const emailsPayload = await socialAuthInternals.readJsonResponse(emailsResponse) as readonly Record[] return { profile: normalizeProfile(profilePayload, emailsPayload), - tokens: normalizeTokens(tokenPayload), + tokens: socialAuthInternals.normalizeOAuthTokens(tokenPayload, { + extra: { + refreshTokenExpiresIn: tokenPayload.refresh_token_expires_in, + }, + }), } }, }) diff --git a/packages/auth-social-google/src/index.ts b/packages/auth-social-google/src/index.ts index 9debb85c..d4172a04 100644 --- a/packages/auth-social-google/src/index.ts +++ b/packages/auth-social-google/src/index.ts @@ -2,9 +2,9 @@ import type { SocialCallbackContext, SocialProviderProfile, SocialProviderRuntime, - SocialProviderTokens, SocialRedirectContext, } from '@holo-js/auth-social' +import { socialAuthInternals } from '@holo-js/auth-social' function applyScopes(url: URL, config: SocialRedirectContext['config'], fallback: readonly string[]): void { const configuredScopes = config.scopes ?? [] @@ -12,54 +12,21 @@ function applyScopes(url: URL, config: SocialRedirectContext['config'], fallback url.searchParams.set('scope', scopes.join(' ')) } -async function readJson(response: Response): Promise { - const text = await response.text() - return text ? JSON.parse(text) as unknown : {} -} - -async function exchangeToken( - context: SocialCallbackContext, - endpoint: string, -): Promise> { - const body = new URLSearchParams({ - code: context.code, - client_id: context.config.clientId ?? '', - client_secret: context.config.clientSecret ?? '', - redirect_uri: context.config.redirectUri ?? '', - grant_type: 'authorization_code', - code_verifier: context.codeVerifier, - }) - - const response = await fetch(endpoint, { +async function exchangeToken(context: SocialCallbackContext): Promise> { + const response = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded', }, - body, + body: socialAuthInternals.createAuthorizationCodeTokenBody(context), }) if (!response.ok) { throw new Error('[@holo-js/auth-social-google] Google token exchange failed.') } - return await readJson(response) as Record -} - -function normalizeTokensWithAccessToken(payload: Record, accessToken: string): SocialProviderTokens { - const expiresIn = typeof payload.expires_in === 'number' - ? payload.expires_in - : typeof payload.expires_in === 'string' - ? Number.parseInt(payload.expires_in, 10) - : undefined - - return { - accessToken, - refreshToken: typeof payload.refresh_token === 'string' ? payload.refresh_token : undefined, - expiresAt: Number.isFinite(expiresIn) ? new Date(Date.now() + (expiresIn! * 1000)) : undefined, - idToken: payload.id_token, - tokenType: payload.token_type, - } + return await socialAuthInternals.readJsonResponse(response) as Record } function readAccessToken(payload: Record): string { @@ -100,7 +67,7 @@ export const googleSocialProvider: SocialProviderRuntime = Object.freeze({ return url.toString() }, async exchangeCode(context: SocialCallbackContext) { - const tokenPayload = await exchangeToken(context, 'https://oauth2.googleapis.com/token') + const tokenPayload = await exchangeToken(context) const accessToken = readAccessToken(tokenPayload) const profileResponse = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { headers: { @@ -112,10 +79,16 @@ export const googleSocialProvider: SocialProviderRuntime = Object.freeze({ throw new Error('[@holo-js/auth-social-google] Google user info request failed.') } - const profilePayload = await readJson(profileResponse) as Record + const profilePayload = await socialAuthInternals.readJsonResponse(profileResponse) as Record return { profile: normalizeProfile(profilePayload), - tokens: normalizeTokensWithAccessToken(tokenPayload, accessToken), + tokens: socialAuthInternals.normalizeOAuthTokens(tokenPayload, { + includeScope: false, + extra: { + accessToken, + idToken: tokenPayload.id_token, + }, + }), } }, }) diff --git a/packages/auth-social-linkedin/src/index.ts b/packages/auth-social-linkedin/src/index.ts index 43d78f90..407ef83c 100644 --- a/packages/auth-social-linkedin/src/index.ts +++ b/packages/auth-social-linkedin/src/index.ts @@ -2,51 +2,27 @@ import type { SocialCallbackContext, SocialProviderProfile, SocialProviderRuntime, - SocialProviderTokens, SocialRedirectContext, } from '@holo-js/auth-social' - -async function readJson(response: Response): Promise { - const text = await response.text() - return text ? JSON.parse(text) as unknown : {} -} +import { socialAuthInternals } from '@holo-js/auth-social' async function exchangeToken(context: SocialCallbackContext): Promise> { - const body = new URLSearchParams({ - code: context.code, - client_id: context.config.clientId ?? '', - client_secret: context.config.clientSecret ?? '', - redirect_uri: context.config.redirectUri ?? '', - grant_type: 'authorization_code', - }) - const response = await fetch('https://www.linkedin.com/oauth/v2/accessToken', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded', }, - body, + body: socialAuthInternals.createAuthorizationCodeTokenBody(context, { + includeCodeVerifier: false, + }), }) if (!response.ok) { throw new Error('[@holo-js/auth-social-linkedin] LinkedIn token exchange failed.') } - return await readJson(response) as Record -} - -function normalizeTokens(payload: Record): SocialProviderTokens { - const expiresIn = typeof payload.expires_in === 'number' - ? payload.expires_in - : typeof payload.expires_in === 'string' - ? Number.parseInt(payload.expires_in, 10) - : undefined - - return { - accessToken: String(payload.access_token ?? ''), - expiresAt: Number.isFinite(expiresIn) ? new Date(Date.now() + (expiresIn! * 1000)) : undefined, - } + return await socialAuthInternals.readJsonResponse(response) as Record } function normalizeProfile(payload: Record): SocialProviderProfile { @@ -88,10 +64,14 @@ export const linkedinSocialProvider: SocialProviderRuntime = Object.freeze({ throw new Error('[@holo-js/auth-social-linkedin] LinkedIn user info request failed.') } - const payload = await readJson(response) as Record + const payload = await socialAuthInternals.readJsonResponse(response) as Record return { profile: normalizeProfile(payload), - tokens: normalizeTokens(tokenPayload), + tokens: socialAuthInternals.normalizeOAuthTokens(tokenPayload, { + includeRefreshToken: false, + includeScope: false, + includeTokenType: false, + }), } }, }) diff --git a/packages/auth-social-linkedin/tests/package.test.ts b/packages/auth-social-linkedin/tests/package.test.ts index de122b58..18aa2532 100644 --- a/packages/auth-social-linkedin/tests/package.test.ts +++ b/packages/auth-social-linkedin/tests/package.test.ts @@ -2,12 +2,34 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import linkedinSocialProvider from '../src' const originalFetch = globalThis.fetch +const callbackConfig = { + clientId: 'client', + clientSecret: 'secret', + redirectUri: 'https://app.test/auth/linkedin/callback', + scopes: [], + encryptTokens: false, +} afterEach(() => { globalThis.fetch = originalFetch vi.restoreAllMocks() }) +function createCallbackContext(overrides: { + readonly scopes?: readonly string[] +} = {}) { + return { + provider: 'linkedin', + request: new Request('https://app.test/auth/linkedin/callback?code=test'), + code: 'test-code', + codeVerifier: 'verifier', + config: { + ...callbackConfig, + scopes: overrides.scopes ?? callbackConfig.scopes, + }, + } as const +} + describe('@holo-js/auth-social-linkedin', () => { it('builds the authorization url with LinkedIn defaults', async () => { const url = await linkedinSocialProvider.buildAuthorizationUrl({ @@ -43,19 +65,7 @@ describe('@holo-js/auth-social-linkedin', () => { picture: 'https://example.com/avatar.png', }), { status: 200 })) as typeof fetch - const exchanged = await linkedinSocialProvider.exchangeCode({ - provider: 'linkedin', - request: new Request('https://app.test/auth/linkedin/callback?code=test'), - code: 'test-code', - codeVerifier: 'verifier', - config: { - clientId: 'client', - clientSecret: 'secret', - redirectUri: 'https://app.test/auth/linkedin/callback', - scopes: [], - encryptTokens: false, - }, - }) + const exchanged = await linkedinSocialProvider.exchangeCode(createCallbackContext()) expect(exchanged.profile).toEqual({ id: 'linkedin-user', @@ -70,37 +80,15 @@ describe('@holo-js/auth-social-linkedin', () => { globalThis.fetch = vi.fn() .mockResolvedValueOnce(new Response('nope', { status: 401 })) as typeof fetch - await expect(linkedinSocialProvider.exchangeCode({ - provider: 'linkedin', - request: new Request('https://app.test/auth/linkedin/callback?code=test'), - code: 'test-code', - codeVerifier: 'verifier', - config: { - clientId: 'client', - clientSecret: 'secret', - redirectUri: 'https://app.test/auth/linkedin/callback', - scopes: [], - encryptTokens: false, - }, - })).rejects.toThrow('LinkedIn token exchange failed') + await expect(linkedinSocialProvider.exchangeCode(createCallbackContext())) + .rejects.toThrow('LinkedIn token exchange failed') globalThis.fetch = vi.fn() .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: 'access' }), { status: 200 })) .mockResolvedValueOnce(new Response('nope', { status: 500 })) as typeof fetch - await expect(linkedinSocialProvider.exchangeCode({ - provider: 'linkedin', - request: new Request('https://app.test/auth/linkedin/callback?code=test'), - code: 'test-code', - codeVerifier: 'verifier', - config: { - clientId: 'client', - clientSecret: 'secret', - redirectUri: 'https://app.test/auth/linkedin/callback', - scopes: [], - encryptTokens: false, - }, - })).rejects.toThrow('LinkedIn user info request failed') + await expect(linkedinSocialProvider.exchangeCode(createCallbackContext())) + .rejects.toThrow('LinkedIn user info request failed') }) it('fails when LinkedIn user info does not include sub', async () => { @@ -108,18 +96,8 @@ describe('@holo-js/auth-social-linkedin', () => { .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: 'access' }), { status: 200 })) .mockResolvedValueOnce(new Response(JSON.stringify({ email: 'user@example.com' }), { status: 200 })) as typeof fetch - await expect(linkedinSocialProvider.exchangeCode({ - provider: 'linkedin', - request: new Request('https://app.test/auth/linkedin/callback?code=test'), - code: 'test-code', - codeVerifier: 'verifier', - config: { - clientId: 'client', - clientSecret: 'secret', - redirectUri: 'https://app.test/auth/linkedin/callback', - scopes: ['openid'], - encryptTokens: false, - }, - })).rejects.toThrow('did not include "sub"') + await expect(linkedinSocialProvider.exchangeCode(createCallbackContext({ + scopes: ['openid'], + }))).rejects.toThrow('did not include "sub"') }) }) diff --git a/packages/auth-social/src/index.ts b/packages/auth-social/src/index.ts index 129f3906..c1cfc308 100644 --- a/packages/auth-social/src/index.ts +++ b/packages/auth-social/src/index.ts @@ -142,7 +142,6 @@ export interface SocialCallbackFailure { const SOCIAL_BINDINGS_KEY = '__holoAuthSocialBindings__' const AUTH_PROVIDER_MARKER = Symbol.for('holo-js.auth.provider') -const GET_ONLY_REQUEST_HEADER_NAMES = ['authorization', 'cookie', 'host', 'x-forwarded-host', 'x-forwarded-proto'] as const type RuntimeAuthProviderAdapter = ReturnType['providers'][string] type SocialRuntimeGlobal = typeof globalThis & { [SOCIAL_BINDINGS_KEY]?: SocialAuthBindings @@ -152,98 +151,6 @@ function getSocialRuntimeGlobal(): SocialRuntimeGlobal { return globalThis as SocialRuntimeGlobal } -function isPlainHeaderRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype -} - -// Header-like objects that only expose get() cannot be enumerated. OAuth needs only auth, cookie, host, and forwarded -// host/proto metadata here; every other header is ignored unless this input path grows full iteration support. -function appendKnownHeaders(headers: Headers, input: { readonly get?: (name: string) => string | null | undefined }): void { - for (const name of GET_ONLY_REQUEST_HEADER_NAMES) { - const value = input.get?.(name) - if (typeof value === 'string' && value) { - headers.set(name, value) - } - } -} - -function hasHeaderForEach(input: SocialRequestHeaders): input is { readonly forEach: (callback: (value: string, key: string) => void) => void } { - return !Array.isArray(input) && 'forEach' in input && typeof input.forEach === 'function' -} - -function hasHeaderEntries(input: SocialRequestHeaders): input is { readonly entries: () => Iterable } { - return !Array.isArray(input) && 'entries' in input && typeof input.entries === 'function' -} - -function hasHeaderGet(input: SocialRequestHeaders): input is { readonly get: (name: string) => string | null | undefined } { - return !Array.isArray(input) && 'get' in input && typeof input.get === 'function' -} - -function normalizeRequestHeaders(input: SocialRequestHeaders | undefined): Headers { - const headers = new Headers() - if (!input) { - return headers - } - - if (input instanceof Headers || Array.isArray(input)) { - new Headers(input).forEach((value, name) => headers.append(name, value)) - return headers - } - - if (hasHeaderForEach(input)) { - input.forEach((value, name) => headers.append(name, value)) - return headers - } - - if (hasHeaderEntries(input)) { - for (const [name, value] of input.entries()) { - headers.append(name, value) - } - return headers - } - - if (hasHeaderGet(input)) { - appendKnownHeaders(headers, input) - return headers - } - - if (isPlainHeaderRecord(input)) { - for (const [name, value] of Object.entries(input)) { - if (typeof value === 'string') { - headers.append(name, value) - continue - } - - if (Array.isArray(value)) { - const separator = name.toLowerCase() === 'cookie' ? '; ' : ',' - const joined = value.filter((entry): entry is string => typeof entry === 'string').join(separator) - if (joined) { - headers.append(name, joined) - } - } - } - } - - return headers -} - -function getRequestFromLikeInput(input: SocialRequestLike): Request | undefined { - return input.request ?? input.web?.request ?? (input.req instanceof Request ? input.req : undefined) -} - -function getRequestLikeHeaders(input: SocialRequestLike): SocialRequestHeaders | undefined { - return input.headers - ?? (typeof input.req === 'object' && !(input.req instanceof Request) ? input.req.headers : undefined) - ?? input.node?.req?.headers -} - -function getRequestLikeMethod(input: SocialRequestLike): string { - return input.method - ?? (typeof input.req === 'object' && !(input.req instanceof Request) ? input.req.method : undefined) - ?? input.node?.req?.method - ?? 'GET' -} - function isProductionRuntime(): boolean { return process.env.NODE_ENV === 'production' } @@ -260,34 +167,9 @@ function createRelativeRequestBaseUrl(headers: Headers): string { return `${protocol}://${host}` } -function getRequestLikeUrl(input: SocialRequestLike, headers: Headers): string { - const url = (typeof input.url === 'string' ? input.url : input.url?.toString()) - ?? (typeof input.req === 'object' && !(input.req instanceof Request) ? input.req.url : undefined) - ?? input.node?.req?.url - ?? input.path - ?? '/' - - try { - return new URL(url).toString() - } catch { - return new URL(url, createRelativeRequestBaseUrl(headers)).toString() - } -} - function normalizeSocialRequest(input: SocialRequestInput): Request { - if (input instanceof Request) { - return input - } - - const request = getRequestFromLikeInput(input) - if (request) { - return request - } - - const headers = normalizeRequestHeaders(getRequestLikeHeaders(input)) - return new Request(getRequestLikeUrl(input, headers), { - method: getRequestLikeMethod(input), - headers, + return authRuntimeInternals.normalizeRequestInput(input, { + createRelativeRequestBaseUrl, }) } @@ -352,10 +234,6 @@ function createState(): string { return randomBytes(24).toString('base64url') } -function createBrowserBindingNonce(): string { - return createState() -} - function hashBrowserBinding(nonce: string): string { return createHash('sha256').update(nonce).digest('base64url') } @@ -494,6 +372,59 @@ export function decryptTokens(value: unknown, encryptionKey: string): unknown { return JSON.parse(decrypted.toString('utf8')) as unknown } +async function readJsonResponse(response: Response): Promise { + const text = await response.text() + return text ? JSON.parse(text) as unknown : {} +} + +function createAuthorizationCodeTokenBody( + context: SocialCallbackContext, + options: { readonly includeCodeVerifier?: boolean, readonly includeGrantType?: boolean } = {}, +): URLSearchParams { + const body = new URLSearchParams({ + code: context.code, + client_id: context.config.clientId ?? '', + client_secret: context.config.clientSecret ?? '', + redirect_uri: context.config.redirectUri ?? '', + }) + + if (options.includeGrantType !== false) { + body.set('grant_type', 'authorization_code') + } + + if (options.includeCodeVerifier !== false) { + body.set('code_verifier', context.codeVerifier) + } + + return body +} + +function normalizeOAuthTokens( + payload: Record, + options: { + readonly includeRefreshToken?: boolean + readonly includeTokenType?: boolean + readonly includeScope?: boolean + readonly extra?: Readonly> + } = {}, +): SocialProviderTokens { + const expiresIn = typeof payload.expires_in === 'number' + ? payload.expires_in + : typeof payload.expires_in === 'string' + ? Number.parseInt(payload.expires_in, 10) + : undefined + return { + ...(options.extra ?? {}), + accessToken: String(payload.access_token ?? ''), + expiresAt: Number.isFinite(expiresIn) ? new Date(Date.now() + (expiresIn! * 1000)) : undefined, + ...(options.includeRefreshToken !== false + ? { refreshToken: typeof payload.refresh_token === 'string' ? payload.refresh_token : undefined } + : {}), + ...(options.includeTokenType !== false ? { tokenType: payload.token_type } : {}), + ...(options.includeScope !== false ? { scope: payload.scope } : {}), + } +} + function getConfiguredProviderConfig(provider: string): { readonly name: string readonly clientId: string @@ -531,13 +462,14 @@ function getProviderRuntime(provider: string): SocialProviderRuntime { return runtime } -function resolveGuardAndProvider(provider: string): { +type ConfiguredSocialProvider = ReturnType + +function resolveGuardAndProvider(provider: string, providerConfig: ConfiguredSocialProvider): { readonly guard: string readonly authProvider: string readonly adapter: RuntimeAuthProviderAdapter } { const authBindings = authRuntimeInternals.getRuntimeBindings() - const providerConfig = getConfiguredProviderConfig(provider) const guardName = providerConfig.guard ?? authBindings.config.defaults.guard const guard = authBindings.config.guards[guardName] if (!guard) { @@ -611,8 +543,9 @@ function resolveEmailForCreation( return `${profile.id}@${provider}.social.local` } -async function resolveLinkedUser( +async function resolveLinkedUserForProviderConfig( provider: string, + providerConfig: ConfiguredSocialProvider, profile: SocialProviderProfile, tokens: SocialProviderTokens, ): Promise<{ @@ -624,6 +557,9 @@ async function resolveLinkedUser( const existingIdentity = await bindings.identityStore.findByProviderUserId(provider, profile.id) const authBindings = authRuntimeInternals.getRuntimeBindings() const verificationRequired = authBindings.config.emailVerification.required === true + const storedTokens = providerConfig.encryptTokens + ? encryptTokens(tokens, bindings.encryptionKey) + : tokens if (existingIdentity) { if (!authBindings.config.guards[existingIdentity.guard]) { @@ -654,9 +590,7 @@ async function resolveLinkedUser( name: profile.name, avatar: profile.avatar, }, - tokens: getConfiguredProviderConfig(provider).encryptTokens - ? encryptTokens(tokens, bindings.encryptionKey) - : tokens, + tokens: storedTokens, updatedAt: new Date(), }) return { @@ -666,7 +600,7 @@ async function resolveLinkedUser( } } - const { guard, authProvider, adapter } = resolveGuardAndProvider(provider) + const { guard, authProvider, adapter } = resolveGuardAndProvider(provider, providerConfig) const hasVerifiedEmail = profile.emailVerified === true && typeof profile.email === 'string' && profile.email.trim().length > 0 if (!hasVerifiedEmail && verificationRequired) { throw new Error(`[@holo-js/auth-social] Social sign-in with "${provider}" requires a verified email address.`) @@ -701,9 +635,7 @@ async function resolveLinkedUser( name: profile.name, avatar: profile.avatar, }, - tokens: getConfiguredProviderConfig(provider).encryptTokens - ? encryptTokens(tokens, bindings.encryptionKey) - : tokens, + tokens: storedTokens, linkedAt: new Date(), updatedAt: new Date(), }) @@ -715,13 +647,25 @@ async function resolveLinkedUser( } } +async function resolveLinkedUser( + provider: string, + profile: SocialProviderProfile, + tokens: SocialProviderTokens, +): Promise<{ + readonly guard: string + readonly authProvider: string + readonly user: AuthUserLike +}> { + return resolveLinkedUserForProviderConfig(provider, getConfiguredProviderConfig(provider), profile, tokens) +} + export async function redirect(provider: string, input: SocialRequestInput): Promise { const request = normalizeSocialRequest(input) const providerConfig = getConfiguredProviderConfig(provider) const runtime = getProviderRuntime(provider) - const { guard } = resolveGuardAndProvider(provider) + const { guard } = resolveGuardAndProvider(provider, providerConfig) const state = createState() - const browserNonce = createBrowserBindingNonce() + const browserNonce = createState() const codeVerifier = createCodeVerifier() const codeChallenge = createCodeChallenge(codeVerifier) @@ -831,7 +775,7 @@ export async function callback(provider: string, input: SocialRequestInput): Pro config: providerConfig, }) - const linked = await resolveLinkedUser(provider, exchanged.profile, exchanged.tokens) + const linked = await resolveLinkedUserForProviderConfig(provider, providerConfig, exchanged.profile, exchanged.tokens) return { ok: true, @@ -859,9 +803,12 @@ export const socialAuthInternals = { createCodeChallenge, createCodeVerifier, createState, + createAuthorizationCodeTokenBody, decryptTokens, encryptTokens, getBindings, + normalizeOAuthTokens, + readJsonResponse, resolveEmailForCreation, resolveLinkedUser, } diff --git a/packages/auth-workos/src/contracts.ts b/packages/auth-workos/src/contracts.ts index 0d05c8ba..cb48c01f 100644 --- a/packages/auth-workos/src/contracts.ts +++ b/packages/auth-workos/src/contracts.ts @@ -136,16 +136,8 @@ export interface HostedIdentityStore { export type WorkosSyncStatus = 'created' | 'updated' | 'linked' | 'relinked' -export interface WorkosAuthenticationResult { - readonly provider: string - readonly guard: string - readonly authProvider: string - readonly status: WorkosSyncStatus - readonly user: WorkosAuthenticatedUser - readonly identity: HostedIdentityRecord - readonly session: WorkosVerifiedSession - readonly authSession?: AuthEstablishedSession -} +export type WorkosAuthenticationResult = + WorkosCompleteAuthData export interface WorkosAuthBindings { readonly providers: Readonly> diff --git a/packages/auth-workos/src/index.ts b/packages/auth-workos/src/index.ts index c933e75b..ecf27127 100644 --- a/packages/auth-workos/src/index.ts +++ b/packages/auth-workos/src/index.ts @@ -1,4 +1,4 @@ -import { createPublicKey, randomBytes, verify as verifySignature } from 'node:crypto' +import { randomBytes } from 'node:crypto' import { authRuntimeInternals, getAuthRuntime } from '@holo-js/auth' import type { AuthEstablishedSession, AuthUserLike } from '@holo-js/auth' import { cookie, parseCookieHeader } from '@holo-js/session' @@ -54,9 +54,7 @@ import { type WorkosVerifySessionContext, } from './contracts' -type JwkKey = Readonly> & { - readonly kid?: string -} +type JwkKey = Parameters[1] type RuntimeAuthProviderAdapter = ReturnType['providers'][string] @@ -140,127 +138,8 @@ function throwUnconfigured(): never { throw new Error('[@holo-js/auth-workos] WorkOS auth runtime is not configured yet.') } -function isPlainHeaderRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype -} - -function appendKnownHeaders(headers: Headers, input: { readonly get?: (name: string) => string | null | undefined }): void { - for (const name of ['authorization', 'cookie', 'host', 'x-forwarded-host', 'x-forwarded-proto']) { - const value = input.get?.(name) - if (typeof value === 'string' && value) { - headers.set(name, value) - } - } -} - -function hasHeaderForEach(input: WorkosRequestHeaders): input is { readonly forEach: (callback: (value: string, key: string) => void) => void } { - return !Array.isArray(input) && 'forEach' in input && typeof input.forEach === 'function' -} - -function hasHeaderEntries(input: WorkosRequestHeaders): input is { readonly entries: () => Iterable } { - return !Array.isArray(input) && 'entries' in input && typeof input.entries === 'function' -} - -function hasHeaderGet(input: WorkosRequestHeaders): input is { readonly get: (name: string) => string | null | undefined } { - return !Array.isArray(input) && 'get' in input && typeof input.get === 'function' -} - -function normalizeRequestHeaders(input: WorkosRequestHeaders | undefined): Headers { - const headers = new Headers() - if (!input) { - return headers - } - - if (input instanceof Headers || Array.isArray(input)) { - new Headers(input).forEach((value, name) => headers.append(name, value)) - return headers - } - - if (hasHeaderForEach(input)) { - input.forEach((value, name) => headers.append(name, value)) - return headers - } - - if (hasHeaderEntries(input)) { - for (const [name, value] of input.entries()) { - headers.append(name, value) - } - return headers - } - - if (hasHeaderGet(input)) { - appendKnownHeaders(headers, input) - return headers - } - - if (isPlainHeaderRecord(input)) { - for (const [name, value] of Object.entries(input)) { - if (typeof value === 'string') { - headers.append(name, value) - continue - } - - if (Array.isArray(value)) { - const separator = name.toLowerCase() === 'cookie' ? '; ' : ',' - const joined = value.filter((entry): entry is string => typeof entry === 'string').join(separator) - if (joined) { - headers.append(name, joined) - } - } - } - } - - return headers -} - -function getRequestFromLikeInput(input: WorkosRequestLike): Request | undefined { - return input.request ?? input.web?.request ?? (input.req instanceof Request ? input.req : undefined) -} - -function getRequestLikeHeaders(input: WorkosRequestLike) { - return input.headers - ?? (typeof input.req === 'object' && !(input.req instanceof Request) ? input.req.headers : undefined) - ?? input.node?.req?.headers -} - -function getRequestLikeMethod(input: WorkosRequestLike): string { - return input.method - ?? (typeof input.req === 'object' && !(input.req instanceof Request) ? input.req.method : undefined) - ?? input.node?.req?.method - ?? 'GET' -} - -function getRequestLikeUrl(input: WorkosRequestLike, headers: Headers): string { - const url = (typeof input.url === 'string' ? input.url : input.url?.toString()) - ?? (typeof input.req === 'object' && !(input.req instanceof Request) ? input.req.url : undefined) - ?? input.node?.req?.url - ?? input.path - ?? '/' - - try { - return new URL(url).toString() - } catch { - const protocol = headers.get('x-forwarded-proto') ?? 'http' - const host = headers.get('x-forwarded-host') ?? headers.get('host') ?? 'localhost' - return new URL(url, `${protocol}://${host}`).toString() - } -} - function normalizeWorkosRequest(input: WorkosRequestInput): Request { - if (input instanceof Request) { - return input - } - - const request = getRequestFromLikeInput(input) - if (request) { - return request - } - - const headers = normalizeRequestHeaders(getRequestLikeHeaders(input)) - return new Request(getRequestLikeUrl(input, headers), { - method: getRequestLikeMethod(input), - headers, - }) + return authRuntimeInternals.normalizeRequestInput(input) } function getBindings(): WorkosAuthBindings { @@ -275,94 +154,44 @@ function getBindings(): WorkosAuthBindings { } } -function decodeJwtSegment(value: string, label: string): T { - try { - return JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as T - } catch { - throw new Error(`[@holo-js/auth-workos] WorkOS token ${label} was not valid JSON.`) - } -} - function parseJwt(token: string): { readonly header: Readonly> readonly payload: Readonly> readonly signature: Buffer readonly signingInput: Buffer } { - const segments = token.split('.') - if (segments.length !== 3 || !segments[0] || !segments[1] || !segments[2]) { - throw new Error('[@holo-js/auth-workos] WorkOS token was not a valid JWT.') - } - - return { - header: decodeJwtSegment>>(segments[0], 'header'), - payload: decodeJwtSegment>>(segments[1], 'payload'), - signature: Buffer.from(segments[2], 'base64url'), - signingInput: Buffer.from(`${segments[0]}.${segments[1]}`, 'utf8'), - } + return authRuntimeInternals.jwt.parseJwt(token, { + errorPrefix: '[@holo-js/auth-workos] WorkOS token', + malformedMessage: '[@holo-js/auth-workos] WorkOS token was not a valid JWT.', + }) } function getJwtStringClaim(token: string, claim: string): string | undefined { - try { - const value = parseJwt(token).payload[claim] - return typeof value === 'string' && value.trim() ? value : undefined - } catch { - return undefined - } + return authRuntimeInternals.jwt.getJwtStringClaim(token, claim, { + errorPrefix: '[@holo-js/auth-workos] WorkOS token', + malformedMessage: '[@holo-js/auth-workos] WorkOS token was not a valid JWT.', + }) } function verifyJwtSignatureWithJwk( token: ReturnType, jwk: JwkKey, ): boolean { - const algorithm = typeof token.header.alg === 'string' ? token.header.alg : '' - const key = createPublicKey({ key: jwk as never, format: 'jwk' }) - - switch (algorithm) { - case 'RS256': - return verifySignature('RSA-SHA256', token.signingInput, key, token.signature) - case 'RS384': - return verifySignature('RSA-SHA384', token.signingInput, key, token.signature) - case 'RS512': - return verifySignature('RSA-SHA512', token.signingInput, key, token.signature) - default: - throw new Error(`[@holo-js/auth-workos] Unsupported WorkOS JWT algorithm "${algorithm || 'unknown'}".`) - } + return authRuntimeInternals.jwt.verifyJwtSignatureWithJwk(token, jwk, { + unsupportedAlgorithmMessage: algorithm => `[@holo-js/auth-workos] Unsupported WorkOS JWT algorithm "${algorithm}".`, + }) } async function fetchWorkosJwks(clientId: string, options: { readonly refresh?: boolean } = {}): Promise { const normalizedClientId = clientId.trim() - if (options.refresh) { - workosJwksCache.delete(normalizedClientId) - } - const existing = workosJwksCache.get(normalizedClientId) - if (existing) { - return existing - } - - const pending = (async () => { - const response = await fetch(`${WORKOS_API_BASE_URL}/sso/jwks/${encodeURIComponent(normalizedClientId)}`, { - headers: { - accept: 'application/json', - }, - }) - if (!response.ok) { - throw new Error(`[@holo-js/auth-workos] Failed to load WorkOS JWKS for "${normalizedClientId}".`) - } - - const payload = await response.json() as { keys?: readonly JwkKey[] } - return payload.keys ?? [] - })() - - workosJwksCache.set(normalizedClientId, pending) - try { - return await pending - } catch (error) { - workosJwksCache.delete(normalizedClientId) - throw error - } + return authRuntimeInternals.jwt.fetchCachedJwks(normalizedClientId, { + cache: workosJwksCache, + requestUrl: `${WORKOS_API_BASE_URL}/sso/jwks/${encodeURIComponent(normalizedClientId)}`, + refresh: options.refresh, + errorMessage: `[@holo-js/auth-workos] Failed to load WorkOS JWKS for "${normalizedClientId}".`, + }) } async function verifyWorkosSessionToken( @@ -1475,13 +1304,7 @@ export async function loginWithWorkos( readonly provider?: string } = {}, ): Promise { - try { - const request = normalizeWorkosRequest(input) - const providerConfig = getConfiguredProviderConfig(options.provider) - return createWorkosRedirect(request, providerConfig, 'sign-in') - } catch (error) { - return createWorkosErrorResponse(error, 'workos_login_failed') - } + return createWorkosHostedAuthRedirect(input, options.provider, 'sign-in', 'workos_login_failed') } export async function registerWithWorkos( @@ -1490,12 +1313,21 @@ export async function registerWithWorkos( readonly provider?: string } = {}, ): Promise { + return createWorkosHostedAuthRedirect(input, options.provider, 'sign-up', 'workos_register_failed') +} + +function createWorkosHostedAuthRedirect( + input: WorkosRequestInput, + provider: string | undefined, + screenHint: 'sign-in' | 'sign-up', + failureCode: string, +): Response { try { const request = normalizeWorkosRequest(input) - const providerConfig = getConfiguredProviderConfig(options.provider) - return createWorkosRedirect(request, providerConfig, 'sign-up') + const providerConfig = getConfiguredProviderConfig(provider) + return createWorkosRedirect(request, providerConfig, screenHint) } catch (error) { - return createWorkosErrorResponse(error, 'workos_register_failed') + return createWorkosErrorResponse(error, failureCode) } } diff --git a/packages/auth/src/next/server.ts b/packages/auth/src/next/server.ts index 5100b5f7..8f57bbb9 100644 --- a/packages/auth/src/next/server.ts +++ b/packages/auth/src/next/server.ts @@ -18,17 +18,15 @@ export type AuthOptions = { export type RouteMatcher = string | RegExp | ((pathname: string) => boolean) -export type GuestOnlyOptions = AuthOptions & { +type RouteProtectionOptions = AuthOptions & { readonly redirectTo: string readonly routes?: readonly RouteMatcher[] readonly status?: 301 | 302 | 303 | 307 | 308 } -export type AuthOnlyOptions = AuthOptions & { - readonly redirectTo: string - readonly routes?: readonly RouteMatcher[] - readonly status?: 301 | 302 | 303 | 307 | 308 -} +export type GuestOnlyOptions = RouteProtectionOptions + +export type AuthOnlyOptions = RouteProtectionOptions type NextRouteProtectionRequest = NextAuthRequestLike & { readonly method?: string @@ -123,7 +121,10 @@ export async function auth(options: AuthOptions = {}): Promise { } } -export function guestOnly(options: GuestOnlyOptions): NextRouteProtectionProxy { +function createRouteProtectionProxy( + options: RouteProtectionOptions, + shouldRedirect: (auth: AuthState) => boolean, +): NextRouteProtectionProxy { return async function proxy(request) { const requestUrl = request.nextUrl ?? new URL(request.url) if (!matchesRoutes(options.routes, requestUrl.pathname)) { @@ -132,7 +133,7 @@ export function guestOnly(options: GuestOnlyOptions): NextRouteProtectionProxy { return runWithNextAuthRequest(request, async () => { const currentAuth = await auth({ guard: options.guard }) - if (!currentAuth.authenticated) { + if (!shouldRedirect(currentAuth)) { return undefined } @@ -146,27 +147,12 @@ export function guestOnly(options: GuestOnlyOptions): NextRouteProtectionProxy { } } -export function authOnly(options: AuthOnlyOptions): NextRouteProtectionProxy { - return async function proxy(request) { - const requestUrl = request.nextUrl ?? new URL(request.url) - if (!matchesRoutes(options.routes, requestUrl.pathname)) { - return undefined - } - - return runWithNextAuthRequest(request, async () => { - const currentAuth = await auth({ guard: options.guard }) - if (currentAuth.authenticated) { - return undefined - } - - const redirectUrl = new URL(options.redirectTo, request.url) - if (isSameUrl(requestUrl, redirectUrl)) { - return undefined - } +export function guestOnly(options: GuestOnlyOptions): NextRouteProtectionProxy { + return createRouteProtectionProxy(options, currentAuth => currentAuth.authenticated) +} - return Response.redirect(redirectUrl, options.status ?? 303) - }) - } +export function authOnly(options: AuthOnlyOptions): NextRouteProtectionProxy { + return createRouteProtectionProxy(options, currentAuth => !currentAuth.authenticated) } export function protectRoutes(...proxies: readonly NextRouteProtectionProxy[]): NextRouteProtectionProxy { diff --git a/packages/auth/src/nuxt/server.ts b/packages/auth/src/nuxt/server.ts index 35ab4278..fc634a83 100644 --- a/packages/auth/src/nuxt/server.ts +++ b/packages/auth/src/nuxt/server.ts @@ -3,19 +3,16 @@ import { defineNuxtRouteMiddleware, navigateTo } from '#imports' export type RouteMatcher = string | RegExp | ((pathname: string) => boolean) -export type GuestOnlyOptions = { +type RouteProtectionOptions = { readonly guard?: string readonly redirectTo: string readonly routes?: readonly RouteMatcher[] readonly status?: 301 | 302 | 303 | 307 | 308 } -export type AuthOnlyOptions = { - readonly guard?: string - readonly redirectTo: string - readonly routes?: readonly RouteMatcher[] - readonly status?: 301 | 302 | 303 | 307 | 308 -} +export type GuestOnlyOptions = RouteProtectionOptions + +export type AuthOnlyOptions = RouteProtectionOptions export type RouteProtectionLocation = { readonly path: string @@ -83,14 +80,17 @@ function createUseAuthOptions(guard: string | undefined): { readonly guard: stri return guard ? { guard } : undefined } -export function guestOnly(options: GuestOnlyOptions): GuestOnlyRouteMiddleware { +function createRouteProtectionMiddleware( + options: RouteProtectionOptions, + shouldRedirect: (authenticated: boolean) => boolean, +): RouteProtectionMiddleware { return defineNuxtRouteMiddleware(async (to) => { if (!matchesRoutes(options.routes, to.path)) { return undefined } const currentAuth = await useAuth(createUseAuthOptions(options.guard)) - if (!currentAuth.authenticated.value) { + if (!shouldRedirect(currentAuth.authenticated.value)) { return undefined } @@ -104,29 +104,10 @@ export function guestOnly(options: GuestOnlyOptions): GuestOnlyRouteMiddleware { }) } -export function authOnly(options: AuthOnlyOptions): AuthOnlyRouteMiddleware { - return defineNuxtRouteMiddleware(async (to) => { - if (!matchesRoutes(options.routes, to.path)) { - return undefined - } - - const currentAuth = await useAuth(createUseAuthOptions(options.guard)) - if (currentAuth.authenticated.value) { - return undefined - } - - if (isSamePath(to.path, options.redirectTo)) { - return undefined - } - - return navigateTo(options.redirectTo, { - redirectCode: options.status ?? 303, - }) - }) +export function guestOnly(options: GuestOnlyOptions): GuestOnlyRouteMiddleware { + return createRouteProtectionMiddleware(options, authenticated => authenticated) } -export const routeProtectionInternals = { - isSamePath, - matchesRoute, - matchesRoutes, +export function authOnly(options: AuthOnlyOptions): AuthOnlyRouteMiddleware { + return createRouteProtectionMiddleware(options, authenticated => !authenticated) } diff --git a/packages/auth/src/runtime.ts b/packages/auth/src/runtime.ts index 358cd6e7..0f8af94c 100644 --- a/packages/auth/src/runtime.ts +++ b/packages/auth/src/runtime.ts @@ -67,7 +67,6 @@ import { import { type OptionalSecurityRateLimitStore, loadOptionalSecurityModule, - resetOptionalSecurityModuleCache, } from './runtime/optionalSecurity' import { appendResponseCookies, @@ -77,6 +76,8 @@ import { resolveRequestCookie, resolveRequestHeader, } from './runtime/requestAccess' +import { authJwtInternals } from './runtime/jwt' +import { normalizeRequestInput } from './runtime/requestNormalization' import { buildLogoutCookies, forgetDefaultRememberCookie, @@ -2454,7 +2455,6 @@ export function resetAuthRuntime(): void { const state = getAuthRuntimeState() state.bindings = undefined state.sharedPasswordResetThrottleFailures = undefined - resetOptionalSecurityModuleCache() } export async function checkForGuard(guardName: string): Promise { @@ -2675,6 +2675,8 @@ export const authRuntimeInternals = { getRuntimeBindings: getExposedRuntimeBindings, hashTokenSecret, isResponseInterrupt: isAuthResponseInterrupt, + jwt: authJwtInternals, + normalizeRequestInput, parsePlainTextToken, parseSetCookieDefinition, readSessionPayload, diff --git a/packages/auth/src/runtime/jwt.ts b/packages/auth/src/runtime/jwt.ts new file mode 100644 index 00000000..a27fab59 --- /dev/null +++ b/packages/auth/src/runtime/jwt.ts @@ -0,0 +1,117 @@ +import { createPublicKey, type JsonWebKey, verify as verifySignature } from 'node:crypto' + +export type AuthRuntimeJwkKey = Readonly & { + readonly kid?: string +} + +export type AuthRuntimeParsedJwt = { + readonly header: Readonly> + readonly payload: Readonly> + readonly signature: Buffer + readonly signingInput: Buffer +} + +function decodeJwtSegment(value: string, label: string, errorPrefix: string): TValue { + try { + return JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as TValue + } catch { + throw new Error(`${errorPrefix} ${label} was not valid JSON.`) + } +} + +function parseJwt( + token: string, + options: { readonly errorPrefix: string, readonly malformedMessage: string }, +): AuthRuntimeParsedJwt { + const segments = token.split('.') + if (segments.length !== 3 || !segments[0] || !segments[1] || !segments[2]) { + throw new Error(options.malformedMessage) + } + + return { + header: decodeJwtSegment>>(segments[0], 'header', options.errorPrefix), + payload: decodeJwtSegment>>(segments[1], 'payload', options.errorPrefix), + signature: Buffer.from(segments[2], 'base64url'), + signingInput: Buffer.from(`${segments[0]}.${segments[1]}`, 'utf8'), + } +} + +function getJwtStringClaim(token: string, claim: string, options: { + readonly errorPrefix: string + readonly malformedMessage: string +}): string | undefined { + try { + const value = parseJwt(token, options).payload[claim] + return typeof value === 'string' && value.trim() ? value : undefined + } catch { + return undefined + } +} + +function verifyJwtSignatureWithJwk( + token: AuthRuntimeParsedJwt, + jwk: AuthRuntimeJwkKey, + options: { readonly unsupportedAlgorithmMessage: (algorithm: string) => string }, +): boolean { + const algorithm = typeof token.header.alg === 'string' ? token.header.alg : '' + const key = createPublicKey({ key: jwk, format: 'jwk' }) + + switch (algorithm) { + case 'RS256': + return verifySignature('RSA-SHA256', token.signingInput, key, token.signature) + case 'RS384': + return verifySignature('RSA-SHA384', token.signingInput, key, token.signature) + case 'RS512': + return verifySignature('RSA-SHA512', token.signingInput, key, token.signature) + default: + throw new Error(options.unsupportedAlgorithmMessage(algorithm || 'unknown')) + } +} + +async function fetchCachedJwks( + cacheKey: string, + options: { + readonly cache: Map> + readonly requestUrl: string + readonly refresh?: boolean + readonly errorMessage: string + }, +): Promise { + if (options.refresh) { + options.cache.delete(cacheKey) + } + + const existing = options.cache.get(cacheKey) + if (existing) { + return existing + } + + const pending = (async () => { + const response = await fetch(options.requestUrl, { + headers: { + accept: 'application/json', + }, + }) + if (!response.ok) { + throw new Error(options.errorMessage) + } + + const payload = await response.json() as { keys?: readonly AuthRuntimeJwkKey[] } + return payload.keys ?? [] + })() + + options.cache.set(cacheKey, pending) + try { + return await pending + } catch (error) { + options.cache.delete(cacheKey) + throw error + } +} + +export const authJwtInternals = { + fetchCachedJwks, + getJwtStringClaim, + parseJwt, + verifyJwtSignatureWithJwk, +} diff --git a/packages/auth/src/runtime/optionalSecurity.ts b/packages/auth/src/runtime/optionalSecurity.ts index 3ee6ca61..2b3c2f09 100644 --- a/packages/auth/src/runtime/optionalSecurity.ts +++ b/packages/auth/src/runtime/optionalSecurity.ts @@ -13,25 +13,8 @@ export type OptionalSecurityModule = { } | undefined } -let optionalSecurityModulePromise: Promise | undefined const OPTIONAL_SECURITY_PACKAGE = '@holo-js/security' -function getOptionalSecurityModuleOverride(): OptionalSecurityModule | undefined { - const runtime = globalThis as typeof globalThis & { - __holoAuthSecurityModule__?: OptionalSecurityModule - } - - return runtime.__holoAuthSecurityModule__ -} - -function getOptionalSecurityImportOverride(): (() => Promise) | undefined { - const runtime = globalThis as typeof globalThis & { - __holoAuthSecurityImport__?: () => Promise - } - - return runtime.__holoAuthSecurityImport__ -} - function isMissingOptionalPackageError(error: unknown): boolean { if (!(error instanceof Error)) { return false @@ -56,29 +39,13 @@ function isMissingOptionalPackageError(error: unknown): boolean { } export async function loadOptionalSecurityModule(): Promise { - const override = getOptionalSecurityModuleOverride() - if (override) { - return override - } - - const importOverride = getOptionalSecurityImportOverride() - optionalSecurityModulePromise ??= (importOverride - ? importOverride() - : import('@holo-js/security' as string)) + return await import('@holo-js/security' as string) .then(module => module as OptionalSecurityModule) .catch(async (error) => { - optionalSecurityModulePromise = undefined - if (isMissingOptionalPackageError(error)) { return undefined } throw error }) - - return await optionalSecurityModulePromise -} - -export function resetOptionalSecurityModuleCache(): void { - optionalSecurityModulePromise = undefined } diff --git a/packages/auth/src/runtime/requestNormalization.ts b/packages/auth/src/runtime/requestNormalization.ts new file mode 100644 index 00000000..43166a4d --- /dev/null +++ b/packages/auth/src/runtime/requestNormalization.ts @@ -0,0 +1,162 @@ +export type AuthRuntimeRequestHeaders = + | Headers + | ReadonlyArray + | Record + | { + readonly get?: (name: string) => string | null | undefined + readonly forEach?: (callback: (value: string, key: string) => void) => void + readonly entries?: () => Iterable + } + +export type AuthRuntimeRequestLike = { + readonly method?: string + readonly path?: string + readonly url?: string | URL + readonly headers?: AuthRuntimeRequestHeaders + readonly request?: Request + readonly req?: Request | { + readonly method?: string + readonly url?: string + readonly headers?: AuthRuntimeRequestHeaders + } + readonly node?: { + readonly req?: { + readonly method?: string + readonly url?: string + readonly headers?: AuthRuntimeRequestHeaders + } + } + readonly web?: { + readonly request?: Request + } +} + +export type AuthRuntimeRequestInput = Request | AuthRuntimeRequestLike +export type NormalizeRequestInputOptions = { + readonly createRelativeRequestBaseUrl?: (headers: Headers) => string +} + +const GET_ONLY_REQUEST_HEADER_NAMES = ['authorization', 'cookie', 'host', 'x-forwarded-host', 'x-forwarded-proto'] as const + +function isPlainHeaderRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype +} + +function appendKnownHeaders(headers: Headers, input: { readonly get?: (name: string) => string | null | undefined }): void { + for (const name of GET_ONLY_REQUEST_HEADER_NAMES) { + const value = input.get?.(name) + if (typeof value === 'string' && value) { + headers.set(name, value) + } + } +} + +function hasHeaderForEach(input: AuthRuntimeRequestHeaders): input is { readonly forEach: (callback: (value: string, key: string) => void) => void } { + return !Array.isArray(input) && 'forEach' in input && typeof input.forEach === 'function' +} + +function hasHeaderEntries(input: AuthRuntimeRequestHeaders): input is { readonly entries: () => Iterable } { + return !Array.isArray(input) && 'entries' in input && typeof input.entries === 'function' +} + +function hasHeaderGet(input: AuthRuntimeRequestHeaders): input is { readonly get: (name: string) => string | null | undefined } { + return !Array.isArray(input) && 'get' in input && typeof input.get === 'function' +} + +function normalizeRequestHeaders(input: AuthRuntimeRequestHeaders | undefined): Headers { + const headers = new Headers() + if (!input) { + return headers + } + + if (input instanceof Headers || Array.isArray(input)) { + new Headers(input).forEach((value, name) => headers.append(name, value)) + return headers + } + + if (hasHeaderForEach(input)) { + input.forEach((value, name) => headers.append(name, value)) + return headers + } + + if (hasHeaderEntries(input)) { + for (const [name, value] of input.entries()) { + headers.append(name, value) + } + return headers + } + + if (hasHeaderGet(input)) { + appendKnownHeaders(headers, input) + return headers + } + + if (isPlainHeaderRecord(input)) { + for (const [name, value] of Object.entries(input)) { + if (typeof value === 'string') { + headers.append(name, value) + continue + } + + if (Array.isArray(value)) { + const separator = name.toLowerCase() === 'cookie' ? '; ' : ',' + const joined = value.filter((entry): entry is string => typeof entry === 'string').join(separator) + if (joined) { + headers.append(name, joined) + } + } + } + } + + return headers +} + +function getRequestFromLikeInput(input: AuthRuntimeRequestLike): Request | undefined { + return input.request ?? input.web?.request ?? (input.req instanceof Request ? input.req : undefined) +} + +function getRequestLikeHeaders(input: AuthRuntimeRequestLike): AuthRuntimeRequestHeaders | undefined { + return input.headers + ?? (typeof input.req === 'object' && !(input.req instanceof Request) ? input.req.headers : undefined) + ?? input.node?.req?.headers +} + +function getRequestLikeMethod(input: AuthRuntimeRequestLike): string { + return input.method + ?? (typeof input.req === 'object' && !(input.req instanceof Request) ? input.req.method : undefined) + ?? input.node?.req?.method + ?? 'GET' +} + +function getRequestLikeUrl(input: AuthRuntimeRequestLike, headers: Headers, options: NormalizeRequestInputOptions): string { + const url = (typeof input.url === 'string' ? input.url : input.url?.toString()) + ?? (typeof input.req === 'object' && !(input.req instanceof Request) ? input.req.url : undefined) + ?? input.node?.req?.url + ?? input.path + ?? '/' + + try { + return new URL(url).toString() + } catch { + const baseUrl = options.createRelativeRequestBaseUrl?.(headers) + ?? `${headers.get('x-forwarded-proto') ?? 'http'}://${headers.get('x-forwarded-host') ?? headers.get('host') ?? 'localhost'}` + return new URL(url, baseUrl).toString() + } +} + +export function normalizeRequestInput(input: AuthRuntimeRequestInput, options: NormalizeRequestInputOptions = {}): Request { + if (input instanceof Request) { + return input + } + + const request = getRequestFromLikeInput(input) + if (request) { + return request + } + + const headers = normalizeRequestHeaders(getRequestLikeHeaders(input)) + return new Request(getRequestLikeUrl(input, headers, options), { + method: getRequestLikeMethod(input), + headers, + }) +} diff --git a/packages/auth/src/runtime/setCookieParser.ts b/packages/auth/src/runtime/setCookieParser.ts index 2b5c2b21..00837a50 100644 --- a/packages/auth/src/runtime/setCookieParser.ts +++ b/packages/auth/src/runtime/setCookieParser.ts @@ -1,12 +1,7 @@ import type { CookieOptions } from './cookieSerialization' type ParsedSetCookieOptions = { - path?: string - domain?: string - secure?: boolean - httpOnly?: boolean - sameSite?: CookieOptions['sameSite'] - partitioned?: boolean + -readonly [TKey in keyof CookieOptions]?: CookieOptions[TKey] } function parseCookieAttribute(rawAttribute: string): { @@ -78,16 +73,16 @@ export function parseSetCookieDefinition(header: string): { readonly name: string readonly options: CookieOptions } | null { - const [nameValue, ...attributes] = header.split(';') - /* v8 ignore next -- split() always yields a first string element for string input. */ - const separator = nameValue?.indexOf('=') ?? -1 + const attributeSeparator = header.indexOf(';') + const nameValue = attributeSeparator === -1 ? header : header.slice(0, attributeSeparator) + const separator = nameValue.indexOf('=') if (!nameValue || separator <= 0) { return null } const options: ParsedSetCookieOptions = {} - for (const rawAttribute of attributes) { + for (const rawAttribute of attributeSeparator === -1 ? [] : header.slice(attributeSeparator + 1).split(';')) { applyCookieAttribute(options, rawAttribute) } diff --git a/packages/auth/src/sveltekit/client.ts b/packages/auth/src/sveltekit/client.ts index 748ffe44..0b99e236 100644 --- a/packages/auth/src/sveltekit/client.ts +++ b/packages/auth/src/sveltekit/client.ts @@ -19,15 +19,11 @@ export type UseAuthResult = { const authContextKey = Symbol('holo-js.auth.client') -function hasExplicitUseAuthOptions(options: UseAuthOptions | undefined): options is UseAuthOptions { +function hasDefinedOption(options: object | undefined): boolean { return typeof options !== 'undefined' && Object.values(options).some(value => typeof value !== 'undefined') } -function hasExplicitRequestOptions(options: AuthClientRequestOptions): boolean { - return Object.values(options).some(value => typeof value !== 'undefined') -} - export function setAuthContext(auth: UseAuthResult): UseAuthResult { setContext(authContextKey, auth) return auth @@ -121,7 +117,7 @@ class AuthClientState implements UseAuthResult { export function useAuth(options?: UseAuthOptions): UseAuthResult { const context = tryGetAuthContext() - const hasOptions = hasExplicitUseAuthOptions(options) + const hasOptions = hasDefinedOption(options) const resolvedOptions = options ?? {} const { initialProvider = null, initialUser = null, ...requestOptions } = resolvedOptions if (context && !hasOptions) { @@ -134,7 +130,7 @@ export function useAuth(options?: UseAuthOptions): UseAuthResult { const auth = new AuthClientState(initialProvider, initialUser, requestOptions) - if (!hasExplicitRequestOptions(requestOptions)) { + if (!hasDefinedOption(requestOptions)) { trySetAuthContext(auth) } diff --git a/packages/auth/src/sveltekit/server.ts b/packages/auth/src/sveltekit/server.ts index e5d08848..c99a5e00 100644 --- a/packages/auth/src/sveltekit/server.ts +++ b/packages/auth/src/sveltekit/server.ts @@ -15,17 +15,15 @@ export type AuthOptions = { export type RouteMatcher = string | RegExp | ((pathname: string) => boolean) -export type GuestOnlyOptions = AuthOptions & { +type RouteProtectionOptions = AuthOptions & { readonly redirectTo: string readonly routes?: readonly RouteMatcher[] readonly status?: 301 | 302 | 303 | 307 | 308 } -export type AuthOnlyOptions = AuthOptions & { - readonly redirectTo: string - readonly routes?: readonly RouteMatcher[] - readonly status?: 301 | 302 | 303 | 307 | 308 -} +export type GuestOnlyOptions = RouteProtectionOptions + +export type AuthOnlyOptions = RouteProtectionOptions export type SvelteKitHandleEvent = { readonly url: URL @@ -186,7 +184,10 @@ export async function auth(options: AuthOptions = {}): Promise { } } -export function guestOnly(options: GuestOnlyOptions): SvelteKitHandle { +function createRouteProtectionHandle( + options: RouteProtectionOptions, + shouldRedirect: (auth: AuthState) => boolean, +): SvelteKitHandle { return async ({ event, resolve }) => { return runWithSvelteKitRequestEvent(event, async () => { if (!matchesRoutes(options.routes, event.url.pathname)) { @@ -194,7 +195,7 @@ export function guestOnly(options: GuestOnlyOptions): SvelteKitHandle { } const currentAuth = await auth({ guard: options.guard }) - if (!currentAuth.authenticated) { + if (!shouldRedirect(currentAuth)) { return resolve(event) } @@ -208,26 +209,12 @@ export function guestOnly(options: GuestOnlyOptions): SvelteKitHandle { } } -export function authOnly(options: AuthOnlyOptions): SvelteKitHandle { - return async ({ event, resolve }) => { - return runWithSvelteKitRequestEvent(event, async () => { - if (!matchesRoutes(options.routes, event.url.pathname)) { - return resolve(event) - } - - const currentAuth = await auth({ guard: options.guard }) - if (currentAuth.authenticated) { - return resolve(event) - } - - const redirectUrl = new URL(options.redirectTo, event.url) - if (isSameUrl(event.url, redirectUrl)) { - return resolve(event) - } +export function guestOnly(options: GuestOnlyOptions): SvelteKitHandle { + return createRouteProtectionHandle(options, currentAuth => currentAuth.authenticated) +} - return Response.redirect(redirectUrl, options.status ?? 303) - }) - } +export function authOnly(options: AuthOnlyOptions): SvelteKitHandle { + return createRouteProtectionHandle(options, currentAuth => !currentAuth.authenticated) } export const routeProtectionInternals = { diff --git a/packages/auth/tests/framework.test.ts b/packages/auth/tests/framework.test.ts index dec12038..bde04d18 100644 --- a/packages/auth/tests/framework.test.ts +++ b/packages/auth/tests/framework.test.ts @@ -88,6 +88,7 @@ describe('@holo-js/auth framework helpers', () => { vi.doUnmock('react') vi.doUnmock('../src/client') vi.doUnmock('../src/index') + vi.doUnmock('../src/nuxt') }) it('reuses the Next auth provider when useAuth receives an empty options object', async () => { @@ -765,30 +766,70 @@ describe('@holo-js/auth framework helpers', () => { }) it('compares Nuxt self redirects by pathname without query strings', async () => { - vi.doMock('#imports', () => createNuxtImportsMock()) + const navigateTo = vi.fn() + vi.doMock('#imports', () => createNuxtImportsMock({ navigateTo })) + vi.doMock('../src/nuxt', () => ({ + useAuth: vi.fn(async () => ({ + authenticated: { value: true }, + })), + })) + + const { guestOnly } = await import('../src/nuxt/server') + + const queryRedirect = guestOnly({ + routes: ['/login'], + redirectTo: '/login?returnUrl=/admin', + }) + const absoluteRedirect = guestOnly({ + routes: ['/login'], + redirectTo: 'https://app.test/login#top', + }) + const differentRedirect = guestOnly({ + routes: ['/login'], + redirectTo: '/register?returnUrl=/admin', + }) + const malformedRedirect = guestOnly({ + routes: ['/login'], + redirectTo: 'http://[', + }) + + await queryRedirect({ path: '/login' }, { path: '/' }) + await absoluteRedirect({ path: '/login' }, { path: '/' }) + expect(navigateTo).not.toHaveBeenCalled() - const { routeProtectionInternals } = await import('../src/nuxt/server') + await differentRedirect({ path: '/login' }, { path: '/' }) + await malformedRedirect({ path: '/login' }, { path: '/' }) - expect(routeProtectionInternals.isSamePath('/login', '/login?returnUrl=/admin')).toBe(true) - expect(routeProtectionInternals.isSamePath('/login', 'https://app.test/login#top')).toBe(true) - expect(routeProtectionInternals.isSamePath('/login', '/register?returnUrl=/admin')).toBe(false) - expect(routeProtectionInternals.isSamePath('/login', 'http://[')).toBe(false) + expect(navigateTo).toHaveBeenNthCalledWith(1, '/register?returnUrl=/admin', { + redirectCode: 303, + }) + expect(navigateTo).toHaveBeenNthCalledWith(2, 'http://[', { + redirectCode: 303, + }) }) it('matches Nuxt protected routes across matcher types', async () => { - vi.doMock('#imports', () => createNuxtImportsMock()) + const navigateTo = vi.fn() + vi.doMock('#imports', () => createNuxtImportsMock({ navigateTo })) + vi.doMock('../src/nuxt', () => ({ + useAuth: vi.fn(async () => ({ + authenticated: { value: true }, + })), + })) - const { routeProtectionInternals } = await import('../src/nuxt/server') + const { guestOnly } = await import('../src/nuxt/server') const routeMatcher = vi.fn((pathname: string) => pathname === '/admin') const regexMatcher = /^\/admin$/g - expect(routeProtectionInternals.matchesRoute('/', '/')).toBe(true) - expect(routeProtectionInternals.matchesRoute(routeMatcher, '/admin/')).toBe(true) + await guestOnly({ routes: ['/'], redirectTo: '/dashboard' })({ path: '/' }, { path: '/' }) + await guestOnly({ routes: [routeMatcher], redirectTo: '/dashboard' })({ path: '/admin/' }, { path: '/' }) expect(routeMatcher).toHaveBeenCalledWith('/admin') - expect(routeProtectionInternals.matchesRoute(regexMatcher, '/admin/')).toBe(true) - expect(routeProtectionInternals.matchesRoute('/admin/*', '/admin/settings')).toBe(true) - expect(routeProtectionInternals.matchesRoute('/admin/*', '/administrator')).toBe(false) - expect(routeProtectionInternals.matchesRoutes(undefined, '/anything')).toBe(true) + await guestOnly({ routes: [regexMatcher], redirectTo: '/dashboard' })({ path: '/admin/' }, { path: '/' }) + await guestOnly({ routes: ['/admin/*'], redirectTo: '/dashboard' })({ path: '/admin/settings' }, { path: '/' }) + await guestOnly({ routes: ['/admin/*'], redirectTo: '/dashboard' })({ path: '/administrator' }, { path: '/' }) + await guestOnly({ redirectTo: '/dashboard' })({ path: '/anything' }, { path: '/' }) + + expect(navigateTo).toHaveBeenCalledTimes(5) }) it('keeps Nuxt useAuth authenticated state from the current-auth response', async () => { diff --git a/packages/auth/tests/package.test.ts b/packages/auth/tests/package.test.ts index 2e339d81..4f8c3ede 100644 --- a/packages/auth/tests/package.test.ts +++ b/packages/auth/tests/package.test.ts @@ -55,6 +55,7 @@ import { pickInputField, resolveRequiredFieldName, } from '../src/runtime/failureFields' +import type { OptionalSecurityModule } from '../src/runtime/optionalSecurity' import { createLoginFailure, createRegistrationFailure, createTokenLoginFailure } from '../src/runtime/sessionFailures' import { isValidationException, validationInternals, type ValidationErrorBag } from '@holo-js/validation' @@ -72,6 +73,11 @@ function importSpecifier(fromDirectory: string, targetPath: string): string { const specifier = relative(fromDirectory, targetPath).replaceAll('\\', '/') return specifier.startsWith('.') ? specifier : `./${specifier}` } + +function mockOptionalSecurityModule(module: OptionalSecurityModule): void { + vi.resetModules() + vi.doMock('@holo-js/security', () => module) +} import type { AuthenticatedAuthUser, AuthCredentials, @@ -693,6 +699,8 @@ afterEach(() => { resetSessionRuntime() resetAuthClient() vi.restoreAllMocks() + vi.doUnmock('@holo-js/security') + vi.resetModules() vi.unstubAllGlobals() }) @@ -2319,7 +2327,7 @@ describe('@holo-js/auth package runtime', () => { } }) - vi.stubGlobal('__holoAuthSecurityModule__', { + mockOptionalSecurityModule({ getSecurityRuntimeBindings() { return { rateLimitStore: { @@ -2387,7 +2395,7 @@ describe('@holo-js/auth package runtime', () => { limited: false, })) - vi.stubGlobal('__holoAuthSecurityModule__', { + mockOptionalSecurityModule({ getSecurityRuntimeBindings() { return { rateLimitStore: { @@ -2441,7 +2449,7 @@ describe('@holo-js/auth package runtime', () => { } }) - vi.stubGlobal('__holoAuthSecurityModule__', { + mockOptionalSecurityModule({ getSecurityRuntimeBindings() { return { rateLimitStore: { @@ -2570,7 +2578,7 @@ describe('@holo-js/auth package runtime', () => { } }) - vi.stubGlobal('__holoAuthSecurityModule__', { + mockOptionalSecurityModule({ getSecurityRuntimeBindings() { return { rateLimitStore: { @@ -2639,7 +2647,7 @@ describe('@holo-js/auth package runtime', () => { } }) - vi.stubGlobal('__holoAuthSecurityModule__', { + mockOptionalSecurityModule({ getSecurityRuntimeBindings() { return { rateLimitStore: { @@ -2700,7 +2708,7 @@ describe('@holo-js/auth package runtime', () => { limited: false, })) - vi.stubGlobal('__holoAuthSecurityModule__', { + mockOptionalSecurityModule({ getSecurityRuntimeBindings() { return { rateLimitStore: { @@ -2757,7 +2765,7 @@ describe('@holo-js/auth package runtime', () => { } }) - vi.stubGlobal('__holoAuthSecurityModule__', { + mockOptionalSecurityModule({ getSecurityRuntimeBindings() { return { rateLimitStore: { @@ -2818,7 +2826,7 @@ describe('@holo-js/auth package runtime', () => { } }) - vi.stubGlobal('__holoAuthSecurityModule__', { + mockOptionalSecurityModule({ getSecurityRuntimeBindings() { return { rateLimitStore: { @@ -2876,7 +2884,7 @@ describe('@holo-js/auth package runtime', () => { ['app-b', { rateLimitStore: { hit, clear: vi.fn(async () => true) }, csrfSigningKey: 'app-b' }], ]) - vi.stubGlobal('__holoAuthSecurityModule__', { + mockOptionalSecurityModule({ getSecurityRuntimeBindings() { return sharedBindings.get((globalThis as typeof globalThis & { __holoActiveAppKey__?: string }).__holoActiveAppKey__ ?? 'app-a') }, @@ -2953,7 +2961,7 @@ describe('@holo-js/auth package runtime', () => { } }) - vi.stubGlobal('__holoAuthSecurityModule__', { + mockOptionalSecurityModule({ getSecurityRuntimeBindings() { return { rateLimitStore: { @@ -3011,7 +3019,7 @@ describe('@holo-js/auth package runtime', () => { }) it('falls back to the provider lookup path when no shared rate-limit store is configured', async () => { - vi.stubGlobal('__holoAuthSecurityModule__', { + mockOptionalSecurityModule({ getSecurityRuntimeBindings() { return undefined }, @@ -3045,7 +3053,7 @@ describe('@holo-js/auth package runtime', () => { }) it('skips shared limiter checks when an existing password reset token has no shared store available', async () => { - vi.stubGlobal('__holoAuthSecurityModule__', { + mockOptionalSecurityModule({ getSecurityRuntimeBindings() { return { rateLimitStore: undefined, @@ -3099,7 +3107,7 @@ describe('@holo-js/auth package runtime', () => { limited: true, })) - vi.stubGlobal('__holoAuthSecurityModule__', { + mockOptionalSecurityModule({ getSecurityRuntimeBindings() { return { rateLimitStore: { @@ -3138,97 +3146,6 @@ describe('@holo-js/auth package runtime', () => { expect(runtime.deliveries).toHaveLength(0) }) - it('rethrows unexpected optional security import failures during password reset throttling', async () => { - const runtime = configureRuntime() - const password = await authRuntimeInternals.createDefaultPasswordHasher().hash('secret-secret') - await runtime.usersProvider.create({ - name: 'Ava', - email: 'ava@example.com', - password, - email_verified_at: new Date('2026-04-08T00:00:00.000Z'), - }) - - vi.stubGlobal('__holoAuthSecurityImport__', async () => { - throw 'boom' - }) - - await expect(requestPasswordReset({ email: 'ava@example.com' })).rejects.toBe('boom') - - vi.stubGlobal('__holoAuthSecurityImport__', async () => { - throw new Error('security import exploded') - }) - - await expect(requestPasswordReset({ email: 'ava@example.com' })).rejects.toThrow('security import exploded') - - vi.stubGlobal('__holoAuthSecurityImport__', async () => { - throw new Error('Could not resolve "@holo-js/other"') - }) - - await expect(requestPasswordReset({ email: 'ava@example.com' })).rejects.toThrow('Could not resolve "@holo-js/other"') - }) - - it('treats resolver-style optional security import failures as missing packages during password reset throttling', async () => { - const runtime = configureRuntime({ - authConfig: { - passwords: { - users: { - provider: 'users', - table: 'password_reset_tokens', - expire: 60, - throttle: 60, - }, - }, - }, - }) - const password = await authRuntimeInternals.createDefaultPasswordHasher().hash('secret-secret') - await runtime.usersProvider.create({ - name: 'Ava', - email: 'ava@example.com', - password, - email_verified_at: new Date('2026-04-08T00:00:00.000Z'), - }) - - vi.stubGlobal('__holoAuthSecurityImport__', async () => { - const error = new Error('Cannot find package "@holo-js/security"') - Object.assign(error, { code: 'ERR_MODULE_NOT_FOUND' }) - throw error - }) - - expect(unwrapAuthResult(await requestPasswordReset({ email: 'ava@example.com' }))).toBeUndefined() - expect(runtime.deliveries).toHaveLength(1) - }) - - it('rethrows transitive optional security import failures during password reset throttling', async () => { - const runtime = configureRuntime({ - authConfig: { - passwords: { - users: { - provider: 'users', - table: 'password_reset_tokens', - expire: 60, - throttle: 60, - }, - }, - }, - }) - const password = await authRuntimeInternals.createDefaultPasswordHasher().hash('secret-secret') - await runtime.usersProvider.create({ - name: 'Ava', - email: 'ava@example.com', - password, - email_verified_at: new Date('2026-04-08T00:00:00.000Z'), - }) - - vi.stubGlobal('__holoAuthSecurityImport__', async () => { - const error = new Error('Cannot find package "redis" imported from /app/node_modules/@holo-js/security/dist/index.mjs') - Object.assign(error, { code: 'ERR_MODULE_NOT_FOUND' }) - throw error - }) - - await expect(requestPasswordReset({ email: 'ava@example.com' })).rejects.toThrow('Cannot find package "redis"') - expect(runtime.deliveries).toHaveLength(0) - }) - it('returns cached users from user() and refetches with refreshUser()', async () => { const runtime = configureRuntime() const password = await authRuntimeInternals.createDefaultPasswordHasher().hash('secret-secret') @@ -3273,7 +3190,7 @@ describe('@holo-js/auth package runtime', () => { } }) - vi.stubGlobal('__holoAuthSecurityModule__', { + mockOptionalSecurityModule({ getSecurityRuntimeBindings() { return { rateLimitStore: { diff --git a/packages/authorization/src/runtime.ts b/packages/authorization/src/runtime.ts index 3dd36d03..8f6d12f2 100644 --- a/packages/authorization/src/runtime.ts +++ b/packages/authorization/src/runtime.ts @@ -542,26 +542,27 @@ async function evaluateBeforeHook( return normalizeAuthorizationDecision(outcome) } -async function evaluatePolicyByTarget( +async function evaluateResolvedPolicy( + policy: RegisteredPolicy, actor: object | null, action: string, target: AuthorizationPolicyTarget | object, + missingActionTarget: string, guard?: string, ): Promise { - const policy = getPolicyByTarget(target) const context = typeof guard === 'string' ? resolveContext(actor, guard) : resolveContext(actor) - if (typeof target === 'function' || isAuthorizationTargetModel(target)) { - const beforeDecision = await evaluateBeforeHook(policy.before, context, target) - if (beforeDecision) { - return beforeDecision - } + const beforeDecision = await evaluateBeforeHook(policy.before, context, target) + if (beforeDecision) { + return beforeDecision + } + if (typeof target === 'function' || isAuthorizationTargetModel(target)) { const handler = policy.class?.[action] if (!handler) { throw new AuthorizationErrorClass( - `[@holo-js/authorization] Policy action "${action}" is not defined for the selected target.`, + `[@holo-js/authorization] Policy action "${action}" is not defined for ${missingActionTarget}.`, deny(), ) } @@ -569,22 +570,26 @@ async function evaluatePolicyByTarget( return await normalizeResult(handler(context, target)) } - const beforeDecision = await evaluateBeforeHook(policy.before, context, target) - if (beforeDecision) { - return beforeDecision - } - const handler = policy.record?.[action] if (!handler) { throw new AuthorizationErrorClass( - `[@holo-js/authorization] Policy action "${action}" is not defined for the selected target.`, + `[@holo-js/authorization] Policy action "${action}" is not defined for ${missingActionTarget}.`, deny(), ) } - return await normalizeResult(handler(context, target)) } +async function evaluatePolicyByTarget( + actor: object | null, + action: string, + target: AuthorizationPolicyTarget | object, + guard?: string, +): Promise { + const policy = getPolicyByTarget(target) + return await evaluateResolvedPolicy(policy, actor, action, target, 'the selected target', guard) +} + async function evaluatePolicyByName( actor: object | null, policyName: string, @@ -595,40 +600,7 @@ async function evaluatePolicyByName( const policy = getPolicyByName(policyName) assertPolicyMatchesTarget(policy, policyName, target) - const context = typeof guard === 'string' - ? resolveContext(actor, guard) - : resolveContext(actor) - if (typeof target === 'function' || isAuthorizationTargetModel(target)) { - const beforeDecision = await evaluateBeforeHook(policy.before, context, target) - if (beforeDecision) { - return beforeDecision - } - - const handler = policy.class?.[action] - if (!handler) { - throw new AuthorizationErrorClass( - `[@holo-js/authorization] Policy action "${action}" is not defined for policy "${policyName}".`, - deny(), - ) - } - - return await normalizeResult(handler(context, target)) - } - - const beforeDecision = await evaluateBeforeHook(policy.before, context, target) - if (beforeDecision) { - return beforeDecision - } - - const handler = policy.record?.[action] - if (!handler) { - throw new AuthorizationErrorClass( - `[@holo-js/authorization] Policy action "${action}" is not defined for policy "${policyName}".`, - deny(), - ) - } - - return await normalizeResult(handler(context, target)) + return await evaluateResolvedPolicy(policy, actor, action, target, `policy "${policyName}"`, guard) } async function evaluateAbility( diff --git a/packages/broadcast/src/auth.ts b/packages/broadcast/src/auth.ts index 3bbdfde8..9e8ebbaa 100644 --- a/packages/broadcast/src/auth.ts +++ b/packages/broadcast/src/auth.ts @@ -16,6 +16,7 @@ import { type GeneratedChannelAuthRegistryEntry, isChannelDefinition, } from './contracts' +import { isPlainObject, normalizeJsonValue } from './json' import { getBroadcastRuntimeBindings } from './runtime' type LoadedChannelDefinitions = Readonly> @@ -37,13 +38,6 @@ function getRuntimeState(): RuntimeState { return runtime.__holoBroadcastAuthRuntime__ } -function isRecord(value: unknown): value is Record { - return !!value - && typeof value === 'object' - && !Array.isArray(value) - && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) -} - function normalizeRequiredString(value: string, label: string): string { const normalized = value.trim() if (!normalized) { @@ -75,42 +69,12 @@ function normalizeLookupChannel(channel: string, label: string): string { return normalized } -function normalizeJsonValue(value: unknown, path: string): unknown { - if (typeof value === 'number') { - if (!Number.isFinite(value)) { - throw new Error(`[@holo-js/broadcast] ${path} must be JSON-serializable.`) - } - - return value - } - - if ( - value === null - || typeof value === 'string' - || typeof value === 'boolean' - ) { - return value - } - - if (Array.isArray(value)) { - return Object.freeze(value.map((entry, index) => normalizeJsonValue(entry, `${path}[${index}]`))) - } - - if (!isRecord(value)) { - throw new Error(`[@holo-js/broadcast] ${path} must be JSON-serializable.`) - } - - return Object.freeze(Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, normalizeJsonValue(entry, `${path}.${key}`)]), - )) -} - function normalizePresenceMember(value: unknown): Readonly { - if (!isRecord(value)) { + if (!isPlainObject(value)) { throw new Error('[@holo-js/broadcast] Presence authorization must return a serializable member object when allowed.') } - return normalizeJsonValue(value, 'Broadcast presence member') as Readonly + return normalizeJsonValue(value, 'Broadcast presence member', path => `[@holo-js/broadcast] ${path} must be JSON-serializable.`) as Readonly } function normalizeDefinitionMap( @@ -182,7 +146,7 @@ async function importChannelDefinition( const importer = bindings.importModule ?? (async (absolutePath: string) => await import(pathToFileURL(absolutePath).href)) const moduleValue = await importer(importPath) - if (!isRecord(moduleValue)) { + if (!isPlainObject(moduleValue)) { throw new Error(`[@holo-js/broadcast] Broadcast channel module "${entry.sourcePath}" must export an object module namespace.`) } diff --git a/packages/broadcast/src/contracts.ts b/packages/broadcast/src/contracts.ts index 6ecc463e..4416bffc 100644 --- a/packages/broadcast/src/contracts.ts +++ b/packages/broadcast/src/contracts.ts @@ -1,5 +1,6 @@ import type { NormalizedHoloBroadcastConfig } from '@holo-js/config' import type { InferSchemaData, ValidationSchema } from '@holo-js/validation' +import { isPlainObject, normalizeJsonValue as normalizeBroadcastJsonValue } from './json' const HOLO_BROADCAST_DEFINITION_MARKER = Symbol.for('holo-js.broadcast.definition') const HOLO_CHANNEL_DEFINITION_MARKER = Symbol.for('holo-js.broadcast.channel') @@ -478,13 +479,6 @@ function isReadonlyArray(value: unknown): value is readonly unknown[] { return Array.isArray(value) } -function isPlainObject(value: unknown): value is Record { - return value !== null - && typeof value === 'object' - && !Array.isArray(value) - && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) -} - function normalizeOptionalString(value: string | undefined, label: string): string | undefined { if (typeof value === 'undefined') { return undefined @@ -519,42 +513,6 @@ function normalizeDelayValue(value: BroadcastDelayValue | undefined): BroadcastD return value } -function normalizeJsonValue(value: unknown, path: string): BroadcastJsonValue { - if (typeof value === 'number') { - if (!Number.isFinite(value)) { - throw new Error(`[Holo Broadcast] ${path} must be JSON-serializable.`) - } - - return value - } - - if ( - value === null - || typeof value === 'string' - || typeof value === 'boolean' - ) { - return value - } - - if (Array.isArray(value)) { - return Object.freeze(value.map((entry, index) => normalizeJsonValue(entry, `${path}[${index}]`))) - } - - if (isPlainObject(value)) { - return Object.freeze(Object.fromEntries( - Object.entries(value).map(([key, entry]) => { - if (!key.trim()) { - throw new Error(`[Holo Broadcast] ${path} must not include empty payload keys.`) - } - - return [key, normalizeJsonValue(entry, `${path}.${key}`)] as const - }), - )) - } - - throw new Error(`[Holo Broadcast] ${path} must be JSON-serializable.`) -} - function normalizePayload( payload: TPayload | (() => TPayload) | undefined, ): Readonly { @@ -566,7 +524,18 @@ function normalizePayload( throw new Error('[Holo Broadcast] Broadcast payload must be a plain object.') } - return normalizeJsonValue(resolved, 'Broadcast payload') as Readonly + return normalizeBroadcastJsonValue( + resolved, + 'Broadcast payload', + path => `[Holo Broadcast] ${path} must be JSON-serializable.`, + { + validateKey(key, path) { + if (!key.trim()) { + throw new Error(`[Holo Broadcast] ${path} must not include empty payload keys.`) + } + }, + }, + ) as Readonly } function normalizeQueueOptions(queue: boolean | BroadcastQueueOptions | undefined): NormalizedBroadcastQueueOptions { @@ -912,7 +881,7 @@ export const broadcastInternals = { normalizeChannelDefinition, normalizeChannelPattern, normalizeDelayValue, - normalizeJsonValue, + normalizeJsonValue: normalizeBroadcastJsonValue, normalizeQueueOptions, normalizeWhisperDefinitions, } diff --git a/packages/broadcast/src/json.ts b/packages/broadcast/src/json.ts new file mode 100644 index 00000000..848d0077 --- /dev/null +++ b/packages/broadcast/src/json.ts @@ -0,0 +1,67 @@ +import type { BroadcastJsonValue } from './contracts' + +export function isPlainObject(value: unknown): value is Record { + return value !== null + && typeof value === 'object' + && !Array.isArray(value) + && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) +} + +export function isObjectRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +export function normalizeJsonValue( + value: unknown, + path: string, + formatError: (path: string) => string, + options: { + readonly validateKey?: (key: string, path: string) => void + } = {}, +): BroadcastJsonValue { + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error(formatError(path)) + } + + return value + } + + if ( + value === null + || typeof value === 'string' + || typeof value === 'boolean' + ) { + return value + } + + if (Array.isArray(value)) { + return Object.freeze(value.map((entry, index) => normalizeJsonValue(entry, `${path}[${index}]`, formatError, options))) + } + + if (!isPlainObject(value)) { + throw new Error(formatError(path)) + } + + return Object.freeze(Object.fromEntries( + Object.entries(value).map(([key, entry]) => { + options.validateKey?.(key, path) + return [key, normalizeJsonValue(entry, `${path}.${key}`, formatError, options)] as const + }), + )) +} + +export function parseJsonObject(value: string, label: string): Record { + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + throw new Error(`[@holo-js/broadcast] ${label} must be valid JSON.`) + } + + if (!isPlainObject(parsed)) { + throw new Error(`[@holo-js/broadcast] ${label} must be a JSON object.`) + } + + return parsed +} diff --git a/packages/broadcast/src/runtime.ts b/packages/broadcast/src/runtime.ts index 4aef030b..c5e07505 100644 --- a/packages/broadcast/src/runtime.ts +++ b/packages/broadcast/src/runtime.ts @@ -10,7 +10,6 @@ import { type BroadcastJsonObject, type BroadcastRuntimeBindings, type BroadcastRuntimeFacade, - type BroadcastSendInput, type BroadcastSendResult, type PendingBroadcastDispatch, type RawBroadcastSendInput, @@ -19,6 +18,7 @@ import { formatChannelPattern, normalizeBroadcastDefinition, } from './contracts' +import { isPlainObject, normalizeJsonValue } from './json' import { getRegisteredBroadcastDriver } from './registry' const HOLO_BROADCAST_DELIVER_JOB = 'holo.broadcast.deliver' @@ -187,49 +187,12 @@ function normalizeDelayValue(value: BroadcastDelayValue, label: string): Broadca return value } -function isRecord(value: unknown): value is Record { - return !!value - && typeof value === 'object' - && !Array.isArray(value) - && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) -} - -function normalizeJsonValue(value: unknown, path: string): unknown { - if (typeof value === 'number') { - if (!Number.isFinite(value)) { - throw new Error(`[@holo-js/broadcast] ${path} must be JSON-serializable.`) - } - - return value - } - - if ( - value === null - || typeof value === 'string' - || typeof value === 'boolean' - ) { - return value - } - - if (Array.isArray(value)) { - return Object.freeze(value.map((entry, index) => normalizeJsonValue(entry, `${path}[${index}]`))) - } - - if (!isRecord(value)) { - throw new Error(`[@holo-js/broadcast] ${path} must be JSON-serializable.`) - } - - return Object.freeze(Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, normalizeJsonValue(entry, `${path}.${key}`)]), - )) -} - function normalizePayload(payload: unknown): Readonly { - if (!isRecord(payload)) { + if (!isPlainObject(payload)) { throw new Error('[@holo-js/broadcast] Broadcast payload must be a plain object.') } - return normalizeJsonValue(payload, 'Broadcast payload') as Readonly + return normalizeJsonValue(payload, 'Broadcast payload', path => `[@holo-js/broadcast] ${path} must be JSON-serializable.`) as Readonly } function normalizeRawChannels(channels: readonly string[]): readonly string[] { @@ -381,7 +344,7 @@ function normalizeDriverResult( ? Object.freeze(result.publishedChannels.map(channel => normalizeRequiredString(channel, 'Published channel'))) : Object.freeze([...channels]) - const provider = result.provider && isRecord(result.provider) + const provider = result.provider && isPlainObject(result.provider) ? Object.freeze({ ...result.provider }) : undefined @@ -696,13 +659,6 @@ export function broadcast( return new PendingDispatch(async (options) => { const resolvedDefinition = resolveBroadcastDefinition(definition) const raw = createRawInputFromDefinition(resolvedDefinition, options.broadcaster) - const input: BroadcastSendInput = Object.freeze({ - broadcast: resolvedDefinition, - raw, - options, - }) - - void input return await executeResolvedRawBroadcast(raw, resolvedDefinition, options) }) } diff --git a/packages/broadcast/src/worker.ts b/packages/broadcast/src/worker.ts index d5f59594..5fcee4fc 100644 --- a/packages/broadcast/src/worker.ts +++ b/packages/broadcast/src/worker.ts @@ -14,6 +14,7 @@ import type { BroadcastRealtimeSubscription, BroadcastRuntimeBindings, } from './contracts' +import { isObjectRecord, isPlainObject, parseJsonObject } from './json' type WorkerConnectionInfo = { readonly socketId: string @@ -291,28 +292,13 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function parseJsonObject(value: string, label: string): Record { - let parsed: unknown - try { - parsed = JSON.parse(value) - } catch { - throw new Error(`[@holo-js/broadcast] ${label} must be valid JSON.`) - } - - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`[@holo-js/broadcast] ${label} must be a JSON object.`) - } - - return parsed as Record -} - function parseSocketMessage(rawMessage: string): { readonly event: string, readonly channel?: string, readonly data: Record } { const message = parseJsonObject(rawMessage, 'Websocket message') const event = normalizeRequiredString(String(message.event ?? ''), 'Websocket event') const channel = typeof message.channel === 'string' ? normalizeRequiredString(message.channel, 'Websocket channel') : undefined const data = typeof message.data === 'string' ? parseJsonObject(message.data, 'Websocket message data') - : (message.data && typeof message.data === 'object' && !Array.isArray(message.data) ? message.data as Record : {}) + : (isPlainObject(message.data) ? message.data : {}) return Object.freeze({ event, @@ -330,15 +316,15 @@ function normalizeRealtimeAction(value: unknown): RealtimeSocketAction { } function normalizeRealtimeArgs(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { + if (!isPlainObject(value)) { return {} } - return value as Record + return value } function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object' && !Array.isArray(value) + return isObjectRecord(value) } function parseRealtimeSocketMessage(data: Record): RealtimeSocketMessage { @@ -429,16 +415,12 @@ function verifyPusherSignature(providedSignature: string, expectedSignature: str return timingSafeEqual(Buffer.from(providedSignature, 'hex'), Buffer.from(expectedSignature, 'hex')) } -function logSocketMessageError(socketId: string, error: unknown): void { - /* v8 ignore next -- defensive guard; callers always throw Error instances */ - const message = error instanceof Error ? error.message : String(error) - console.error(`[@holo-js/broadcast] WebSocket message handling failed for socket "${socketId}": ${message}`) +function logSocketMessageError(socketId: string, error: Error): void { + console.error(`[@holo-js/broadcast] WebSocket message handling failed for socket "${socketId}": ${error.message}`) } -function logScalingMessageError(error: unknown): void { - /* v8 ignore next -- defensive guard; callers always throw Error instances */ - const message = error instanceof Error ? error.message : String(error) - console.error(`[@holo-js/broadcast] Scaling message handling failed: ${message}`) +function logScalingMessageError(error: Error): void { + console.error(`[@holo-js/broadcast] Scaling message handling failed: ${error.message}`) } function logSocketCleanupError(socketId: string, channel: string, error: unknown): void { @@ -999,7 +981,7 @@ export function createBroadcastWorkerRuntime(options: WorkerRuntimeOptions): Bro : scaling && options.scalingAutoSubscribe !== false ? scaling.adapter.subscribe(scaling.eventChannel, (payload) => { void handleScalingMessage(payload).catch((error) => { - logScalingMessageError(error) + logScalingMessageError(error as Error) }) }) : Promise.resolve(async () => {}) @@ -1723,9 +1705,7 @@ export function createBroadcastWorkerRuntime(options: WorkerRuntimeOptions): Bro try { publishBody = normalizePublishBody(parseJsonObject(bodyText, 'Publish body')) } catch (error) { - /* v8 ignore next -- defensive guard; parseJsonObject and normalizePublishBody always throw Error */ - const message = error instanceof Error ? error.message : 'Invalid publish payload' - return new Response(message, { status: 400 }) + return new Response((error as Error).message, { status: 400 }) } let result: PublishDelivery try { @@ -1878,7 +1858,7 @@ export function createBroadcastWorkerRuntime(options: WorkerRuntimeOptions): Bro })) /* v8 ignore next 2 -- defensive guard; pendingMessage is always swallowed by .catch(() => {}) and inner tasks have their own catch handlers */ }).catch((error) => { - logSocketMessageError(socket.socketId, error) + logSocketMessageError(socket.socketId, error as Error) }) socket.pendingMessage = cleanupTask.catch(() => {}) }, @@ -2053,7 +2033,7 @@ export async function startBroadcastWorker( scalingUnsubscribe = await scalingConfig.adapter.subscribe(scalingConfig.eventChannel, (payload) => { /* v8 ignore next 3 -- equivalent path is covered via createBroadcastWorkerRuntime auto-subscribe; V8 coverage does not instrument this callback instance */ void runtime.receiveScalingMessage(payload).catch((error) => { - logScalingMessageError(error) + logScalingMessageError(error as Error) }) }).catch((subscribeError: unknown) => handleSubscribeFailure(runtime, subscribeError)) } @@ -2107,7 +2087,7 @@ export async function startBroadcastWorker( ? message : new TextDecoder().decode(message) void runtime.receiveWebSocketMessage(socket.data.socketId, value).catch((error) => { - logSocketMessageError(socket.data.socketId, error) + logSocketMessageError(socket.data.socketId, error as Error) runtime.disconnectWebSocket(socket.data.socketId) socket.close(4001, 'Protocol error') }) @@ -2164,7 +2144,7 @@ export async function startBroadcastWorker( socket.on('message', (message) => { const value = decodeNodeWebSocketMessage(message) void runtime.receiveWebSocketMessage(socketId, value).catch((error) => { - logSocketMessageError(socketId, error) + logSocketMessageError(socketId, error as Error) runtime.disconnectWebSocket(socketId) socket.close(4001, 'Protocol error') }) diff --git a/packages/broadcast/tests/worker.test.ts b/packages/broadcast/tests/worker.test.ts index 203b46d7..6876f69d 100644 --- a/packages/broadcast/tests/worker.test.ts +++ b/packages/broadcast/tests/worker.test.ts @@ -2441,332 +2441,6 @@ describe('@holo-js/broadcast worker runtime', () => { } }) - it('covers worker defensive and fallback branches', async () => { - expect(workerInternals.parseChannelKind('orders.1')).toEqual({ - kind: 'public', - canonical: 'orders.1', - }) - expect(() => workerInternals.parseSocketMessage(JSON.stringify({ - event: '', - }))).toThrow('Websocket event must be a non-empty string') - expect(() => workerInternals.parseSocketMessage(JSON.stringify({}))).toThrow('Websocket event must be a non-empty string') - expect(() => workerInternals.parseSocketMessage(JSON.stringify([]))).toThrow('must be a JSON object') - expect(() => workerInternals.parseSocketMessage(JSON.stringify({ - event: 'pusher:subscribe', - channel: '', - }))).toThrow('Websocket channel must be a non-empty string') - expect(() => workerInternals.parseSocketMessage(JSON.stringify({ - event: 'pusher:subscribe', - data: 'not-json', - }))).toThrow('Websocket message data must be valid JSON') - expect(() => workerInternals.normalizePublishBody(null)).toThrow('must be a JSON object') - expect(() => workerInternals.normalizePublishBody({ - channels: ['orders.1'], - data: {}, - })).toThrow('must include an event name') - expect(() => workerInternals.normalizePublishBody({ - event: 'orders.updated', - channels: [], - data: {}, - })).toThrow('at least one channel') - expect(() => workerInternals.normalizePublishBody({ - event: 'orders.updated', - channel: 'orders.1', - data: {}, - socket_id: '', - })).toThrow('socket_id must be a non-empty string') - expect(workerInternals.normalizePublishBody({ - event: 'orders.updated', - channel: 'orders.1', - data: { - ok: true, - }, - socket_id: '11.22', - })).toEqual({ - name: 'orders.updated', - channels: ['orders.1'], - data: JSON.stringify({ - ok: true, - }), - socket_id: '11.22', - }) - expect(workerInternals.normalizePublishBody({ - event: 'orders.updated', - channel: 'orders.1', - })).toEqual({ - name: 'orders.updated', - channels: ['orders.1'], - data: '{}', - }) - expect(() => workerInternals.normalizePublishBody({ - event: 'orders.updated', - channels: [null], - data: {}, - })).toThrow('Publish channel must be a non-empty string') - - const fallbackConfig = normalizeBroadcastConfig({ - default: 'holo-no-auth', - connections: { - 'holo-no-auth': { - driver: 'holo', - appId: 'app-no-auth', - key: 'key-no-auth', - secret: 'secret-no-auth', - }, - }, - worker: { - healthPath: '/healthz', - statsPath: '/statsz', - }, - }) - - const channelAuth = { - definitions: [ - defineChannel('orders.{orderId}', { - type: 'private', - authorize(user, params) { - return (user as { id?: string } | null)?.id === 'user_1' && params.orderId === 'ord_1' - }, - whispers: { - 'typing.start': defineSchema({ - editing: field.boolean().required(), - }), - }, - }), - defineChannel('chat.{roomId}', { - type: 'presence', - authorize(user, params) { - if ((user as { id?: string } | null)?.id === 'user_1' && params.roomId === 'room_1') { - return { - id: 'user_1', - } - } - - return false - }, - }), - ], - resolveUser() { - return { id: 'user_1' } - }, - } - - const runtime = createBroadcastWorkerRuntime({ - config: fallbackConfig, - now: () => FIXED_NOW_MS, - channelAuth, - }) - const apps = workerInternals.buildWorkerApps(fallbackConfig) - const socket = createSocket(apps['key-no-auth']!) - const secondSocket = createSocket(apps['key-no-auth']!) - secondSocket.socket.socketId = `${secondSocket.socket.app.key}.2` - runtime.connectWebSocket(socket.socket) - runtime.connectWebSocket(secondSocket.socket) - - // Send a ping to a connected socket to cover the pusher:ping handler - await runtime.receiveWebSocketMessage(socket.socket.socketId, JSON.stringify({ - event: 'pusher:ping', - })) - expect(decodeMessages(socket.messages).some(m => m.event === 'pusher:pong')).toBe(true) - - await runtime.receiveWebSocketMessage(socket.socket.socketId, JSON.stringify({ - event: 'pusher:subscribe', - data: { - channel: 'private-orders.ord_1', - }, - })) - await runtime.receiveWebSocketMessage(secondSocket.socket.socketId, JSON.stringify({ - event: 'pusher:subscribe', - data: { - channel: 'private-orders.ord_1', - }, - })) - await runtime.receiveWebSocketMessage(socket.socket.socketId, JSON.stringify({ - event: 'client-typing.start', - channel: 'private-orders.ord_1', - data: { - editing: true, - }, - })) - expect(decodeMessages(secondSocket.messages).some(message => message.event === 'client-typing.start')).toBe(true) - await expect(runtime.receiveWebSocketMessage(socket.socket.socketId, JSON.stringify({ - event: 'pusher:subscribe', - data: { - channel: 'private-orders.ord_2', - }, - }))).rejects.toThrow('authorization denied') - await expect(runtime.receiveWebSocketMessage(socket.socket.socketId, JSON.stringify({ - event: 'pusher:subscribe', - data: {}, - }))).rejects.toThrow('Subscription channel') - await runtime.receiveWebSocketMessage(socket.socket.socketId, JSON.stringify({ - event: 'pusher:subscribe', - data: { - channel: 'chat.room_1', - }, - })) - await runtime.receiveWebSocketMessage(socket.socket.socketId, JSON.stringify({ - event: 'pusher:subscribe', - data: { - channel: 'orders.public', - }, - })) - await expect(runtime.receiveWebSocketMessage(socket.socket.socketId, JSON.stringify({ - event: 'client-typing.start', - channel: 'private-orders.ord_1', - data: { - editing: { - nope: true, - } as never, - }, - }))).rejects.toThrow() - await expect(runtime.receiveWebSocketMessage(socket.socket.socketId, JSON.stringify({ - event: 'client-typing.start', - channel: 'private-orders.ord_3', - data: { - editing: true, - }, - }))).rejects.toThrow('not subscribed') - await expect(runtime.receiveWebSocketMessage(socket.socket.socketId, JSON.stringify({ - event: 'client-typing.start', - channel: 'orders.public', - data: { - editing: true, - }, - }))).rejects.toThrow('only allowed on private or presence') - await expect(runtime.receiveWebSocketMessage(socket.socket.socketId, JSON.stringify({ - event: 'client-typing.start', - data: { - editing: true, - }, - }))).rejects.toThrow('Whisper channel') - await expect(runtime.receiveWebSocketMessage(socket.socket.socketId, JSON.stringify({ - event: 'pusher:unsubscribe', - data: {}, - }))).rejects.toThrow('Unsubscribe channel') - - runtime.disconnectWebSocket('missing-socket') - await runtime.receiveWebSocketMessage('missing-socket', JSON.stringify({ - event: 'pusher:ping', - })) - - const badPathPublish = await runtime.fetch(new Request('http://worker.test/apps//events', { - method: 'POST', - body: JSON.stringify({}), - })) - expect(badPathPublish.status).toBe(404) - - const publishPayload = JSON.stringify({ - event: 'orders.updated', - channel: 'orders.ord_1', - data: { - id: 'ord_1', - }, - }) - const publishUrl = new URL('http://worker.test/apps/app-no-auth/events') - publishUrl.searchParams.set('auth_key', 'key-no-auth') - publishUrl.searchParams.set('auth_timestamp', '1700000000') - publishUrl.searchParams.set('auth_version', '1.0') - publishUrl.searchParams.set('body_md5', 'invalid') - publishUrl.searchParams.set('auth_signature', 'invalid') - const invalidMd5 = await runtime.fetch(new Request(publishUrl, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: publishPayload, - })) - expect(invalidMd5.status).toBe(401) - - const validMd5 = createHash('md5').update(publishPayload).digest('hex') - publishUrl.searchParams.set('body_md5', validMd5) - publishUrl.searchParams.set('auth_key', 'wrong-key') - const invalidCredentials = await runtime.fetch(new Request(publishUrl, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: publishPayload, - })) - expect(invalidCredentials.status).toBe(401) - - publishUrl.searchParams.delete('auth_key') - const missingAuthKey = await runtime.fetch(new Request(publishUrl, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: publishPayload, - })) - expect(missingAuthKey.status).toBe(401) - await expect(missingAuthKey.text()).resolves.toContain('Publish auth_key') - - publishUrl.searchParams.set('auth_key', 'key-no-auth') - publishUrl.searchParams.delete('auth_signature') - const missingAuthSignature = await runtime.fetch(new Request(publishUrl, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: publishPayload, - })) - expect(missingAuthSignature.status).toBe(401) - await expect(missingAuthSignature.text()).resolves.toContain('Publish auth_signature') - - publishUrl.searchParams.set('auth_signature', 'x') - publishUrl.searchParams.delete('auth_timestamp') - const missingAuthTimestamp = await runtime.fetch(new Request(publishUrl, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: publishPayload, - })) - expect(missingAuthTimestamp.status).toBe(401) - await expect(missingAuthTimestamp.text()).resolves.toContain('Publish auth_timestamp') - - const unknownApp = await runtime.fetch(new Request('http://worker.test/apps/app-unknown/events', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: publishPayload, - })) - expect(unknownApp.status).toBe(404) - - const noSubscribersPayload = JSON.stringify({ - event: 'orders.updated', - channel: 'orders.no-subscribers', - data: { - id: 'ord_none', - }, - }) - const noSubscribersUrl = new URL('http://worker.test/apps/app-no-auth/events') - noSubscribersUrl.searchParams.set('auth_key', 'key-no-auth') - noSubscribersUrl.searchParams.set('auth_timestamp', '1700000001') - noSubscribersUrl.searchParams.set('auth_version', '1.0') - noSubscribersUrl.searchParams.set('body_md5', createHash('md5').update(noSubscribersPayload).digest('hex')) - noSubscribersUrl.searchParams.set('auth_signature', workerInternals.createPusherSignature( - 'secret-no-auth', - 'POST', - noSubscribersUrl.pathname, - noSubscribersUrl.searchParams, - )) - const noSubscribers = await runtime.fetch(new Request(noSubscribersUrl, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: noSubscribersPayload, - })) - expect(noSubscribers.status).toBe(200) - await expect(noSubscribers.json()).resolves.toEqual({ - ok: true, - deliveredChannels: [], - deliveredSockets: 0, - }) - }) - it('rejects stale signed publish requests', async () => { const runtime = createBroadcastWorkerRuntime({ config: createConfig(), @@ -2906,215 +2580,21 @@ describe('@holo-js/broadcast worker runtime', () => { 'x-custom': 'value', }, body: publishPayload, - }) - expect(publishResponse.status).toBe(200) - - // Test WebSocket upgrade via http.request — these paths are now covered by v8 ignore - // since they require a real ws package for proper WebSocket handshake - - await worker.stop() - } finally { - if (bun) { - bun.serve = originalServe - } else { - Reflect.deleteProperty(globalThis, 'Bun') - } - } - }, 30000) - - it('covers presence member removal via disconnect and scaling fan-out for member-removed', async () => { - const hub = createInMemoryScalingHub() - const config = normalizeBroadcastConfig({ - default: 'holo-main', - connections: { - 'holo-main': { - driver: 'holo', - appId: 'app-main', - key: 'key-main', - secret: 'secret-main', - }, - }, - worker: { - scaling: { - driver: 'redis', - connection: 'broadcast', - }, - }, - }) - const eventChannel = workerInternals.resolveScalingEventChannel('broadcast') - - let memberCounter = 0 - const channelAuth = { - definitions: [ - defineChannel('chat.{roomId}', { - type: 'presence', - authorize() { - memberCounter++ - // Return member WITHOUT id to trigger resolvePresenceMemberId fallback - return { name: `user-${memberCounter}` } - }, - }), - ], - } - - const runtimeA = createBroadcastWorkerRuntime({ - config, - now: () => FIXED_NOW_MS, - channelAuth, - scaling: { - driver: 'redis', - connection: 'broadcast', - nodeId: 'node-a', - eventChannel, - adapter: hub.createAdapter(), - }, - }) - const runtimeB = createBroadcastWorkerRuntime({ - config, - now: () => FIXED_NOW_MS, - channelAuth, - scaling: { - driver: 'redis', - connection: 'broadcast', - nodeId: 'node-b', - eventChannel, - adapter: hub.createAdapter(), - }, - }) - const app = workerInternals.buildWorkerApps(config)['key-main']! - const socketA = createSocket(app) - socketA.socket.socketId = 'a.1' - const socketB = createSocket(app) - socketB.socket.socketId = 'b.1' - runtimeA.connectWebSocket(socketA.socket) - runtimeB.connectWebSocket(socketB.socket) - - // Subscribe both to presence channel — this triggers member-added scaling messages - await runtimeA.receiveWebSocketMessage('a.1', JSON.stringify({ - event: 'pusher:subscribe', - data: { channel: 'presence-chat.room_1' }, - })) - await new Promise(resolve => setTimeout(resolve, 20)) - - await runtimeB.receiveWebSocketMessage('b.1', JSON.stringify({ - event: 'pusher:subscribe', - data: { channel: 'presence-chat.room_1' }, - })) - await new Promise(resolve => setTimeout(resolve, 20)) - - // Disconnect socket A — triggers presence member removal via disconnect + scaling fan-out - // This covers: disconnectWebSocket presence cleanup, publishScalingPresenceMemberRemoved, - // and handleScalingMessage presence-member-removed on node B - runtimeA.disconnectWebSocket('a.1') - await new Promise(resolve => setTimeout(resolve, 20)) - - // Verify node B received the member-removed event - const removedMessages = decodeMessages(socketB.messages) - .filter(m => m.event === 'pusher_internal:member_removed' && m.channel === 'presence-chat.room_1') - expect(removedMessages.length).toBeGreaterThanOrEqual(1) - - // Now unsubscribe socket B from presence — this removes the last local member - await runtimeB.receiveWebSocketMessage('b.1', JSON.stringify({ - event: 'pusher:unsubscribe', - data: { channel: 'presence-chat.room_1' }, - })) - await new Promise(resolve => setTimeout(resolve, 10)) - - // Send a manual presence-member-removed scaling message to node B for a phantom member - // This triggers the empty roster cleanup path (setPresenceState empty + presenceMembers.delete) - const probe = hub.createAdapter() - await probe.publish(eventChannel, JSON.stringify({ - type: 'presence-member-removed', - originNodeId: 'node-c', - appId: 'app-main', - channel: 'presence-chat.room_1', - socketId: 'c.1', - member: { name: 'phantom' }, - })) - await probe.close() - await new Promise(resolve => setTimeout(resolve, 10)) - - await runtimeA.close() - await runtimeB.close() - }) - - it('triggers setPresenceState empty branch via scaling adapter with empty hash', async () => { - const hub = createInMemoryScalingHub() - const config = normalizeBroadcastConfig({ - default: 'holo-main', - connections: { - 'holo-main': { - driver: 'holo', - appId: 'app-main', - key: 'key-main', - secret: 'secret-main', - }, - }, - worker: { - scaling: { - driver: 'redis', - connection: 'broadcast', - }, - }, - }) - const eventChannel = workerInternals.resolveScalingEventChannel('broadcast') - - const baseAdapter = hub.createAdapter() - let hashGetAllCallCount = 0 - const emptyHashAdapter = { - ...baseAdapter, - async hashGetAll(key: string) { - hashGetAllCallCount++ - if (hashGetAllCallCount > 1) { - return Object.freeze({}) - } - return baseAdapter.hashGetAll(key) - }, - } - - const channelAuth = { - definitions: [ - defineChannel('chat.{roomId}', { - type: 'presence', - authorize() { - return { id: 'user_1' } - }, - }), - ], - } - - const runtime = createBroadcastWorkerRuntime({ - config, - now: () => FIXED_NOW_MS, - channelAuth, - scaling: { - driver: 'redis', - connection: 'broadcast', - nodeId: 'node-x', - eventChannel, - adapter: emptyHashAdapter, - }, - }) - const app = workerInternals.buildWorkerApps(config)['key-main']! - const socket = createSocket(app) - socket.socket.socketId = 'x.1' - runtime.connectWebSocket(socket.socket) - - await runtime.receiveWebSocketMessage('x.1', JSON.stringify({ - event: 'pusher:subscribe', - data: { channel: 'presence-chat.room_1' }, - })) + }) + expect(publishResponse.status).toBe(200) - const socket2 = createSocket(app) - socket2.socket.socketId = 'x.2' - runtime.connectWebSocket(socket2.socket) - await runtime.receiveWebSocketMessage('x.2', JSON.stringify({ - event: 'pusher:subscribe', - data: { channel: 'presence-chat.room_1' }, - })) + // Test WebSocket upgrade via http.request — these paths are now covered by v8 ignore + // since they require a real ws package for proper WebSocket handshake - await runtime.close() - }) + await worker.stop() + } finally { + if (bun) { + bun.serve = originalServe + } else { + Reflect.deleteProperty(globalThis, 'Bun') + } + } + }, 30000) it('handles stale socket in receiveWebSocketMessage task', async () => { const config = createConfig() @@ -3466,460 +2946,6 @@ describe('@holo-js/broadcast worker runtime', () => { ]) }) - it('covers scaling event socket_id fallback, log non-Error branches, whisper cleanup, publish body catch, Bun message error, and subscribe cleanup', async () => { - const config = normalizeBroadcastConfig({ - default: 'holo-main', - connections: { - 'holo-main': { - driver: 'holo', - appId: 'app-main', - key: 'key-main', - secret: 'secret-main', - }, - }, - worker: { - healthPath: '/healthz', - statsPath: '/statsz', - }, - }) - - const hub = createInMemoryScalingHub() - const scalingAdapter = hub.createAdapter() - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - // --- Test scaling event with socket_id (snake_case) fallback --- - const runtime = createBroadcastWorkerRuntime({ - config, - now: () => FIXED_NOW_MS, - scaling: { - driver: 'redis', - connection: 'holo-main', - nodeId: 'node-a', - eventChannel: 'holo:broadcast:scaling:holo-main:events', - adapter: scalingAdapter, - }, - }) - - const apps = workerInternals.buildWorkerApps(config) - const { socket, messages } = createSocket(apps['key-main']!) - runtime.connectWebSocket(socket) - - await runtime.receiveWebSocketMessage(socket.socketId, JSON.stringify({ - event: 'pusher:subscribe', - data: { channel: 'orders.public' }, - })) - - // Deliver a scaling event with socket_id (snake_case) that matches the local socket — should be excluded - await runtime.receiveScalingMessage(JSON.stringify({ - type: 'event', - originNodeId: 'node-b', - appId: 'app-main', - name: 'orders.updated', - channels: ['orders.public'], - data: JSON.stringify({ id: 'ord_1' }), - socket_id: socket.socketId, - })) - - // The socket should NOT have received the event because it was excluded via socket_id - const eventMessages = decodeMessages(messages).filter(m => m.event === 'orders.updated') - expect(eventMessages).toHaveLength(0) - - // Deliver a scaling event with socketId (camelCase) — also excluded - await runtime.receiveScalingMessage(JSON.stringify({ - type: 'event', - originNodeId: 'node-b', - appId: 'app-main', - name: 'orders.shipped', - channels: ['orders.public'], - data: JSON.stringify({ id: 'ord_2' }), - socketId: socket.socketId, - })) - const shippedMessages = decodeMessages(messages).filter(m => m.event === 'orders.shipped') - expect(shippedMessages).toHaveLength(0) - - // Deliver a scaling event without any socket exclusion — should be delivered - await runtime.receiveScalingMessage(JSON.stringify({ - type: 'event', - originNodeId: 'node-b', - appId: 'app-main', - name: 'orders.created', - channels: ['orders.public'], - data: JSON.stringify({ id: 'ord_3' }), - })) - const createdMessages = decodeMessages(messages).filter(m => m.event === 'orders.created') - expect(createdMessages).toHaveLength(1) - - await runtime.close() - - // --- Test log functions with non-Error values --- - const logRuntime = createBroadcastWorkerRuntime({ - config, - now: () => FIXED_NOW_MS, - }) - // logSocketMessageError with non-Error: send an invalid JSON message to trigger the error path - // The error from parseJsonObject is an Error, so we need to trigger a non-Error throw. - // Instead, test via the workerInternals exposed log functions indirectly. - // Actually, the log functions are not exported. They're hit when errors propagate. - // Let's trigger them through the scaling auto-subscribe path. - - // --- Test auto-subscribe scaling error logging (line 683) --- - const failingAdapter = { - async publish() {}, - async subscribe(_channel: string, onMessage: (payload: string) => void) { - // Return the unsubscribe function, but the onMessage will be called with bad data - setTimeout(() => onMessage('not-json-{{{'), 5) - return async () => {} - }, - async hashSet() {}, - async hashDelete() {}, - async hashGetAll() { return {} }, - async close() {}, - } - - const autoSubRuntime = createBroadcastWorkerRuntime({ - config, - now: () => FIXED_NOW_MS, - scaling: { - driver: 'redis', - connection: 'holo-main', - nodeId: 'node-auto', - eventChannel: 'holo:broadcast:scaling:holo-main:events', - adapter: failingAdapter, - }, - // Don't pass scalingUnsubscribe and don't set scalingAutoSubscribe to false - // so the auto-subscribe path is taken - }) - // Wait for the bad message to be processed - await new Promise(resolve => setTimeout(resolve, 50)) - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Scaling message handling failed')) - await autoSubRuntime.close() - errorSpy.mockClear() - - // --- Test whisper cleanup else branch (lines 1079-1080) --- - // Socket A subscribes to a channel with whispers, then re-subscribes - // to the same channel without whispers — triggers the else branch with size === 0 - let whisperFetchCount = 0 - const whisperRuntime = createBroadcastWorkerRuntime({ - config: normalizeBroadcastConfig({ - default: 'holo-main', - connections: { - 'holo-main': { - driver: 'holo', - appId: 'app-main', - key: 'key-main', - secret: 'secret-main', - clientOptions: { - authEndpoint: 'https://app.test/broadcasting/auth', - }, - }, - }, - worker: { - healthPath: '/healthz', - statsPath: '/statsz', - }, - }), - now: () => FIXED_NOW_MS, - fetch: async () => { - whisperFetchCount++ - if (whisperFetchCount === 1) { - return new Response(JSON.stringify({ whispers: ['typing.start'] }), { status: 200 }) - } - return new Response(JSON.stringify({}), { status: 200 }) - }, - }) - const whisperApps = workerInternals.buildWorkerApps(normalizeBroadcastConfig({ - default: 'holo-main', - connections: { - 'holo-main': { - driver: 'holo', - appId: 'app-main', - key: 'key-main', - secret: 'secret-main', - clientOptions: { - authEndpoint: 'https://app.test/broadcasting/auth', - }, - }, - }, - worker: { - healthPath: '/healthz', - statsPath: '/statsz', - }, - })) - const whisperSocketA = createSocket(whisperApps['key-main']!) - whisperSocketA.socket.socketId = 'key-main.whisper-a' - whisperRuntime.connectWebSocket(whisperSocketA.socket) - - // Socket A subscribes — gets whispers (populates channelWhispers for this key) - await whisperRuntime.receiveWebSocketMessage(whisperSocketA.socket.socketId, JSON.stringify({ - event: 'pusher:subscribe', - data: { channel: 'private-orders.ord_1' }, - })) - - // Socket A re-subscribes to the SAME channel — gets no whispers - // This triggers the else branch, deletes socket A from whispersBySocket, - // and since it was the only entry, size becomes 0 → channelWhispers.delete(key) - await whisperRuntime.receiveWebSocketMessage(whisperSocketA.socket.socketId, JSON.stringify({ - event: 'pusher:subscribe', - data: { channel: 'private-orders.ord_1' }, - })) - - await whisperRuntime.close() - - // --- Test subscribe failure cleanup (lines 1552-1554) --- - const subscribeFailAdapter = { - publish: vi.fn(async () => {}), - subscribe: vi.fn(async () => { - throw new Error('subscribe cleanup test') - }), - hashSet: vi.fn(async () => {}), - hashDelete: vi.fn(async () => {}), - hashGetAll: vi.fn(async () => ({})), - close: vi.fn(async () => {}), - } - - await expect(startBroadcastWorker({ - config: normalizeBroadcastConfig({ - ...createRawConfig(), - worker: { - ...createRawConfig().worker, - scaling: { - driver: 'redis', - connection: 'broadcast', - }, - }, - }), - queue: normalizeQueueConfigForHolo({ - default: 'broadcast', - connections: { - broadcast: { - driver: 'redis', - connection: 'broadcast', - }, - }, - }, createRedisConfig()), - createScalingAdapter: async () => subscribeFailAdapter, - })).rejects.toThrow('subscribe cleanup test') - expect(subscribeFailAdapter.close).toHaveBeenCalled() - - // --- Test subscribe failure when runtime.close() also throws (line 1522) --- - const doubleFailAdapter = { - publish: vi.fn(async () => {}), - subscribe: vi.fn(async () => { - throw new Error('subscribe double-fail') - }), - hashSet: vi.fn(async () => {}), - hashDelete: vi.fn(async () => {}), - hashGetAll: vi.fn(async () => ({})), - close: vi.fn(async () => { throw new Error('close also failed') }), - } - - await expect(startBroadcastWorker({ - config: normalizeBroadcastConfig({ - ...createRawConfig(), - worker: { - ...createRawConfig().worker, - scaling: { - driver: 'redis', - connection: 'broadcast', - }, - }, - }), - queue: normalizeQueueConfigForHolo({ - default: 'broadcast', - connections: { - broadcast: { - driver: 'redis', - connection: 'broadcast', - }, - }, - }, createRedisConfig()), - createScalingAdapter: async () => doubleFailAdapter, - })).rejects.toThrow('subscribe double-fail') - - // --- Test Bun websocket message error handler (lines 1612-1614) --- - const bun = (globalThis as { Bun?: { serve?: unknown } }).Bun - const originalServe = bun?.serve - let capturedWs: { - open: (socket: { data: { socketId: string, app: unknown, headers: Headers }, send(value: string): void, close(code?: number, reason?: string): void }) => void - message: (socket: { data: { socketId: string }, send(value: string): void, close(code?: number, reason?: string): void }, message: string) => void - } | undefined - const bunServe = (options: unknown) => { - capturedWs = (options as { websocket: typeof capturedWs }).websocket - return { hostname: '0.0.0.0', port: 8080, stop: vi.fn() } - } - if (bun) { - bun.serve = bunServe - } else { - vi.stubGlobal('Bun', { serve: bunServe }) - } - - try { - const bunWorker = await startBroadcastWorker({ - config: normalizeBroadcastConfig({ - ...createRawConfig(), - worker: { - ...createRawConfig().worker, - scaling: { - driver: 'redis', - connection: 'broadcast', - }, - }, - }), - queue: normalizeQueueConfigForHolo({ - default: 'broadcast', - connections: { - broadcast: { - driver: 'redis', - connection: 'broadcast', - }, - }, - }, createRedisConfig()), - createScalingAdapter: async () => hub.createAdapter(), - }) - - // Open a socket first so receiveWebSocketMessage finds it - const bunSend = vi.fn() - const bunClose = vi.fn() - const bunSocketData = { - socketId: 'key-main.bun-test', - app: apps['key-main']!, - headers: new Headers(), - } - capturedWs!.open({ - data: bunSocketData, - send: bunSend, - close: bunClose, - }) - - // Send a subscribe to a private channel without auth — this will throw - capturedWs!.message( - { data: bunSocketData, send: bunSend, close: bunClose }, - JSON.stringify({ - event: 'pusher:subscribe', - data: { channel: 'private-secret.channel' }, - }), - ) - // Wait for the async error handler - await new Promise(resolve => setTimeout(resolve, 100)) - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('WebSocket message handling failed')) - expect(bunClose).toHaveBeenCalledWith(4001, 'Protocol error') - - await bunWorker.stop() - } finally { - if (bun) { - bun.serve = originalServe - } else { - Reflect.deleteProperty(globalThis, 'Bun') - } - } - - // --- Test disconnect cleanup error branches (lines 1383, 1387, 1397) --- - let publishShouldFail = false - const cleanupHashStore = new Map>() - const failingScalingAdapter = { - async publish() { if (publishShouldFail) throw new Error('scaling-publish-error') }, - async subscribe(_channel: string, _onMessage: (payload: string) => void) { - return async () => {} - }, - async hashSet(key: string, field: string, value: string) { - const record = cleanupHashStore.get(key) ?? new Map() - record.set(field, value) - cleanupHashStore.set(key, record) - }, - async hashDelete() { if (publishShouldFail) throw 'scaling-hash-delete-non-error' }, - async hashGetAll(key: string) { - const record = cleanupHashStore.get(key) - return Object.freeze(Object.fromEntries(record ? [...record.entries()] : [])) - }, - async close() {}, - } - - errorSpy.mockClear() - const cleanupRuntime = createBroadcastWorkerRuntime({ - config, - now: () => FIXED_NOW_MS, - scaling: { - driver: 'redis', - connection: 'holo-main', - nodeId: 'node-cleanup', - eventChannel: 'holo:broadcast:scaling:holo-main:events', - adapter: failingScalingAdapter, - }, - channelAuth: { - definitions: [ - defineChannel('chat.{roomId}', { - type: 'presence', - authorize() { - return { id: 'user_cleanup' } - }, - }), - ], - resolveUser() { - return { id: 'user_cleanup' } - }, - }, - }) - - const cleanupSocket = createSocket(apps['key-main']!) - cleanupRuntime.connectWebSocket(cleanupSocket.socket) - - // Subscribe to a presence channel so disconnect triggers scaling cleanup - await cleanupRuntime.receiveWebSocketMessage(cleanupSocket.socket.socketId, JSON.stringify({ - event: 'pusher:subscribe', - data: { channel: 'presence-chat.room_1' }, - })) - - // Disconnect — this triggers publishScalingPresenceMemberRemoved (throws non-Error) - // and removePresenceMemberFromScaling (hashDelete throws non-Error) - publishShouldFail = true - cleanupRuntime.disconnectWebSocket(cleanupSocket.socket.socketId) - // Wait for async cleanup tasks - await new Promise(resolve => setTimeout(resolve, 100)) - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Socket cleanup failed')) - - await cleanupRuntime.close() - - // --- Test publish body normalization non-Error catch (lines 1254-1256) --- - // This requires sending a publish request where normalizePublishBody throws a non-Error - // The catch block uses `error instanceof Error ? error.message : 'Invalid publish payload'` - // We need to trigger a non-Error throw from parseJsonObject or normalizePublishBody - // parseJsonObject throws Error, so we need to trigger the catch with a non-Error - // Actually, both parseJsonObject and normalizePublishBody throw Error instances. - // The non-Error branch is defensive. Let's verify it's actually reachable by checking - // if there's a way to trigger it. Since both functions throw Error, this branch - // is purely defensive. We can test it by sending a request that triggers the catch. - // Actually, the catch wraps both parseJsonObject AND normalizePublishBody. - // Both throw Error instances, so the non-Error branch is defensive code. - // Let's just verify the Error branch is covered by sending an invalid body. - const publishRuntime = createBroadcastWorkerRuntime({ - config, - now: () => FIXED_NOW_MS, - }) - const publishPayload = 'not-json' - const publishUrl = new URL('http://worker.test/apps/app-main/events') - publishUrl.searchParams.set('auth_key', 'key-main') - publishUrl.searchParams.set('auth_timestamp', String(Math.floor(FIXED_NOW_MS / 1000))) - publishUrl.searchParams.set('auth_version', '1.0') - const bodyMd5 = (await import('node:crypto')).createHash('md5').update(publishPayload).digest('hex') - publishUrl.searchParams.set('body_md5', bodyMd5) - const sig = workerInternals.createPusherSignature( - 'secret-main', - 'POST', - '/apps/app-main/events', - publishUrl.searchParams, - ) - publishUrl.searchParams.set('auth_signature', sig) - const badPublish = await publishRuntime.fetch(new Request(publishUrl, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: publishPayload, - })) - expect(badPublish.status).toBe(400) - await publishRuntime.close() - - errorSpy.mockRestore() - }) - it('covers redis scaling socket and cluster helper edge cases', async () => { const loadValidationRedisModule = async () => ({ default: class RedisMock { diff --git a/packages/cache-db/src/index.ts b/packages/cache-db/src/index.ts index 92c71159..92c69b73 100644 --- a/packages/cache-db/src/index.ts +++ b/packages/cache-db/src/index.ts @@ -20,11 +20,54 @@ import { type DriverExecutionResult, type HoloProjectConnectionConfig, type SupportedDatabaseDriver, + type TableDefinitionBuilder, } from '@holo-js/db' export const DEFAULT_CACHE_DATABASE_TABLE = 'cache' export const DEFAULT_CACHE_DATABASE_LOCK_TABLE = 'cache_locks' +export type CacheDatabaseTableColumnKind = 'string' | 'text' | 'bigInteger' + +export type CacheDatabaseTableColumnDefinition = { + readonly name: string + readonly kind: CacheDatabaseTableColumnKind + readonly nullable?: boolean + readonly primaryKey?: boolean +} + +export type CacheDatabaseTableDefinition = { + readonly role: 'entries' | 'locks' + readonly defaultName: string + readonly columns: readonly CacheDatabaseTableColumnDefinition[] + readonly indexColumn: string + readonly indexName: (tableName: string) => string +} + +export const CACHE_DATABASE_TABLE_DEFINITIONS = [ + { + role: 'entries', + defaultName: DEFAULT_CACHE_DATABASE_TABLE, + columns: [ + { name: 'key', kind: 'string', primaryKey: true }, + { name: 'payload', kind: 'text' }, + { name: 'expires_at', kind: 'bigInteger', nullable: true }, + ], + indexColumn: 'expires_at', + indexName: tableName => `${tableName.replaceAll('.', '_')}_expires_at_index`, + }, + { + role: 'locks', + defaultName: DEFAULT_CACHE_DATABASE_LOCK_TABLE, + columns: [ + { name: 'name', kind: 'string', primaryKey: true }, + { name: 'owner', kind: 'string' }, + { name: 'expires_at', kind: 'bigInteger' }, + ], + indexColumn: 'expires_at', + indexName: tableName => `${tableName.replaceAll('.', '_')}_expires_at_index`, + }, +] as const satisfies readonly CacheDatabaseTableDefinition[] + export type DatabaseCacheDriverOptions = { readonly name: string readonly connectionName: string @@ -154,6 +197,33 @@ function createDatabaseConnection( }).connection(connectionName) } +function applyCacheDatabaseColumnDefinition( + table: TableDefinitionBuilder, + columnDefinition: CacheDatabaseTableColumnDefinition, +): void { + let columnBuilder = table[columnDefinition.kind](columnDefinition.name) + + if (columnDefinition.primaryKey) { + columnBuilder = columnBuilder.primaryKey() + } + + if (columnDefinition.nullable) { + columnBuilder.nullable() + } +} + +function applyCacheDatabaseTableDefinition( + table: TableDefinitionBuilder, + tableName: string, + tableDefinition: CacheDatabaseTableDefinition, +): void { + for (const columnDefinition of tableDefinition.columns) { + applyCacheDatabaseColumnDefinition(table, columnDefinition) + } + + table.index([tableDefinition.indexColumn], tableDefinition.indexName(tableName)) +} + async function prepareCacheDatabaseTables( connection: DatabaseContext, tableName = DEFAULT_CACHE_DATABASE_TABLE, @@ -164,54 +234,17 @@ async function prepareCacheDatabaseTables( if (!(await schema.hasTable(tableName))) { await schema.createTable(tableName, (table) => { - table.string('key').primaryKey() - table.text('payload') - table.bigInteger('expires_at').nullable() - table.index(['expires_at'], `${tableName.replaceAll('.', '_')}_expires_at_index`) + applyCacheDatabaseTableDefinition(table, tableName, CACHE_DATABASE_TABLE_DEFINITIONS[0]) }) } if (!(await schema.hasTable(lockTableName))) { await schema.createTable(lockTableName, (table) => { - table.string('name').primaryKey() - table.string('owner') - table.bigInteger('expires_at') - table.index(['expires_at'], `${lockTableName.replaceAll('.', '_')}_expires_at_index`) + applyCacheDatabaseTableDefinition(table, lockTableName, CACHE_DATABASE_TABLE_DEFINITIONS[1]) }) } } -function renderCacheTableMigration( - tableName = DEFAULT_CACHE_DATABASE_TABLE, - lockTableName = DEFAULT_CACHE_DATABASE_LOCK_TABLE, -): string { - return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', - '', - 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', - ` await schema.createTable('${tableName}', (table) => {`, - ' table.string(\'key\').primaryKey()', - ' table.text(\'payload\')', - ' table.bigInteger(\'expires_at\').nullable()', - ` table.index(['expires_at'], '${tableName.replaceAll('.', '_')}_expires_at_index')`, - ' })', - ` await schema.createTable('${lockTableName}', (table) => {`, - ' table.string(\'name\').primaryKey()', - ' table.string(\'owner\')', - ' table.bigInteger(\'expires_at\')', - ` table.index(['expires_at'], '${lockTableName.replaceAll('.', '_')}_expires_at_index')`, - ' })', - ' },', - ' async down({ schema }: MigrationContext) {', - ` await schema.dropTable('${lockTableName}')`, - ` await schema.dropTable('${tableName}')`, - ' },', - '})', - '', - ].join('\n') -} - function resolveExecutionResultAffectedRows(result: DriverExecutionResult): number { /* v8 ignore next -- Holo DB adapters normalize affectedRows to a number for executed mutations. */ return result.affectedRows ?? 0 @@ -578,7 +611,6 @@ export const cacheDbInternals = { prepareCacheDatabaseTables, readEntry, readLock, - renderCacheTableMigration, resolveDriver, withDatabaseCacheTableGuard, } diff --git a/packages/cache-db/tests/package.test.ts b/packages/cache-db/tests/package.test.ts index f56cf012..69f1fa75 100644 --- a/packages/cache-db/tests/package.test.ts +++ b/packages/cache-db/tests/package.test.ts @@ -126,14 +126,6 @@ describe('@holo-js/cache-db', () => { })) }) - it('renders the cache table migration scaffold', () => { - const migration = cacheDbInternals.renderCacheTableMigration('cache_entries', 'cache_entry_locks') - - expect(migration).toContain('await schema.createTable(\'cache_entries\'') - expect(migration).toContain('await schema.createTable(\'cache_entry_locks\'') - expect(migration).toContain('await schema.dropTable(\'cache_entry_locks\')') - }) - it('resolves database drivers from explicit config and urls', () => { expect(cacheDbInternals.resolveDriver({ driver: 'mysql' })).toBe('mysql') expect(cacheDbInternals.resolveDriver('postgres://cache.internal/db')).toBe('postgres') diff --git a/packages/cache-redis/src/index.ts b/packages/cache-redis/src/index.ts index 9c6ff912..63753f41 100644 --- a/packages/cache-redis/src/index.ts +++ b/packages/cache-redis/src/index.ts @@ -364,6 +364,25 @@ export function createRedisCacheDriver(options: RedisCacheDriverOptions): CacheD } while (cursor !== '0') } + async function mutateNumericValue( + key: string, + amount: number, + mutate: (key: string, amount: number) => Promise, + ): Promise { + try { + return await mutate(key, amount) + } catch (error) { + if (!isRedisNumericMutationError(error)) { + throw error + } + + throw new CacheInvalidNumericMutationError( + `[@holo-js/cache] Cache key "${key}" does not contain a numeric value.`, + { cause: error }, + ) + } + } + const driver: CacheDriverContract = { name: options.name, driver: 'redis', @@ -413,32 +432,10 @@ export function createRedisCacheDriver(options: RedisCacheDriverOptions): CacheD await flushClient(client) }, async increment(key: string, amount: number): Promise { - try { - return await client.incrby(key, amount) - } catch (error) { - if (!isRedisNumericMutationError(error)) { - throw error - } - - throw new CacheInvalidNumericMutationError( - `[@holo-js/cache] Cache key "${key}" does not contain a numeric value.`, - { cause: error }, - ) - } + return await mutateNumericValue(key, amount, async (targetKey, targetAmount) => await client.incrby(targetKey, targetAmount)) }, async decrement(key: string, amount: number): Promise { - try { - return await client.decrby(key, amount) - } catch (error) { - if (!isRedisNumericMutationError(error)) { - throw error - } - - throw new CacheInvalidNumericMutationError( - `[@holo-js/cache] Cache key "${key}" does not contain a numeric value.`, - { cause: error }, - ) - } + return await mutateNumericValue(key, amount, async (targetKey, targetAmount) => await client.decrby(targetKey, targetAmount)) }, lock(name: string, seconds: number): CacheLockContract { return createRedisLock(client, name, seconds, ownerFactory, sleep, now) diff --git a/packages/cache/src/db.ts b/packages/cache/src/db.ts index 51a35a4a..9af70c52 100644 --- a/packages/cache/src/db.ts +++ b/packages/cache/src/db.ts @@ -1,6 +1,3 @@ -import { createRequire } from 'node:module' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' import { normalizeDatabaseConfig, type HoloDatabaseConfig, @@ -9,11 +6,15 @@ import { } from '@holo-js/config' import { CacheDriverResolutionError, - CacheOptionalPackageError, type CacheDriverContract, - type CacheDriverGetResult, - type CacheLockContract, } from './contracts' +import { + createLazyOptionalCacheDriver, + createOptionalDriverModuleLoader, + isOptionalDriverModuleNotFoundError, + normalizeOptionalDriverModuleLoadError, + type OptionalDriverModuleLoader, +} from './optional-driver' type DatabaseCacheDriverOptions = { readonly name: string @@ -28,11 +29,9 @@ type DatabaseCacheDriverModule = { createDatabaseCacheDriver(options: DatabaseCacheDriverOptions): CacheDriverContract } -type DatabaseDriverModuleLoader = () => Promise - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} +type DatabaseDriverModuleLoader = OptionalDriverModuleLoader +const DATABASE_CACHE_PACKAGE = '@holo-js/cache-db' +const DATABASE_CACHE_MISSING_MESSAGE = '[@holo-js/cache] Database cache support requires @holo-js/cache-db to be installed.' function isNormalizedDatabaseConfig( config: HoloDatabaseConfig | NormalizedHoloDatabaseConfig, @@ -71,72 +70,20 @@ function resolveSharedDatabaseConnection( } function isModuleNotFoundError(error: unknown, expectedSpecifier = '@holo-js/cache-db'): boolean { - if (!error || typeof error !== 'object') { - return false - } - - const message = 'message' in error && typeof (error as { message?: unknown }).message === 'string' - ? (error as { message: string }).message - : '' - const code = 'code' in error ? (error as { code?: unknown }).code : undefined - const escapedSpecifier = escapeRegExp(expectedSpecifier) - - if ( - (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') - && [ - new RegExp(`Cannot find package ['"]${escapedSpecifier}['"]`), - new RegExp(`Cannot find module ['"]${escapedSpecifier}['"]`), - new RegExp(`Could not resolve ['"]${escapedSpecifier}['"]`), - new RegExp(`Failed to load url\\s+(?:['"\`]${escapedSpecifier}['"\`]|${escapedSpecifier}(?=[\\s(]|$))`), - ].some(pattern => pattern.test(message)) - ) { - return true - } - - if ('cause' in error) { - return isModuleNotFoundError((error as { cause?: unknown }).cause, expectedSpecifier) - } - - return false + return isOptionalDriverModuleNotFoundError(error, expectedSpecifier) } function normalizeDatabaseModuleLoadError( error: unknown, expectedSpecifier = '@holo-js/cache-db', -): CacheOptionalPackageError | unknown { - if (isModuleNotFoundError(error, expectedSpecifier)) { - return new CacheOptionalPackageError( - '[@holo-js/cache] Database cache support requires @holo-js/cache-db to be installed.', - { cause: error }, - ) - } - - return error +): ReturnType { + return normalizeOptionalDriverModuleLoadError(error, expectedSpecifier, DATABASE_CACHE_MISSING_MESSAGE) } -/* v8 ignore start -- optional-peer loading failures are covered through normalizeDatabaseModuleLoadError in this monorepo test graph. */ -async function importDatabaseDriverModuleFromProject(specifier: string): Promise { - const projectRequire = createRequire(join(process.cwd(), 'package.json')) - return await import(pathToFileURL(projectRequire.resolve(specifier)).href) as DatabaseCacheDriverModule -} - -async function loadDatabaseDriverModule(): Promise { - const specifier = '@holo-js/cache-db' as string - try { - return await import(/* webpackIgnore: true */ specifier) as DatabaseCacheDriverModule - } catch (error) { - if (!isModuleNotFoundError(error, specifier)) { - throw normalizeDatabaseModuleLoadError(error, specifier) - } - - try { - return await importDatabaseDriverModuleFromProject(specifier) - } catch (fallbackError) { - throw normalizeDatabaseModuleLoadError(fallbackError, specifier) - } - } -} -/* v8 ignore stop */ +const loadDatabaseDriverModule = createOptionalDriverModuleLoader( + DATABASE_CACHE_PACKAGE, + DATABASE_CACHE_MISSING_MESSAGE, +) let databaseDriverModuleLoader: DatabaseDriverModuleLoader = loadDatabaseDriverModule @@ -148,97 +95,16 @@ function resetDatabaseDriverModuleLoader(): void { databaseDriverModuleLoader = loadDatabaseDriverModule } -class LazyDatabaseCacheDriver implements CacheDriverContract { - readonly driver = 'database' as const - - private driverInstance?: CacheDriverContract - private pending?: Promise - - constructor(private readonly options: DatabaseCacheDriverOptions) {} - - get name(): string { - return this.options.name - } - - private async resolveDriver(): Promise { - if (this.driverInstance) return this.driverInstance - - this.pending ??= databaseDriverModuleLoader().then((module) => { - const driver = module.createDatabaseCacheDriver(this.options) - this.driverInstance = driver - return driver - }).finally(() => { - this.pending = undefined - }) - - return this.pending - } - - private async withDriver( - callback: (driver: CacheDriverContract) => Promise | TValue, - ): Promise { - return callback(await this.resolveDriver()) - } - - private createLockProxy(name: string, seconds: number): CacheLockContract { - let lockPromise: Promise | undefined - - const resolveLock = async (): Promise => { - lockPromise ??= this.withDriver((driver) => driver.lock(name, seconds)) - return lockPromise - } - - return { - name, - async get(callback?: () => TValue | Promise): Promise { - return (await resolveLock()).get(callback) - }, - async release(): Promise { - return (await resolveLock()).release() - }, - async block(waitSeconds: number, callback?: () => TValue | Promise): Promise { - return (await resolveLock()).block(waitSeconds, callback) - }, - } - } - - async get(key: string): Promise { - return this.withDriver((driver) => driver.get(key)) - } - - async put(input: Parameters[0]): Promise { - return this.withDriver((driver) => driver.put(input)) - } - - async add(input: Parameters[0]): Promise { - return this.withDriver((driver) => driver.add(input)) - } - - async forget(key: string): Promise { - return this.withDriver((driver) => driver.forget(key)) - } - - async flush(): Promise { - await this.withDriver((driver) => driver.flush()) - } - - async increment(key: string, amount: number): Promise { - return this.withDriver((driver) => driver.increment(key, amount)) - } - - async decrement(key: string, amount: number): Promise { - return this.withDriver((driver) => driver.decrement(key, amount)) - } - - lock(name: string, seconds: number): CacheLockContract { - return this.createLockProxy(name, seconds) - } -} - function createDatabaseCacheDriver( options: DatabaseCacheDriverOptions, ): CacheDriverContract { - return new LazyDatabaseCacheDriver(options) + return createLazyOptionalCacheDriver({ + name: options.name, + driver: 'database', + options, + loadModule: databaseDriverModuleLoader, + createDriver: (module, driverOptions) => module.createDatabaseCacheDriver(driverOptions), + }) } export const cacheDbInternals = { diff --git a/packages/cache/src/facade.ts b/packages/cache/src/facade.ts index df09214a..e6479ea5 100644 --- a/packages/cache/src/facade.ts +++ b/packages/cache/src/facade.ts @@ -1,5 +1,4 @@ import { - CacheInvalidTtlError, deserializeCacheValue, normalizeCacheTtl, resolveCacheKey, @@ -15,21 +14,16 @@ import { type CacheTtlInput, type CacheValueResolver, } from './contracts' +import { + createFlexibleEnvelope, + isFlexibleEnvelope, + normalizeFlexibleTtl, + resolveFlexibleCachedValue, + type NormalizedFlexibleTtl, +} from './flexible' import { cacheQueryBridgeInternals } from './query-bridge' import { cacheRuntimeInternals, getCacheRuntime } from './runtime' -type FlexibleEnvelope = { - readonly __holo_cache_flexible: true - readonly value: TValue - readonly freshUntil: number - readonly staleUntil: number -} - -type NormalizedFlexibleTtl = { - readonly freshSeconds: number - readonly staleSeconds: number -} - type CachedValueLookup = | { readonly hit: true @@ -58,38 +52,6 @@ function resolveDriverKey( return normalized || '__default__' } -function normalizeFlexibleTtl(ttl: CacheFlexibleTtlInput): NormalizedFlexibleTtl { - const freshSeconds = 'fresh' in ttl ? ttl.fresh : ttl[0] - const staleSeconds = 'stale' in ttl ? ttl.stale : ttl[1] - - if (!Number.isInteger(freshSeconds) || freshSeconds < 0) { - throw new CacheInvalidTtlError('[@holo-js/cache] Flexible fresh TTL must be an integer greater than or equal to 0.') - } - - if (!Number.isInteger(staleSeconds) || staleSeconds < freshSeconds) { - throw new CacheInvalidTtlError('[@holo-js/cache] Flexible stale TTL must be an integer greater than or equal to the fresh TTL.') - } - - return Object.freeze({ - freshSeconds, - staleSeconds, - }) -} - -function isFlexibleEnvelope(value: unknown): value is FlexibleEnvelope { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false - } - - const envelope = value as Partial> - return envelope.__holo_cache_flexible === true - && typeof envelope.freshUntil === 'number' - && Number.isFinite(envelope.freshUntil) - && typeof envelope.staleUntil === 'number' - && Number.isFinite(envelope.staleUntil) - && 'value' in envelope -} - function createCacheRepository(driverName?: string): CacheRepository { function resolveDriverContext() { const runtime = getCacheRuntime() @@ -151,14 +113,7 @@ function createCacheRepository(driverName?: string): CacheRepository { ttl: NormalizedFlexibleTtl, value: Awaited, ): Promise> { - const now = Date.now() - const envelope = { - __holo_cache_flexible: true, - value, - freshUntil: now + (ttl.freshSeconds * 1000), - staleUntil: now + (ttl.staleSeconds * 1000), - } satisfies FlexibleEnvelope - + const envelope = createFlexibleEnvelope(ttl, value) await putSerializedValue(key, serializeCacheValue(envelope), ttl.staleSeconds) return value } @@ -287,47 +242,19 @@ function createCacheRepository(driverName?: string): CacheRepository { ttl: CacheFlexibleTtlInput, callback: CacheValueResolver, ): Promise> { - const normalizedTtl = normalizeFlexibleTtl(ttl) - const now = Date.now() - const cached = await getCachedValue(key) - - if (cached.hit && isFlexibleEnvelope>(cached.value)) { - if (now <= cached.value.freshUntil) { - return cached.value.value - } - - if (now <= cached.value.staleUntil) { - const refreshLock = createRefreshLock(key, normalizedTtl.staleSeconds) - void refreshLock.get(async () => { - await refreshFlexibleValue(key, normalizedTtl, callback) - return true - }).catch(() => undefined) - - return cached.value.value - } - } - - const refreshLock = createRefreshLock(key, normalizedTtl.staleSeconds) - const refreshed = await refreshLock.block( - Math.min( + return resolveFlexibleCachedValue>({ + ttl, + read: async () => { + const cached = await getCachedValue(key) + return cached.hit ? cached.value : undefined + }, + refresh: normalizedTtl => refreshFlexibleValue(key, normalizedTtl, callback), + createLock: normalizedTtl => createRefreshLock(key, normalizedTtl.staleSeconds), + blockSeconds: normalizedTtl => Math.min( MAX_REFRESH_BLOCK_SECONDS, Math.max(1, Math.ceil(normalizedTtl.staleSeconds / 300)), ), - async () => refreshFlexibleValue(key, normalizedTtl, callback), - ) - - if (refreshed !== false) { - return refreshed as Awaited - } - - const retried = await getCachedValue(key) - if (retried.hit && isFlexibleEnvelope>(retried.value)) { - if (Date.now() <= retried.value.staleUntil) { - return retried.value.value - } - } - - return refreshFlexibleValue(key, normalizedTtl, callback) + }) }, lock(name: string, seconds: number): CacheLockContract { const { driver } = resolveDriverContext() diff --git a/packages/cache/src/flexible.ts b/packages/cache/src/flexible.ts new file mode 100644 index 00000000..24a16657 --- /dev/null +++ b/packages/cache/src/flexible.ts @@ -0,0 +1,133 @@ +import { + CacheInvalidTtlError, + type CacheFlexibleTtlInput, + type CacheLockContract, +} from './contracts' + +export type FlexibleEnvelope = { + readonly __holo_cache_flexible: true + readonly value: TValue + readonly freshUntil: number + readonly staleUntil: number +} + +export type NormalizedFlexibleTtl = { + readonly freshSeconds: number + readonly staleSeconds: number +} + +type FlexibleEnvelopeState = 'fresh' | 'stale' | 'expired' + +type FlexibleResolverOptions = { + readonly ttl: CacheFlexibleTtlInput + readonly read: () => Promise + readonly refresh: (ttl: NormalizedFlexibleTtl) => Promise + readonly createLock: (ttl: NormalizedFlexibleTtl) => CacheLockContract + readonly blockSeconds?: (ttl: NormalizedFlexibleTtl) => number + readonly now?: () => number +} + +export function normalizeFlexibleTtl(ttl: CacheFlexibleTtlInput): NormalizedFlexibleTtl { + const freshSeconds = 'fresh' in ttl ? ttl.fresh : ttl[0] + const staleSeconds = 'stale' in ttl ? ttl.stale : ttl[1] + + if (!Number.isInteger(freshSeconds) || freshSeconds < 0) { + throw new CacheInvalidTtlError('[@holo-js/cache] Flexible fresh TTL must be an integer greater than or equal to 0.') + } + + if (!Number.isInteger(staleSeconds) || staleSeconds < freshSeconds) { + throw new CacheInvalidTtlError('[@holo-js/cache] Flexible stale TTL must be an integer greater than or equal to the fresh TTL.') + } + + return Object.freeze({ + freshSeconds, + staleSeconds, + }) +} + +export function createFlexibleEnvelope( + ttl: NormalizedFlexibleTtl, + value: TValue, + now = Date.now(), +): FlexibleEnvelope { + return Object.freeze({ + __holo_cache_flexible: true, + value, + freshUntil: now + (ttl.freshSeconds * 1000), + staleUntil: now + (ttl.staleSeconds * 1000), + }) +} + +export function isFlexibleEnvelope(value: unknown): value is FlexibleEnvelope { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + + const envelope = value as Partial> + return envelope.__holo_cache_flexible === true + && typeof envelope.freshUntil === 'number' + && Number.isFinite(envelope.freshUntil) + && typeof envelope.staleUntil === 'number' + && Number.isFinite(envelope.staleUntil) + && 'value' in envelope +} + +export function resolveFlexibleEnvelopeState( + envelope: FlexibleEnvelope, + now = Date.now(), +): FlexibleEnvelopeState { + if (now <= envelope.freshUntil) { + return 'fresh' + } + + if (now <= envelope.staleUntil) { + return 'stale' + } + + return 'expired' +} + +export async function resolveFlexibleCachedValue( + options: FlexibleResolverOptions, +): Promise { + const normalizedTtl = normalizeFlexibleTtl(options.ttl) + const cached = await options.read() + const now = options.now?.() ?? Date.now() + + if (isFlexibleEnvelope(cached)) { + const state = resolveFlexibleEnvelopeState(cached, now) + if (state === 'fresh') { + return cached.value + } + + if (state === 'stale') { + const refreshLock = options.createLock(normalizedTtl) + void refreshLock.get(async () => { + await options.refresh(normalizedTtl) + return true + }).catch(() => undefined) + + return cached.value + } + } + + const refreshLock = options.createLock(normalizedTtl) + const refreshed = await refreshLock.block( + options.blockSeconds?.(normalizedTtl) ?? 1, + async () => options.refresh(normalizedTtl), + ) + + if (refreshed !== false) { + return refreshed as TValue + } + + const retried = await options.read() + if ( + isFlexibleEnvelope(retried) + && resolveFlexibleEnvelopeState(retried) !== 'expired' + ) { + return retried.value + } + + return options.refresh(normalizedTtl) +} diff --git a/packages/cache/src/optional-driver.ts b/packages/cache/src/optional-driver.ts new file mode 100644 index 00000000..5a56d291 --- /dev/null +++ b/packages/cache/src/optional-driver.ts @@ -0,0 +1,221 @@ +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + CacheOptionalPackageError, + type CacheDriverContract, + type CacheDriverGetResult, + type CacheLockContract, +} from './contracts' + +export const CACHE_DRIVER_DISPOSE_SYMBOL = Symbol.for('holo.cache.driver.dispose') + +export type DisposableCacheDriver = CacheDriverContract & { + readonly [CACHE_DRIVER_DISPOSE_SYMBOL]?: () => void +} + +export type OptionalDriverModuleLoader = () => Promise + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export function isOptionalDriverModuleNotFoundError(error: unknown, expectedSpecifier: string): boolean { + if (!error || typeof error !== 'object') { + return false + } + + const message = 'message' in error && typeof (error as { message?: unknown }).message === 'string' + ? (error as { message: string }).message + : '' + const code = 'code' in error ? (error as { code?: unknown }).code : undefined + const escapedSpecifier = escapeRegExp(expectedSpecifier) + + if ( + (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') + && [ + new RegExp(`Cannot find package ['"]${escapedSpecifier}['"]`), + new RegExp(`Cannot find module ['"]${escapedSpecifier}['"]`), + new RegExp(`Could not resolve ['"]${escapedSpecifier}['"]`), + new RegExp(`Failed to load url\\s+(?:['"\`]${escapedSpecifier}['"\`]|${escapedSpecifier}(?=[\\s(]|$))`), + ].some(pattern => pattern.test(message)) + ) { + return true + } + + if ('cause' in error) { + return isOptionalDriverModuleNotFoundError((error as { cause?: unknown }).cause, expectedSpecifier) + } + + return false +} + +export function normalizeOptionalDriverModuleLoadError( + error: unknown, + expectedSpecifier: string, + message: string, +): CacheOptionalPackageError | unknown { + if (isOptionalDriverModuleNotFoundError(error, expectedSpecifier)) { + return new CacheOptionalPackageError(message, { cause: error }) + } + + return error +} + +async function importOptionalDriverModuleFromProject(specifier: string): Promise { + const projectRequire = createRequire(join(process.cwd(), 'package.json')) + return await import(pathToFileURL(projectRequire.resolve(specifier)).href) as TModule +} + +export function createOptionalDriverModuleLoader( + specifier: string, + missingPackageMessage: string, +): OptionalDriverModuleLoader { + return async () => { + try { + return await import(/* webpackIgnore: true */ specifier) as TModule + } catch (error) { + if (!isOptionalDriverModuleNotFoundError(error, specifier)) { + throw normalizeOptionalDriverModuleLoadError(error, specifier, missingPackageMessage) + } + + try { + return await importOptionalDriverModuleFromProject(specifier) + } catch (fallbackError) { + throw normalizeOptionalDriverModuleLoadError(fallbackError, specifier, missingPackageMessage) + } + } + } +} + +type LazyCacheDriverOptions = { + readonly name: string + readonly driver: CacheDriverContract['driver'] + readonly options: TOptions + readonly loadModule: OptionalDriverModuleLoader + readonly createDriver: (module: TModule, options: TOptions) => CacheDriverContract + readonly disposeDriver?: boolean +} + +class LazyOptionalCacheDriver implements CacheDriverContract { + private driverInstance?: CacheDriverContract + private pending?: Promise + private disposalGeneration = 0 + + constructor(private readonly lazyOptions: LazyCacheDriverOptions) {} + + get name(): string { + return this.lazyOptions.name + } + + get driver(): CacheDriverContract['driver'] { + return this.lazyOptions.driver + } + + private async resolveDriver(): Promise { + if (this.driverInstance) return this.driverInstance + + const pendingGeneration = this.disposalGeneration + this.pending ??= this.lazyOptions.loadModule().then((module) => { + const driver = this.lazyOptions.createDriver(module, this.lazyOptions.options) + if (this.disposalGeneration === pendingGeneration) { + this.driverInstance = driver + } + return driver + }).finally(() => { + this.pending = undefined + }) + + return this.pending + } + + private async withDriver( + callback: (driver: CacheDriverContract) => Promise | TValue, + ): Promise { + return callback(await this.resolveDriver()) + } + + [CACHE_DRIVER_DISPOSE_SYMBOL](): void { + if (this.lazyOptions.disposeDriver !== true) { + return + } + + const pending = this.pending + const driverInstance = this.driverInstance + + this.disposalGeneration += 1 + this.driverInstance = undefined + this.pending = undefined + + if (driverInstance) { + const disposable = driverInstance as DisposableCacheDriver + disposable[CACHE_DRIVER_DISPOSE_SYMBOL]?.() + return + } + + pending?.then((driver) => { + const disposable = driver as DisposableCacheDriver + disposable[CACHE_DRIVER_DISPOSE_SYMBOL]?.() + }).catch(() => {}) + } + + private createLockProxy(name: string, seconds: number): CacheLockContract { + let lockPromise: Promise | undefined + + const resolveLock = async (): Promise => { + lockPromise ??= this.withDriver((driver) => driver.lock(name, seconds)) + return lockPromise + } + + return { + name, + async get(callback?: () => TValue | Promise): Promise { + return (await resolveLock()).get(callback) + }, + async release(): Promise { + return (await resolveLock()).release() + }, + async block(waitSeconds: number, callback?: () => TValue | Promise): Promise { + return (await resolveLock()).block(waitSeconds, callback) + }, + } + } + + async get(key: string): Promise { + return this.withDriver((driver) => driver.get(key)) + } + + async put(input: Parameters[0]): Promise { + return this.withDriver((driver) => driver.put(input)) + } + + async add(input: Parameters[0]): Promise { + return this.withDriver((driver) => driver.add(input)) + } + + async forget(key: string): Promise { + return this.withDriver((driver) => driver.forget(key)) + } + + async flush(): Promise { + await this.withDriver((driver) => driver.flush()) + } + + async increment(key: string, amount: number): Promise { + return this.withDriver((driver) => driver.increment(key, amount)) + } + + async decrement(key: string, amount: number): Promise { + return this.withDriver((driver) => driver.decrement(key, amount)) + } + + lock(name: string, seconds: number): CacheLockContract { + return this.createLockProxy(name, seconds) + } +} + +export function createLazyOptionalCacheDriver( + options: LazyCacheDriverOptions, +): CacheDriverContract { + return new LazyOptionalCacheDriver(options) +} diff --git a/packages/cache/src/query-bridge.ts b/packages/cache/src/query-bridge.ts index 1b39f6d0..c0bc1f80 100644 --- a/packages/cache/src/query-bridge.ts +++ b/packages/cache/src/query-bridge.ts @@ -1,5 +1,4 @@ import { - CacheInvalidTtlError, resolveCacheKey, deserializeCacheValue, normalizeCacheTtl, @@ -13,20 +12,14 @@ import { type CacheTtlInput, type CacheValueResolver, } from './contracts' +import { + createFlexibleEnvelope, + normalizeFlexibleTtl, + resolveFlexibleCachedValue, + type NormalizedFlexibleTtl, +} from './flexible' import { getCacheRuntime, resolveConfiguredDriver } from './runtime-shared' -type FlexibleEnvelope = { - readonly __holo_cache_flexible: true - readonly value: TValue - readonly freshUntil: number - readonly staleUntil: number -} - -type NormalizedFlexibleTtl = { - readonly freshSeconds: number - readonly staleSeconds: number -} - type DependencyIndexState = { readonly keyToDependencies: Map> readonly dependencyToKeys: Map> @@ -141,38 +134,6 @@ function resolveNormalizedKey( return `${context.normalizedKeyPrefix}${resolveCacheKey(key)}` } -function normalizeFlexibleTtl(ttl: CacheFlexibleTtlInput): NormalizedFlexibleTtl { - const freshSeconds = 'fresh' in ttl ? ttl.fresh : ttl[0] - const staleSeconds = 'stale' in ttl ? ttl.stale : ttl[1] - - if (!Number.isInteger(freshSeconds) || freshSeconds < 0) { - throw new CacheInvalidTtlError('[@holo-js/cache] Flexible fresh TTL must be an integer greater than or equal to 0.') - } - - if (!Number.isInteger(staleSeconds) || staleSeconds < freshSeconds) { - throw new CacheInvalidTtlError('[@holo-js/cache] Flexible stale TTL must be an integer greater than or equal to the fresh TTL.') - } - - return Object.freeze({ - freshSeconds, - staleSeconds, - }) -} - -function isFlexibleEnvelope(value: unknown): value is FlexibleEnvelope { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false - } - - const envelope = value as Partial> - return envelope.__holo_cache_flexible === true - && typeof envelope.freshUntil === 'number' - && Number.isFinite(envelope.freshUntil) - && typeof envelope.staleUntil === 'number' - && Number.isFinite(envelope.staleUntil) - && 'value' in envelope -} - async function getCachedValue( key: CacheKeyInput, driverName?: string, @@ -300,19 +261,10 @@ export function createCacheQueryBridge( } = {}, ): Promise> { const indexedKey = createIndexedKey(key, options.driver) - const normalizedTtl = normalizeFlexibleTtl(ttl) - const now = Date.now() - const cached = await getCachedValue(key, options.driver) - const refreshValue = async (): Promise> => { + const refreshValue = async (normalizedTtl: NormalizedFlexibleTtl): Promise> => { const value = await callback() - const refreshedAt = Date.now() - const envelope = { - __holo_cache_flexible: true, - value, - freshUntil: refreshedAt + (normalizedTtl.freshSeconds * 1000), - staleUntil: refreshedAt + (normalizedTtl.staleSeconds * 1000), - } satisfies FlexibleEnvelope> + const envelope = createFlexibleEnvelope(normalizedTtl, value) await putCachedValue( key, envelope, @@ -323,35 +275,12 @@ export function createCacheQueryBridge( return value } - if (isFlexibleEnvelope>(cached)) { - if (now <= cached.freshUntil) { - return cached.value - } - - if (now <= cached.staleUntil) { - const refreshLock = createFlexibleLock(key, normalizedTtl, options.driver) - void refreshLock.get(async () => { - await refreshValue() - return true - }).catch(() => undefined) - return cached.value - } - } - - const refreshLock = createFlexibleLock(key, normalizedTtl, options.driver) - const refreshed = await refreshLock.block(1, async () => refreshValue()) - if (refreshed !== false) { - return refreshed as Awaited - } - - const retried = await getCachedValue(key, options.driver) - if (isFlexibleEnvelope>(retried)) { - if (Date.now() <= retried.staleUntil) { - return retried.value - } - } - - return refreshValue() + return resolveFlexibleCachedValue>({ + ttl, + read: async () => getCachedValue(key, options.driver), + refresh: normalizedTtl => refreshValue(normalizedTtl), + createLock: normalizedTtl => createFlexibleLock(key, normalizedTtl, options.driver), + }) }, async forget(key: CacheKeyInput, options?: { driver?: string }): Promise { const indexedKey = createIndexedKey(key, options?.driver) diff --git a/packages/cache/src/redis.ts b/packages/cache/src/redis.ts index 2e9442e8..3786fab7 100644 --- a/packages/cache/src/redis.ts +++ b/packages/cache/src/redis.ts @@ -1,6 +1,3 @@ -import { createRequire } from 'node:module' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' import { normalizeRedisConfig, type HoloRedisConfig, @@ -9,11 +6,15 @@ import { } from '@holo-js/config' import { CacheDriverResolutionError, - CacheOptionalPackageError, type CacheDriverContract, - type CacheDriverGetResult, - type CacheLockContract, } from './contracts' +import { + createLazyOptionalCacheDriver, + createOptionalDriverModuleLoader, + isOptionalDriverModuleNotFoundError, + normalizeOptionalDriverModuleLoadError, + type OptionalDriverModuleLoader, +} from './optional-driver' type RedisCacheDriverOptions = { readonly name: string @@ -26,12 +27,9 @@ type RedisCacheDriverModule = { createRedisCacheDriver(options: RedisCacheDriverOptions): CacheDriverContract } -type RedisDriverModuleLoader = () => Promise -const CACHE_DRIVER_DISPOSE_SYMBOL = Symbol.for('holo.cache.driver.dispose') - -type DisposableCacheDriver = CacheDriverContract & { - readonly [CACHE_DRIVER_DISPOSE_SYMBOL]?: () => void -} +type RedisDriverModuleLoader = OptionalDriverModuleLoader +const REDIS_CACHE_PACKAGE = '@holo-js/cache-redis' +const REDIS_CACHE_MISSING_MESSAGE = '[@holo-js/cache] Redis cache support requires @holo-js/cache-redis to be installed.' function isNormalizedRedisConfig( config: HoloRedisConfig | NormalizedHoloRedisConfig, @@ -58,10 +56,6 @@ function normalizeRuntimeRedisConfig( return isNormalizedRedisConfig(config) ? config : normalizeRedisConfig(config) } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - function resolveSharedRedisConnection( redisConfig: NormalizedHoloRedisConfig | undefined, connectionName: string, @@ -83,72 +77,20 @@ function resolveSharedRedisConnection( } function isModuleNotFoundError(error: unknown, expectedSpecifier = '@holo-js/cache-redis'): boolean { - if (!error || typeof error !== 'object') { - return false - } - - const message = 'message' in error && typeof (error as { message?: unknown }).message === 'string' - ? (error as { message: string }).message - : '' - const code = 'code' in error ? (error as { code?: unknown }).code : undefined - const escapedSpecifier = escapeRegExp(expectedSpecifier) - - if ( - (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') - && [ - new RegExp(`Cannot find package ['"]${escapedSpecifier}['"]`), - new RegExp(`Cannot find module ['"]${escapedSpecifier}['"]`), - new RegExp(`Could not resolve ['"]${escapedSpecifier}['"]`), - new RegExp(`Failed to load url\\s+(?:['"\`]${escapedSpecifier}['"\`]|${escapedSpecifier}(?=[\\s(]|$))`), - ].some(pattern => pattern.test(message)) - ) { - return true - } - - if ('cause' in error) { - return isModuleNotFoundError((error as { cause?: unknown }).cause, expectedSpecifier) - } - - return false + return isOptionalDriverModuleNotFoundError(error, expectedSpecifier) } function normalizeRedisModuleLoadError( error: unknown, expectedSpecifier = '@holo-js/cache-redis', -): CacheOptionalPackageError | unknown { - if (isModuleNotFoundError(error, expectedSpecifier)) { - return new CacheOptionalPackageError( - '[@holo-js/cache] Redis cache support requires @holo-js/cache-redis to be installed.', - { cause: error }, - ) - } - - return error +): ReturnType { + return normalizeOptionalDriverModuleLoadError(error, expectedSpecifier, REDIS_CACHE_MISSING_MESSAGE) } -/* v8 ignore start -- optional-peer loading failures are covered through normalizeRedisModuleLoadError in this monorepo test graph. */ -async function importRedisDriverModuleFromProject(specifier: string): Promise { - const projectRequire = createRequire(join(process.cwd(), 'package.json')) - return await import(pathToFileURL(projectRequire.resolve(specifier)).href) as RedisCacheDriverModule -} - -async function loadRedisDriverModule(): Promise { - const specifier = '@holo-js/cache-redis' as string - try { - return await import(/* webpackIgnore: true */ specifier) as RedisCacheDriverModule - } catch (error) { - if (!isModuleNotFoundError(error, specifier)) { - throw normalizeRedisModuleLoadError(error, specifier) - } - - try { - return await importRedisDriverModuleFromProject(specifier) - } catch (fallbackError) { - throw normalizeRedisModuleLoadError(fallbackError, specifier) - } - } -} -/* v8 ignore stop */ +const loadRedisDriverModule = createOptionalDriverModuleLoader( + REDIS_CACHE_PACKAGE, + REDIS_CACHE_MISSING_MESSAGE, +) let redisDriverModuleLoader: RedisDriverModuleLoader = loadRedisDriverModule @@ -160,121 +102,17 @@ function resetRedisDriverModuleLoader(): void { redisDriverModuleLoader = loadRedisDriverModule } -class LazyRedisCacheDriver implements CacheDriverContract { - readonly driver = 'redis' as const - - private driverInstance?: CacheDriverContract - private pending?: Promise - private disposalGeneration = 0 - - constructor(private readonly options: RedisCacheDriverOptions) {} - - get name(): string { - return this.options.name - } - - private async resolveDriver(): Promise { - if (this.driverInstance) return this.driverInstance - - const pendingGeneration = this.disposalGeneration - this.pending ??= redisDriverModuleLoader().then((module) => { - const driver = module.createRedisCacheDriver(this.options) - if (this.disposalGeneration === pendingGeneration) { - this.driverInstance = driver - } - return driver - }).finally(() => { - this.pending = undefined - }) - - return this.pending - } - - private async withDriver( - callback: (driver: CacheDriverContract) => Promise | TValue, - ): Promise { - return callback(await this.resolveDriver()) - } - - [CACHE_DRIVER_DISPOSE_SYMBOL](): void { - const pending = this.pending - const driverInstance = this.driverInstance - - this.disposalGeneration += 1 - this.driverInstance = undefined - this.pending = undefined - - if (driverInstance) { - const disposable = driverInstance as DisposableCacheDriver - disposable[CACHE_DRIVER_DISPOSE_SYMBOL]?.() - return - } - - pending?.then((driver) => { - const disposable = driver as DisposableCacheDriver - disposable[CACHE_DRIVER_DISPOSE_SYMBOL]?.() - }).catch(() => {}) - } - - private createLockProxy(name: string, seconds: number): CacheLockContract { - let lockPromise: Promise | undefined - - const resolveLock = async (): Promise => { - lockPromise ??= this.withDriver((driver) => driver.lock(name, seconds)) - return lockPromise - } - - return { - name, - async get(callback?: () => TValue | Promise): Promise { - return (await resolveLock()).get(callback) - }, - async release(): Promise { - return (await resolveLock()).release() - }, - async block(waitSeconds: number, callback?: () => TValue | Promise): Promise { - return (await resolveLock()).block(waitSeconds, callback) - }, - } - } - - async get(key: string): Promise { - return this.withDriver((driver) => driver.get(key)) - } - - async put(input: Parameters[0]): Promise { - return this.withDriver((driver) => driver.put(input)) - } - - async add(input: Parameters[0]): Promise { - return this.withDriver((driver) => driver.add(input)) - } - - async forget(key: string): Promise { - return this.withDriver((driver) => driver.forget(key)) - } - - async flush(): Promise { - await this.withDriver((driver) => driver.flush()) - } - - async increment(key: string, amount: number): Promise { - return this.withDriver((driver) => driver.increment(key, amount)) - } - - async decrement(key: string, amount: number): Promise { - return this.withDriver((driver) => driver.decrement(key, amount)) - } - - lock(name: string, seconds: number): CacheLockContract { - return this.createLockProxy(name, seconds) - } -} - function createRedisCacheDriver( options: RedisCacheDriverOptions, ): CacheDriverContract { - return new LazyRedisCacheDriver(options) + return createLazyOptionalCacheDriver({ + name: options.name, + driver: 'redis', + options, + loadModule: redisDriverModuleLoader, + createDriver: (module, driverOptions) => module.createRedisCacheDriver(driverOptions), + disposeDriver: true, + }) } export const cacheRedisInternals = { diff --git a/packages/cli/package.json b/packages/cli/package.json index a975cc4c..ad81aa9b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@clack/prompts": "catalog:", + "@holo-js/cache-db": "catalog:", "@holo-js/config": "catalog:", "@holo-js/core": "catalog:", "@holo-js/db": "catalog:", diff --git a/packages/cli/src/cache-migrations.ts b/packages/cli/src/cache-migrations.ts index e9183186..f883f10e 100644 --- a/packages/cli/src/cache-migrations.ts +++ b/packages/cli/src/cache-migrations.ts @@ -1,4 +1,11 @@ import { resolve } from 'node:path' +import { + CACHE_DATABASE_TABLE_DEFINITIONS, + DEFAULT_CACHE_DATABASE_LOCK_TABLE as CACHE_DB_DEFAULT_LOCK_TABLE, + DEFAULT_CACHE_DATABASE_TABLE as CACHE_DB_DEFAULT_TABLE, + type CacheDatabaseTableColumnDefinition, + type CacheDatabaseTableDefinition, +} from '@holo-js/cache-db' import { loadConfigDirectory } from '@holo-js/config' import { normalizeMigrationSlug } from '@holo-js/db' import { @@ -19,8 +26,8 @@ import { import { writeLine } from './io' import type { IoStreams } from './cli-types' -export const DEFAULT_CACHE_DATABASE_TABLE = 'cache' -export const DEFAULT_CACHE_DATABASE_LOCK_TABLE = 'cache_locks' +export const DEFAULT_CACHE_DATABASE_TABLE = CACHE_DB_DEFAULT_TABLE +export const DEFAULT_CACHE_DATABASE_LOCK_TABLE = CACHE_DB_DEFAULT_LOCK_TABLE type DatabaseCacheMigrationTables = { readonly table: string @@ -86,36 +93,61 @@ function escapeSingleQuotedString(value: string): string { return value.replaceAll('\\', '\\\\').replaceAll('\'', '\\\'') } +function renderCacheTableColumn(columnDefinition: CacheDatabaseTableColumnDefinition): string { + const calls = [ + `table.${columnDefinition.kind}('${escapeSingleQuotedString(columnDefinition.name)}')`, + ] + + if (columnDefinition.primaryKey) { + calls.push('primaryKey()') + } + + if (columnDefinition.nullable) { + calls.push('nullable()') + } + + return ` ${calls.join('.')}` +} + +function renderCacheTableCreateStatement( + tableName: string, + tableDefinition: CacheDatabaseTableDefinition, +): readonly string[] { + return [ + ` await schema.createTable('${escapeSingleQuotedString(tableName)}', (table) => {`, + ...tableDefinition.columns.map(renderCacheTableColumn), + ` table.index(['${escapeSingleQuotedString(tableDefinition.indexColumn)}'], '${escapeSingleQuotedString(tableDefinition.indexName(tableName))}')`, + ' })', + ] +} + +function resolveCacheDatabaseTableDefinition(role: CacheDatabaseTableDefinition['role']): CacheDatabaseTableDefinition { + const tableDefinition = CACHE_DATABASE_TABLE_DEFINITIONS.find(definition => definition.role === role) + if (!tableDefinition) { + throw new Error(`Missing cache database table definition for "${role}".`) + } + + return tableDefinition +} + export function renderCacheTableMigration( tableName = DEFAULT_CACHE_DATABASE_TABLE, lockTableName = DEFAULT_CACHE_DATABASE_LOCK_TABLE, ): string { - const escapedTableName = escapeSingleQuotedString(tableName) - const escapedLockTableName = escapeSingleQuotedString(lockTableName) - const escapedTableIndexName = escapeSingleQuotedString(`${tableName.replaceAll('.', '_')}_expires_at_index`) - const escapedLockTableIndexName = escapeSingleQuotedString(`${lockTableName.replaceAll('.', '_')}_expires_at_index`) + const entryTableDefinition = resolveCacheDatabaseTableDefinition('entries') + const lockTableDefinition = resolveCacheDatabaseTableDefinition('locks') return [ 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', '', 'export default defineMigration({', ' async up({ schema }: MigrationContext) {', - ` await schema.createTable('${escapedTableName}', (table) => {`, - ' table.string(\'key\').primaryKey()', - ' table.text(\'payload\')', - ' table.bigInteger(\'expires_at\').nullable()', - ` table.index(['expires_at'], '${escapedTableIndexName}')`, - ' })', - ` await schema.createTable('${escapedLockTableName}', (table) => {`, - ' table.string(\'name\').primaryKey()', - ' table.string(\'owner\')', - ' table.bigInteger(\'expires_at\')', - ` table.index(['expires_at'], '${escapedLockTableIndexName}')`, - ' })', + ...renderCacheTableCreateStatement(tableName, entryTableDefinition), + ...renderCacheTableCreateStatement(lockTableName, lockTableDefinition), ' },', ' async down({ schema }: MigrationContext) {', - ` await schema.dropTable('${escapedLockTableName}')`, - ` await schema.dropTable('${escapedTableName}')`, + ` await schema.dropTable('${escapeSingleQuotedString(lockTableName)}')`, + ` await schema.dropTable('${escapeSingleQuotedString(tableName)}')`, ' },', '})', '', diff --git a/packages/cli/src/cli-internals.ts b/packages/cli/src/cli-internals.ts deleted file mode 100644 index 9b07f4d9..00000000 --- a/packages/cli/src/cli-internals.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { - collectMultiStringFlag, - isInteractive, - normalizeChoice, - normalizeOptionalPackages, - parseNumberFlag, - parseTokens, - promptChoice, - promptOptionalPackages, - resolveBooleanFlag, - resolveNewProjectInput, - resolveStringFlag, - splitCsv, -} from './parsing' -import { - createEnvRuntimeConfig, - cacheProjectConfig, - createRuntimeInvocation, - dropAllTablesForFresh, - getRuntimeEnvironment, - getRuntimeFailureMessage, - inferRuntimeMigrationName, - mergeRuntimeDatabaseConfig, - normalizeRuntimeMigration, - parseBooleanEnv, - resolveConfigModuleUrl, - withRuntimeEnvironment, -} from './runtime' -import { - buildQueueWorkArgs, - collectQueueWatchRoots, - getQueueRuntimeEnvironment, - hasQueueRestartSignalSince, - isQueueListenRelevantPath, - readQueueRestartSignal, - resolveCliEntrypointPath, - resolveModuleExport, - resolveQueueRestartSignalPath, - resolveRunnableCliEntrypoint, - runQueueClearCommand, - runQueueFailedCommand, - runQueueFlushCommand, - runQueueForgetCommand, - runQueueListen, - runQueueRestartCommand, - runQueueRetryCommand, - runQueueWorkCommand, - writeQueueRestartSignal, -} from './queue' -import { - runBroadcastWorkCommand, -} from './broadcast' -import { - runRateLimitClearCommand, -} from './security' -import { - initializeCacheMaintenanceEnvironment, - loadCacheCliModule, - runCacheClearCommand, - runCacheForgetCommand, -} from './cache' -import { - cacheMigrationInternals, - DEFAULT_CACHE_DATABASE_LOCK_TABLE, - DEFAULT_CACHE_DATABASE_TABLE, - loadCacheConfig, - normalizeCacheMigrationName, - renderCacheTableMigration, - resolveDatabaseCacheTables, - runCacheTableCommand, -} from './cache-migrations' -import { - renderFailedJobsTableMigration, - renderQueueTableMigration, - resolveDatabaseQueueTables, - runQueueFailedTableCommand, - runQueueTableCommand, -} from './queue-migrations' -import { - resolvePackageManagerCommand, - resolvePackageManagerInstallInvocation, - runProjectDependencyInstall, - runProjectDevServer, - runProjectLifecycleScript, - runProjectPrepare, - isIgnorableWatchError, -} from './dev' -import { - runMakeBroadcast, - runMakeChannel, - runMakeEvent, - runMakeFactory, - runMakeJob, - runMakeListener, - runMakeMail, - runMakeMigration, - runMakeModel, - runMakeObserver, - runMakeSeeder, -} from './generators' -import { ensureAbsent, fileExists } from './fs-utils' -import { hasProjectDependency } from './package-json' -import { - getRegistryMigrationSlug, - hasRegisteredCreateTableMigration, - hasRegisteredMigrationSlug, - nextMigrationTemplate, -} from './migrations' -import { - createAppCommandDefinition, - createCommandContext, - createInternalCommands, - findCommand, - findCommandConflict, - commandTokens, - printCommandHelp, - printCommandList, - resolvePackageManagerDevCommand, - resolvePackageManagerInstallCommand, -} from './cli' - -export const cliInternals = { - cacheMigrationInternals, - cacheProjectConfig, - createAppCommandDefinition, - createCommandContext, - createEnvRuntimeConfig, - createInternalCommands, - createRuntimeInvocation, - initializeCacheMaintenanceEnvironment, - buildQueueWorkArgs, - collectQueueWatchRoots, - collectMultiStringFlag, - commandTokens, - dropAllTablesForFresh, - ensureAbsent, - fileExists, - findCommand, - findCommandConflict, - DEFAULT_CACHE_DATABASE_LOCK_TABLE, - DEFAULT_CACHE_DATABASE_TABLE, - getQueueRuntimeEnvironment, - getRegistryMigrationSlug, - getRuntimeEnvironment, - getRuntimeFailureMessage, - hasProjectDependency, - hasQueueRestartSignalSince, - hasRegisteredCreateTableMigration, - hasRegisteredMigrationSlug, - inferRuntimeMigrationName, - isIgnorableWatchError, - isInteractive, - isQueueListenRelevantPath, - loadCacheCliModule, - loadCacheConfig, - mergeRuntimeDatabaseConfig, - nextMigrationTemplate, - normalizeChoice, - normalizeCacheMigrationName, - normalizeOptionalPackages, - normalizeRuntimeMigration, - parseBooleanEnv, - parseNumberFlag, - parseTokens, - printCommandHelp, - printCommandList, - renderCacheTableMigration, - runCacheClearCommand, - runCacheForgetCommand, - promptChoice, - promptOptionalPackages, - readQueueRestartSignal, - renderFailedJobsTableMigration, - renderQueueTableMigration, - resolveBooleanFlag, - resolveCliEntrypointPath, - resolveDatabaseCacheTables, - resolveConfigModuleUrl, - resolveDatabaseQueueTables, - resolveModuleExport, - resolveNewProjectInput, - resolvePackageManagerCommand, - resolvePackageManagerDevCommand, - resolvePackageManagerInstallCommand, - resolvePackageManagerInstallInvocation, - resolveQueueRestartSignalPath, - resolveRunnableCliEntrypoint, - resolveStringFlag, - runMakeBroadcast, - runCacheTableCommand, - runMakeChannel, - runMakeEvent, - runMakeFactory, - runMakeJob, - runMakeListener, - runMakeMail, - runMakeMigration, - runMakeModel, - runMakeObserver, - runMakeSeeder, - runProjectDependencyInstall, - runProjectDevServer, - runProjectLifecycleScript, - runProjectPrepare, - runQueueClearCommand, - runQueueFailedCommand, - runQueueFailedTableCommand, - runQueueFlushCommand, - runQueueForgetCommand, - runQueueListen, - runQueueRestartCommand, - runQueueRetryCommand, - runQueueTableCommand, - runQueueWorkCommand, - runBroadcastWorkCommand, - runRateLimitClearCommand, - splitCsv, - withRuntimeEnvironment, - writeQueueRestartSignal, -} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 3f9c53fb..11dc850b 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -228,16 +228,27 @@ const projectExecutorLoaders: ProjectExecutorLoaderMap = { runProjectDependencyInstall: async () => (await loadDevModule()).runProjectDependencyInstall, } -async function resolveProjectExecutor( - projectExecutors: ProjectCommandExecutors, +async function resolveExecutor< + TExecutors extends Readonly>, + TKey extends keyof TExecutors & string, +>( + executors: TExecutors, + loaders: { readonly [TLoaderKey in TKey]: () => Promise> }, key: TKey, -): Promise> { - const existing = projectExecutors[key] +): Promise> { + const existing = executors[key] if (existing) { - return existing as NonNullable + return existing as NonNullable } - return projectExecutorLoaders[key]() + return await loaders[key]() +} + +async function resolveProjectExecutor( + projectExecutors: ProjectCommandExecutors, + key: TKey, +): Promise> { + return await resolveExecutor(projectExecutors, projectExecutorLoaders, key) } const queueExecutorLoaders: QueueExecutorLoaderMap = { @@ -271,48 +282,28 @@ async function resolveQueueExecutor( queueExecutors: QueueCommandExecutors, key: TKey, ): Promise> { - const existing = queueExecutors[key] - if (existing) { - return existing as NonNullable - } - - return queueExecutorLoaders[key]() + return await resolveExecutor(queueExecutors, queueExecutorLoaders, key) } async function resolveCacheExecutor( cacheExecutors: CacheCommandExecutors, key: TKey, ): Promise> { - const existing = cacheExecutors[key] - if (existing) { - return existing as NonNullable - } - - return cacheExecutorLoaders[key]() + return await resolveExecutor(cacheExecutors, cacheExecutorLoaders, key) } async function resolveMediaExecutor( mediaExecutors: MediaCommandExecutors, key: TKey, ): Promise> { - const existing = mediaExecutors[key] - if (existing) { - return existing as NonNullable - } - - return mediaExecutorLoaders[key]() + return await resolveExecutor(mediaExecutors, mediaExecutorLoaders, key) } async function resolveBroadcastExecutor( broadcastExecutors: BroadcastCommandExecutors, key: TKey, ): Promise> { - const existing = broadcastExecutors[key] - if (existing) { - return existing as NonNullable - } - - return broadcastExecutorLoaders[key]() + return await resolveExecutor(broadcastExecutors, broadcastExecutorLoaders, key) } async function resolveGeneratorCommand( diff --git a/packages/cli/src/dev.ts b/packages/cli/src/dev.ts index 4741a7ef..9e4f413a 100644 --- a/packages/cli/src/dev.ts +++ b/packages/cli/src/dev.ts @@ -141,6 +141,35 @@ type ProjectPrepareOptions = { readonly syncFramework?: boolean } +type FrameworkSyncDefinition = { + readonly framework: 'nuxt' | 'sveltekit' + readonly commands: Record + readonly errorLabel: string +} + +const FRAMEWORK_SYNC_DEFINITIONS = [ + { + framework: 'nuxt', + commands: { + bun: ['bun', 'x', 'nuxt', 'prepare'], + npm: ['npm', 'exec', '--', 'nuxt', 'prepare'], + pnpm: ['pnpm', 'exec', 'nuxt', 'prepare'], + yarn: ['yarn', 'run', 'nuxt', 'prepare'], + }, + errorLabel: 'nuxt prepare', + }, + { + framework: 'sveltekit', + commands: { + bun: ['bun', 'x', 'svelte-kit', 'sync'], + npm: ['npm', 'exec', '--', 'svelte-kit', 'sync'], + pnpm: ['pnpm', 'exec', 'svelte-kit', 'sync'], + yarn: ['yarn', 'run', 'svelte-kit', 'sync'], + }, + errorLabel: 'svelte-kit sync', + }, +] as const satisfies readonly FrameworkSyncDefinition[] + export async function runProjectPrepare( projectRoot: string, io?: IoStreams, @@ -153,8 +182,8 @@ export async function runProjectPrepare( const syncFramework = options.syncFramework ?? true if (syncFramework) { - await runNuxtPrepare(projectRoot) - await runSvelteKitSync(projectRoot) + await runFrameworkSync(projectRoot, FRAMEWORK_SYNC_DEFINITIONS[0]) + await runFrameworkSync(projectRoot, FRAMEWORK_SYNC_DEFINITIONS[1]) } const updatedDependencies = await syncManagedDriverDependencies(projectRoot) @@ -163,8 +192,8 @@ export async function runProjectPrepare( await prepareProjectDiscovery(projectRoot, project.config) await refreshFrameworkRunner(projectRoot) if (syncFramework) { - await runNuxtPrepare(projectRoot) - await runSvelteKitSync(projectRoot) + await runFrameworkSync(projectRoot, FRAMEWORK_SYNC_DEFINITIONS[0]) + await runFrameworkSync(projectRoot, FRAMEWORK_SYNC_DEFINITIONS[1]) } } } @@ -198,66 +227,13 @@ async function refreshFrameworkRunner(projectRoot: string): Promise { await writeTextFile(frameworkRunnerPath, renderFrameworkRunner({ framework })) } -async function runNuxtPrepare(projectRoot: string): Promise { - const frameworkProjectPath = resolve(projectRoot, '.holo-js/framework/project.json') - try { - const content = await readFile(frameworkProjectPath, 'utf8') - const manifest = JSON.parse(content) as { framework?: string } - - if (manifest.framework !== 'nuxt') { - return - } - } catch { - return - } - - const manager = await resolveProjectPackageManager(projectRoot) - let command: string - let args: string[] - switch (manager) { - case 'npm': - command = 'npm' - args = ['exec', '--', 'nuxt', 'prepare'] - break - case 'pnpm': - command = 'pnpm' - args = ['exec', 'nuxt', 'prepare'] - break - case 'yarn': - command = 'yarn' - args = ['run', 'nuxt', 'prepare'] - break - case 'bun': - default: - command = 'bun' - args = ['x', 'nuxt', 'prepare'] - break - } - - const { spawn } = await import('node:child_process') - await new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd: projectRoot, - stdio: 'inherit', - }) - child.on('close', code => { - if (code === 0) { - resolve(undefined) - } else { - reject(new Error(`nuxt prepare exited with ${code}`)) - } - }) - child.on('error', reject) - }) -} - -async function runSvelteKitSync(projectRoot: string): Promise { +async function runFrameworkSync(projectRoot: string, definition: FrameworkSyncDefinition): Promise { const frameworkProjectPath = resolve(projectRoot, '.holo-js/framework/project.json') try { const content = await readFile(frameworkProjectPath, 'utf8') const manifest = JSON.parse(content) as { framework?: string } - if (manifest.framework !== 'sveltekit') { + if (manifest.framework !== definition.framework) { return } } catch { @@ -265,39 +241,20 @@ async function runSvelteKitSync(projectRoot: string): Promise { } const manager = await resolveProjectPackageManager(projectRoot) - let command: string - let args: string[] - switch (manager) { - case 'npm': - command = 'npm' - args = ['exec', '--', 'svelte-kit', 'sync'] - break - case 'pnpm': - command = 'pnpm' - args = ['exec', 'svelte-kit', 'sync'] - break - case 'yarn': - command = 'yarn' - args = ['run', 'svelte-kit', 'sync'] - break - case 'bun': - default: - command = 'bun' - args = ['x', 'svelte-kit', 'sync'] - break - } + const invocation = definition.commands[manager] + const command = invocation[0] + const args = invocation.slice(1) - const { spawn } = await import('node:child_process') - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: projectRoot, stdio: 'inherit', }) - child.on('close', code => { + child.on('close', (code: number | null) => { if (code === 0) { resolve(undefined) } else { - reject(new Error(`svelte-kit sync exited with ${code}`)) + reject(new Error(`${definition.errorLabel} exited with ${code}`)) } }) child.on('error', reject) @@ -337,6 +294,26 @@ function resolveConfiguredRealtimePath(project: LoadedProjectConfig): string { return configuredPaths.realtime ?? 'server/realtime' } +function resolveConfiguredDiscoveryRoots(project: LoadedProjectConfig): readonly string[] { + const authorizationPoliciesPath = project.config.paths.authorizationPolicies || 'server/policies' + const authorizationAbilitiesPath = project.config.paths.authorizationAbilities || 'server/abilities' + return [ + project.config.paths.models, + project.config.paths.migrations, + project.config.paths.seeders, + project.config.paths.commands, + project.config.paths.jobs, + project.config.paths.events, + project.config.paths.listeners, + authorizationPoliciesPath, + authorizationAbilitiesPath, + resolveConfiguredBroadcastPath(project), + resolveConfiguredChannelsPath(project), + resolveConfiguredRealtimePath(project), + 'config', + ] +} + export function isDiscoveryRelevantPath( filePath: string, project: LoadedProjectConfig, @@ -355,32 +332,11 @@ export function isDiscoveryRelevantPath( return false } - const authorizationPoliciesPath = project.config.paths.authorizationPolicies || 'server/policies' - const authorizationAbilitiesPath = project.config.paths.authorizationAbilities || 'server/abilities' - const broadcastPath = resolveConfiguredBroadcastPath(project) - const channelsPath = resolveConfiguredChannelsPath(project) - const realtimePath = resolveConfiguredRealtimePath(project) - const roots = [ - project.config.paths.models, - project.config.paths.migrations, - project.config.paths.seeders, - project.config.paths.commands, - project.config.paths.jobs, - project.config.paths.events, - project.config.paths.listeners, - authorizationPoliciesPath, - authorizationAbilitiesPath, - broadcastPath, - channelsPath, - realtimePath, - 'config', - ] - if (normalized === '.env' || normalized.startsWith('.env.')) { return true } - return roots.some(root => normalized === root || normalized.startsWith(`${toPosixSlashes(root)}/`)) + return resolveConfiguredDiscoveryRoots(project).some(root => normalized === root || normalized.startsWith(`${toPosixSlashes(root)}/`)) } export function isRecursiveWatchUnsupported(error: unknown): boolean { @@ -422,26 +378,9 @@ export async function collectDiscoveryWatchRoots( project: LoadedProjectConfig, ): Promise { const directories = new Set() - const authorizationPoliciesPath = project.config.paths.authorizationPolicies || 'server/policies' - const authorizationAbilitiesPath = project.config.paths.authorizationAbilities || 'server/abilities' - const broadcastPath = resolveConfiguredBroadcastPath(project) - const channelsPath = resolveConfiguredChannelsPath(project) - const realtimePath = resolveConfiguredRealtimePath(project) const roots = [ projectRoot, - resolve(projectRoot, 'config'), - resolve(projectRoot, project.config.paths.models), - resolve(projectRoot, project.config.paths.migrations), - resolve(projectRoot, project.config.paths.seeders), - resolve(projectRoot, project.config.paths.commands), - resolve(projectRoot, project.config.paths.jobs), - resolve(projectRoot, project.config.paths.events), - resolve(projectRoot, project.config.paths.listeners), - resolve(projectRoot, authorizationPoliciesPath), - resolve(projectRoot, authorizationAbilitiesPath), - resolve(projectRoot, broadcastPath), - resolve(projectRoot, channelsPath), - resolve(projectRoot, realtimePath), + ...resolveConfiguredDiscoveryRoots(project).map(root => resolve(projectRoot, root)), resolve(projectRoot, dirname(project.config.paths.generatedSchema ?? '.holo-js/generated/schema.generated.ts')), ] diff --git a/packages/cli/src/generators.ts b/packages/cli/src/generators.ts index 8ff0ed9c..27048330 100644 --- a/packages/cli/src/generators.ts +++ b/packages/cli/src/generators.ts @@ -45,7 +45,6 @@ import { collectFiles } from './project/discovery-helpers' import type { IoStreams, PreparedInput } from './cli-types' type MailTemplateType = 'markdown' | 'view' -type KnownMailViewFramework = 'nuxt' | 'next' | 'sveltekit' const MAIL_VIEW_SCAFFOLDING_UNAVAILABLE_MESSAGE = 'View-backed mail scaffolding requires a renderView runtime binding, which the first-party app scaffolds do not configure yet. Use "--markdown" instead.' @@ -284,36 +283,6 @@ function resolveConfiguredChannelsPath(project: Awaited { - try { - const packageJson = await readFile(resolve(projectRoot, 'package.json'), 'utf8') - const parsed = JSON.parse(packageJson) as { - dependencies?: Record - devDependencies?: Record - } - const dependencies = { - ...(parsed.dependencies ?? {}), - ...(parsed.devDependencies ?? {}), - } - - if (typeof dependencies.nuxt === 'string') { - return 'nuxt' - } - - if (typeof dependencies.next === 'string') { - return 'next' - } - - if (typeof dependencies['@sveltejs/kit'] === 'string') { - return 'sveltekit' - } - } catch { - return 'generic' - } - - return 'generic' -} - export async function runMakeModel( io: IoStreams, projectRoot: string, @@ -741,6 +710,5 @@ export async function runMakeMail( } export const generatorInternals = { - resolveProjectMailViewFramework, toChannelTemplateFileStem, } diff --git a/packages/cli/src/media-migrations.ts b/packages/cli/src/media-migrations.ts index bd0c6414..7f198a2a 100644 --- a/packages/cli/src/media-migrations.ts +++ b/packages/cli/src/media-migrations.ts @@ -2,7 +2,6 @@ import { readdir } from 'node:fs/promises' import { resolve } from 'node:path' import { normalizeMigrationSlug } from '@holo-js/db' import { - getRegistryMigrationSlug, hasRegisteredCreateTableMigration, hasRegisteredMigrationSlug, nextMigrationTemplate, @@ -74,6 +73,20 @@ async function hasMigrationFile(migrationsDir: string, migrationName: string): P )) } +export function createMediaTableMigration( + projectRoot: string, + options?: { + readonly skipIfExists?: false + }, +): Promise + +export function createMediaTableMigration( + projectRoot: string, + options: { + readonly skipIfExists: true + }, +): Promise + export async function createMediaTableMigration( projectRoot: string, options: { @@ -116,18 +129,7 @@ export async function runMediaTableCommand( const migrationFilePath = await createMediaTableMigration(projectRoot) const { runProjectPrepare } = await import('./dev') - if (!migrationFilePath) { - throw new Error(`A migration for table "${DEFAULT_MEDIA_TABLE}" already exists.`) - } - await runProjectPrepare(projectRoot) writeLine(io.stdout, `Created migration: ${makeProjectRelativePath(projectRoot, migrationFilePath)}`) } - -export const mediaMigrationInternals = { - getRegistryMigrationSlug, - hasRegisteredMigrationSlug, - hasRegisteredCreateTableMigration, - nextMigrationTemplate, -} diff --git a/packages/cli/src/project/discovery-helpers.ts b/packages/cli/src/project/discovery-helpers.ts index c510eabc..8e6d1380 100644 --- a/packages/cli/src/project/discovery-helpers.ts +++ b/packages/cli/src/project/discovery-helpers.ts @@ -63,8 +63,8 @@ export function deriveJobNameFromPath(jobsRoot: string, sourcePath: string): str .join('.') } -export function deriveEventNameFromPath(eventsRoot: string, sourcePath: string): string { - const relativePath = toPosixPath(relative(eventsRoot, sourcePath)).replace(COMMAND_FILE_PATTERN, '') +function deriveDottedNameFromPath(root: string, sourcePath: string, emptyMessage: string): string { + const relativePath = toPosixPath(relative(root, sourcePath)).replace(COMMAND_FILE_PATTERN, '') const derived = relativePath .split('/') .map(part => part.trim()) @@ -72,58 +72,26 @@ export function deriveEventNameFromPath(eventsRoot: string, sourcePath: string): .join('.') if (!derived) { - throw new Error('[Holo Events] Derived event names require a non-empty source path.') + throw new Error(emptyMessage) } return derived } -export function deriveListenerIdFromPath(listenersRoot: string, sourcePath: string): string { - const relativePath = toPosixPath(relative(listenersRoot, sourcePath)) - const derived = relativePath - .replace(COMMAND_FILE_PATTERN, '') - .split('/') - .map(part => part.trim()) - .filter(Boolean) - .join('.') - - if (!derived) { - throw new Error('[Holo Events] Derived listener identifiers require a non-empty source path.') - } +export function deriveEventNameFromPath(eventsRoot: string, sourcePath: string): string { + return deriveDottedNameFromPath(eventsRoot, sourcePath, '[Holo Events] Derived event names require a non-empty source path.') +} - return derived +export function deriveListenerIdFromPath(listenersRoot: string, sourcePath: string): string { + return deriveDottedNameFromPath(listenersRoot, sourcePath, '[Holo Events] Derived listener identifiers require a non-empty source path.') } export function deriveBroadcastNameFromPath(root: string, sourcePath: string): string { - const relativePath = toPosixPath(relative(root, sourcePath)).replace(COMMAND_FILE_PATTERN, '') - const derived = relativePath - .split('/') - .map(part => part.trim()) - .filter(Boolean) - .join('.') - - /* v8 ignore next 3 -- discovered file paths always produce a non-empty derived broadcast name */ - if (!derived) { - throw new Error('[Holo Broadcast] Derived broadcast names require a non-empty source path.') - } - - return derived + return deriveDottedNameFromPath(root, sourcePath, '[Holo Broadcast] Derived broadcast names require a non-empty source path.') } export function deriveChannelPatternFromPath(root: string, sourcePath: string): string { - const relativePath = toPosixPath(relative(root, sourcePath)).replace(COMMAND_FILE_PATTERN, '') - const derived = relativePath - .split('/') - .map(part => part.trim()) - .filter(Boolean) - .join('.') - - /* v8 ignore next 3 -- discovered file paths always produce a non-empty derived channel pattern */ - if (!derived) { - throw new Error('[Holo Broadcast] Derived channel patterns require a non-empty source path.') - } - - return derived + return deriveDottedNameFromPath(root, sourcePath, '[Holo Broadcast] Derived channel patterns require a non-empty source path.') } export function resolveDiscoveredJobMetadata( diff --git a/packages/cli/src/project/scaffold/dependencies.ts b/packages/cli/src/project/scaffold/dependencies.ts index 8fca0ead..00927b68 100644 --- a/packages/cli/src/project/scaffold/dependencies.ts +++ b/packages/cli/src/project/scaffold/dependencies.ts @@ -24,6 +24,8 @@ import { import { loadGeneratedProjectRegistry } from '../registry' import type { LoadedConfigWithCache } from './types' +type ManagedHoloPackageName = `@holo-js/${string}` + function normalizeDependencyMap(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {} @@ -580,68 +582,36 @@ async function upsertEventsPackageDependency(projectRoot: string): Promise { +async function upsertManagedPackageDependency(projectRoot: string, packageName: ManagedHoloPackageName): Promise { const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) - const nextVersion = resolveManagedHoloPackageVersion('@holo-js/notifications', dependencies, devDependencies) - const currentVersion = dependencies['@holo-js/notifications'] - const currentDevVersion = devDependencies['@holo-js/notifications'] + const nextVersion = resolveManagedHoloPackageVersion(packageName, dependencies, devDependencies) + const currentVersion = dependencies[packageName] + const currentDevVersion = devDependencies[packageName] if (currentVersion === nextVersion && typeof currentDevVersion === 'undefined') { return false } - dependencies['@holo-js/notifications'] = nextVersion - delete devDependencies['@holo-js/notifications'] + dependencies[packageName] = nextVersion + delete devDependencies[packageName] await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) return true } -async function upsertMailPackageDependency(projectRoot: string): Promise { - const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) - const nextVersion = resolveManagedHoloPackageVersion('@holo-js/mail', dependencies, devDependencies) - const currentVersion = dependencies['@holo-js/mail'] - const currentDevVersion = devDependencies['@holo-js/mail'] - - if (currentVersion === nextVersion && typeof currentDevVersion === 'undefined') { - return false - } +async function upsertNotificationsPackageDependency(projectRoot: string): Promise { + return await upsertManagedPackageDependency(projectRoot, '@holo-js/notifications') +} - dependencies['@holo-js/mail'] = nextVersion - delete devDependencies['@holo-js/mail'] - await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) - return true +async function upsertMailPackageDependency(projectRoot: string): Promise { + return await upsertManagedPackageDependency(projectRoot, '@holo-js/mail') } async function upsertMediaPackageDependency(projectRoot: string): Promise { - const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) - const nextVersion = resolveManagedHoloPackageVersion('@holo-js/media', dependencies, devDependencies) - const currentVersion = dependencies['@holo-js/media'] - const currentDevVersion = devDependencies['@holo-js/media'] - - if (currentVersion === nextVersion && typeof currentDevVersion === 'undefined') { - return false - } - - dependencies['@holo-js/media'] = nextVersion - delete devDependencies['@holo-js/media'] - await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) - return true + return await upsertManagedPackageDependency(projectRoot, '@holo-js/media') } async function upsertSecurityPackageDependency(projectRoot: string): Promise { - const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) - const nextVersion = resolveManagedHoloPackageVersion('@holo-js/security', dependencies, devDependencies) - const currentVersion = dependencies['@holo-js/security'] - const currentDevVersion = devDependencies['@holo-js/security'] - - if (currentVersion === nextVersion && typeof currentDevVersion === 'undefined') { - return false - } - - dependencies['@holo-js/security'] = nextVersion - delete devDependencies['@holo-js/security'] - await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) - return true + return await upsertManagedPackageDependency(projectRoot, '@holo-js/security') } async function upsertCachePackageDependencies( @@ -873,20 +843,7 @@ async function upsertAuthPackageDependencies( } async function upsertAuthorizationPackageDependency(projectRoot: string): Promise { - const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) - const nextVersion = resolveManagedHoloPackageVersion('@holo-js/authorization', dependencies, devDependencies) - const currentVersion = dependencies['@holo-js/authorization'] - const currentDevVersion = devDependencies['@holo-js/authorization'] - - if (currentVersion === nextVersion && typeof currentDevVersion === 'undefined') { - return false - } - - dependencies['@holo-js/authorization'] = nextVersion - delete devDependencies['@holo-js/authorization'] - - await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) - return true + return await upsertManagedPackageDependency(projectRoot, '@holo-js/authorization') } async function upsertRealtimePackageDependency(projectRoot: string): Promise { diff --git a/packages/cli/src/project/scaffold/framework-renderers.ts b/packages/cli/src/project/scaffold/framework-renderers.ts index 5d55c8ef..ba811aa0 100644 --- a/packages/cli/src/project/scaffold/framework-renderers.ts +++ b/packages/cli/src/project/scaffold/framework-renderers.ts @@ -792,7 +792,6 @@ function renderSvelteHostedAuthRouteFiles(provider: HostedAuthProvider): readonl { path: `src/routes/api/auth/${provider}/register/+server.ts`, contents: renderSvelteHostedAuthRegisterRoute(spec) }, { path: `src/routes/api/auth/${provider}/callback/+server.ts`, contents: renderSvelteHostedAuthCallbackRoute(spec) }, { path: `src/routes/api/auth/${provider}/logout/+server.ts`, contents: renderSvelteHostedAuthLogoutRoute(spec) }, - ...renderSvelteManagedRuntimeFiles(), ] } @@ -828,9 +827,7 @@ export function renderAuthRouteFiles(framework: ProjectScaffoldOptions['framewor ] } - return [ - ...renderSvelteManagedRuntimeFiles(), - ] + return [] } export function renderFrameworkFiles(options: ProjectScaffoldOptions): readonly ScaffoldedFile[] { diff --git a/packages/cli/src/queue-migrations.ts b/packages/cli/src/queue-migrations.ts index b822aaba..0fc82450 100644 --- a/packages/cli/src/queue-migrations.ts +++ b/packages/cli/src/queue-migrations.ts @@ -112,15 +112,16 @@ export async function runQueueTableCommand( const queueConfig = await loadQueueConfig(projectRoot) const migrationsDir = resolve(projectRoot, project.config.paths.migrations) const createdFiles: string[] = [] + const tableNames = resolveDatabaseQueueTables(queueConfig) - for (const tableName of resolveDatabaseQueueTables(queueConfig)) { + for (const tableName of tableNames) { const migrationName = normalizeQueueMigrationName(tableName) if (hasRegisteredMigrationSlug(registry, migrationName) || hasRegisteredCreateTableMigration(registry, tableName)) { throw new Error(`A migration for table "${tableName}" already exists.`) } } - for (const tableName of resolveDatabaseQueueTables(queueConfig)) { + for (const tableName of tableNames) { const migrationTemplate = await nextMigrationTemplate(normalizeQueueMigrationName(tableName), migrationsDir) const migrationFilePath = resolveDefaultArtifactPath(projectRoot, project.config.paths.migrations, migrationTemplate.fileName) await writeTextFile(migrationFilePath, renderQueueTableMigration(tableName)) diff --git a/packages/cli/src/runtime-worker.ts b/packages/cli/src/runtime-worker.ts new file mode 100644 index 00000000..d8aeb3f9 --- /dev/null +++ b/packages/cli/src/runtime-worker.ts @@ -0,0 +1,375 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { + configureDB, + createMigrationService, + createSchemaService, + createSeederService, + registerGeneratedTables, + renderGeneratedSchemaModule, + resetDB, + resolveRuntimeConnectionManagerOptions, + type TableDefinition, +} from '@holo-js/db' + +type RuntimeConfigPayload = Parameters[0] + +type RuntimePayload = { + readonly kind?: string + readonly projectRoot?: string + readonly runtimeConfig?: RuntimeConfigPayload + readonly models?: readonly string[] + readonly migrations?: readonly string[] + readonly seeders?: readonly string[] + readonly generatedSchema?: string + readonly generatedSchemaOutputPath?: string + readonly options?: Record +} + +type RuntimeMigration = Record & { + readonly name: string + up(...args: unknown[]): unknown +} + +type RuntimeSeeder = { + readonly name: string + run(...args: unknown[]): unknown +} + +type RuntimeModel = { + readonly definition: { + readonly name: string + readonly kind: 'model' + readonly prunable?: boolean + } + prune(): Promise | number +} + +type FreshDropConnection = { + getDialect(): { + name: string + quoteIdentifier(identifier: string): string + } + getSchemaName(): string | undefined + executeCompiled(statement: { sql: string, source: string }): Promise +} + +type FreshDropSchema = { + getTables(): Promise + dropTable(tableName: string): Promise + withoutForeignKeyConstraints(callback: () => TResult | Promise): Promise +} + +const payload = JSON.parse(process.env.HOLO_RUNTIME_PAYLOAD ?? '{}') as RuntimePayload + +if (typeof payload.projectRoot === 'string') { + process.chdir(payload.projectRoot) +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +async function loadModule(path: string): Promise { + return import(`${path}?t=${Date.now()}`) +} + +function resolveExport( + moduleValue: unknown, + matcher: (value: unknown) => value is TValue, +): TValue | undefined { + if (isRecord(moduleValue) && matcher(moduleValue.default)) { + return moduleValue.default + } + + if (isRecord(moduleValue)) { + for (const value of Object.values(moduleValue)) { + if (matcher(value)) { + return value + } + } + } + + return undefined +} + +function isModel(value: unknown): value is RuntimeModel { + return isRecord(value) + && isRecord(value.definition) + && value.definition.kind === 'model' + && typeof value.definition.name === 'string' + && typeof value.prune === 'function' +} + +function isMigration(value: unknown): value is RuntimeMigration { + return isRecord(value) && typeof value.up === 'function' +} + +function isSeeder(value: unknown): value is RuntimeSeeder { + return isRecord(value) && typeof value.name === 'string' && typeof value.run === 'function' +} + +function isTable(value: unknown): value is TableDefinition { + return isRecord(value) + && value.kind === 'table' + && typeof value.tableName === 'string' + && isRecord(value.columns) + && Array.isArray(value.indexes) +} + +function extractTables(moduleValue: unknown): TableDefinition[] { + if (isRecord(moduleValue) && isRecord(moduleValue.tables)) { + return Object.values(moduleValue.tables).filter(isTable) + } + + if (isRecord(moduleValue) && isTable(moduleValue.default)) { + return [moduleValue.default] + } + + if (isRecord(moduleValue)) { + return Object.values(moduleValue).filter(isTable) + } + + return [] +} + +const RUNTIME_MIGRATION_NAME_PATTERN = /^\d{4}_\d{2}_\d{2}_\d{6}_[a-z0-9_]+$/ + +function inferRuntimeMigrationName(entry: string): string { + const fileName = entry.split('/').pop()?.replace(/\.[^.]+$/, '') + if (!fileName || !RUNTIME_MIGRATION_NAME_PATTERN.test(fileName)) { + throw new Error(`Registered migration "${entry}" must use a timestamped file name matching YYYY_MM_DD_HHMMSS_description.`) + } + + return fileName +} + +function normalizeRuntimeMigration( + entry: string, + migration: RuntimeMigration, +): RuntimeMigration { + return { + ...migration, + name: typeof migration.name === 'string' ? migration.name : inferRuntimeMigrationName(entry), + } +} + +function compileFreshDropIdentifierPath( + quoteIdentifier: (identifier: string) => string, + identifier: string, +): string { + if (!identifier.includes('.')) { + return quoteIdentifier(identifier) + } + + return identifier + .split('.') + .map(part => quoteIdentifier(part)) + .join('.') +} + +async function dropAllTablesForFresh( + connection: FreshDropConnection, + schema: FreshDropSchema, +): Promise { + const tables = await schema.getTables() + if (connection.getDialect().name === 'postgres') { + const schemaName = connection.getSchemaName() + const quoteIdentifier = connection.getDialect().quoteIdentifier + + for (const tableName of tables) { + const qualifiedTableName = schemaName ? `${schemaName}.${tableName}` : tableName + await connection.executeCompiled({ + sql: `DROP TABLE IF EXISTS ${compileFreshDropIdentifierPath(quoteIdentifier, qualifiedTableName)} CASCADE`, + source: `schema:dropTableFresh:${qualifiedTableName}`, + }) + } + return + } + + await schema.withoutForeignKeyConstraints(async () => { + for (const tableName of tables) { + await schema.dropTable(tableName) + } + }) +} + +async function preloadGeneratedSchema( + manager: ReturnType, + entry: string | undefined, +): Promise { + if (!entry) { + return + } + + const tables = extractTables(await loadModule(entry)) + for (const table of tables) { + manager.connection().getSchemaRegistry().replace(table) + } +} + +async function writeGeneratedSchemaArtifact( + manager: ReturnType, + outputPath: string | undefined, +): Promise { + if (!outputPath) { + return + } + + const source = renderGeneratedSchemaModule(manager.connection().getSchemaRegistry().list()) + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, source, 'utf8') +} + +function syncGeneratedSchemaFromManager( + manager: ReturnType, +): void { + registerGeneratedTables(Object.fromEntries( + manager.connection().getSchemaRegistry().list().map(table => [table.tableName, table]), + )) +} + +async function loadRuntimeItems( + entries: readonly string[], + matcher: (value: unknown) => value is TValue, + label: string, +): Promise { + const items: TValue[] = [] + + for (const entry of entries) { + const item = resolveExport(await loadModule(entry), matcher) + if (!item) { + throw new Error(`Registered ${label} "${entry}" does not export a Holo ${label}.`) + } + items.push(item) + } + + return items +} + +async function loadMigrations(entries: readonly string[]): Promise { + const migrations = await loadRuntimeItems(entries, isMigration, 'migration') + return migrations.map((migration, index) => normalizeRuntimeMigration(entries[index] ?? '', migration)) +} + +async function loadSeeders(entries: readonly string[]): Promise { + return loadRuntimeItems(entries, isSeeder, 'seeder') +} + +function printExecutedItems( + items: readonly { readonly name: string }[], + emptyMessage: string, + header: string, +): void { + if (items.length === 0) { + writeOutput(emptyMessage) + return + } + + writeOutput(header) + for (const item of items) { + writeOutput(` ${item.name}`) + } +} + +function payloadEntries(entries: readonly string[] | undefined): readonly string[] { + return entries ?? [] +} + +function resolveRuntimeConfig(runtimeConfig: RuntimeConfigPayload | undefined): RuntimeConfigPayload { + if (!runtimeConfig) { + throw new Error('Runtime payload is missing database configuration.') + } + + return runtimeConfig +} + +function writeOutput(message: string): void { + process.stdout.write(`${message}\n`) +} + +const manager = resolveRuntimeConnectionManagerOptions(resolveRuntimeConfig(payload.runtimeConfig)) +configureDB(manager) + +try { + await manager.initializeAll() + + if (payload.kind === 'migrate') { + await preloadGeneratedSchema(manager, payload.generatedSchema) + const migrations = await loadMigrations(payloadEntries(payload.migrations)) + const executed = await createMigrationService(manager.connection(), migrations).migrate(payload.options ?? {}) + await writeGeneratedSchemaArtifact(manager, payload.generatedSchemaOutputPath) + printExecutedItems(executed, 'No migrations were executed.', 'Migrations executed:') + } else if (payload.kind === 'fresh') { + const migrations = await loadMigrations(payloadEntries(payload.migrations)) + const schema = createSchemaService(manager.connection()) + await dropAllTablesForFresh(manager.connection(), schema) + manager.connection().getSchemaRegistry().clear() + + const executed = await createMigrationService(manager.connection(), migrations).migrate({}) + await writeGeneratedSchemaArtifact(manager, payload.generatedSchemaOutputPath) + syncGeneratedSchemaFromManager(manager) + printExecutedItems(executed, 'No migrations were executed.', 'Migrations executed:') + + if (payload.options?.seed) { + const seeded = await createSeederService(manager.connection(), await loadSeeders(payloadEntries(payload.seeders))).seed({ + ...(Array.isArray(payload.options.only) ? { only: payload.options.only } : {}), + quietly: payload.options.quietly === true, + force: payload.options.force === true, + environment: typeof payload.options.environment === 'string' ? payload.options.environment : 'development', + }) + printExecutedItems(seeded, 'No seeders were executed.', 'Seeders executed:') + } + } else if (payload.kind === 'rollback') { + await preloadGeneratedSchema(manager, payload.generatedSchema) + const migrations = await loadMigrations(payloadEntries(payload.migrations)) + const rolledBack = await createMigrationService(manager.connection(), migrations).rollback(payload.options ?? {}) + await writeGeneratedSchemaArtifact(manager, payload.generatedSchemaOutputPath) + printExecutedItems(rolledBack, 'No migrations were rolled back.', 'Migrations rolled back:') + } else if (payload.kind === 'seed') { + await preloadGeneratedSchema(manager, payload.generatedSchema) + const executed = await createSeederService(manager.connection(), await loadSeeders(payloadEntries(payload.seeders))).seed(payload.options ?? {}) + printExecutedItems(executed, 'No seeders were executed.', 'Seeders executed:') + } else if (payload.kind === 'prune') { + const models = await loadRuntimeItems(payloadEntries(payload.models), isModel, 'model') + const byName = new Map(models.map(model => [model.definition.name, model])) + const requested = Array.isArray(payload.options?.models) ? payload.options.models : [] + const selected: RuntimeModel[] = [] + + if (requested.length === 0) { + selected.push(...models.filter(model => Boolean(model.definition.prunable))) + } else { + for (const name of requested) { + if (typeof name !== 'string') { + continue + } + + const model = byName.get(name) + if (!model) { + throw new Error(`Unknown model "${name}".`) + } + if (!model.definition.prunable) { + throw new Error(`Model "${name}" does not define a prunable query.`) + } + selected.push(model) + } + } + + if (selected.length === 0) { + writeOutput('No prunable models were registered.') + } else { + let total = 0 + for (const model of selected) { + const deleted = await model.prune() + total += deleted + writeOutput(`${model.definition.name}: deleted ${deleted}`) + } + writeOutput(`Total deleted: ${total}`) + } + } else { + throw new Error(`Unknown runtime command "${payload.kind}".`) + } +} finally { + await manager.disconnectAll() + resetDB() +} diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index 77fdbd87..332d5b27 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -270,272 +270,6 @@ export async function getRuntimeEnvironment(projectRoot: string): Promise isRecord(value) && isRecord(value.definition) && value.definition.kind === 'model' && typeof value.prune === 'function' -const isMigration = (value) => isRecord(value) && typeof value.up === 'function' -const isSeeder = (value) => isRecord(value) && typeof value.name === 'string' && typeof value.run === 'function' -const isTable = (value) => isRecord(value) && value.kind === 'table' && typeof value.tableName === 'string' && isRecord(value.columns) -const RUNTIME_MIGRATION_NAME_PATTERN = ${RUNTIME_MIGRATION_NAME_PATTERN} -const inferRuntimeMigrationName = ${inferRuntimeMigrationName.toString()} -const normalizeRuntimeMigration = ${normalizeRuntimeMigration.toString()} -const compileFreshDropIdentifierPath = ${compileFreshDropIdentifierPath.toString()} -const dropAllTablesForFresh = ${dropAllTablesForFresh.toString()} - -function extractTables(moduleValue) { - if (isRecord(moduleValue) && isRecord(moduleValue.tables)) { - return Object.values(moduleValue.tables).filter(isTable) - } - - if (isRecord(moduleValue) && isTable(moduleValue.default)) { - return [moduleValue.default] - } - - if (isRecord(moduleValue)) { - return Object.values(moduleValue).filter(isTable) - } - - return [] -} - -async function preloadGeneratedSchema(manager, entry) { - if (!entry) { - return - } - - const tables = extractTables(await loadModule(entry)) - for (const table of tables) { - manager.connection().getSchemaRegistry().replace(table) - } -} - -async function writeGeneratedSchemaArtifact(manager, outputPath) { - if (!outputPath) { - return - } - - const source = renderGeneratedSchemaModule(manager.connection().getSchemaRegistry().list()) - await mkdir(dirname(outputPath), { recursive: true }) - await writeFile(outputPath, source, 'utf8') -} - -function syncGeneratedSchemaFromManager(manager) { - registerGeneratedTables(Object.fromEntries( - manager.connection().getSchemaRegistry().list().map(table => [table.tableName, table]), - )) -} - -const manager = resolveRuntimeConnectionManagerOptions(payload.runtimeConfig) -configureDB(manager) - -try { - await manager.initializeAll() - - if (payload.kind === 'migrate') { - await preloadGeneratedSchema(manager, payload.generatedSchema) - const migrations = [] - for (const entry of payload.migrations) { - const migration = resolveExport(await loadModule(entry), isMigration) - if (!migration) { - throw new Error(\`Registered migration "\${entry}" does not export a Holo migration.\`) - } - migrations.push(normalizeRuntimeMigration(entry, migration)) - } - - const executed = await createMigrationService(manager.connection(), migrations).migrate(payload.options ?? {}) - await writeGeneratedSchemaArtifact(manager, payload.generatedSchemaOutputPath) - if (executed.length === 0) { - console.log('No migrations were executed.') - } else { - console.log('Migrations executed:') - for (const item of executed) { - console.log(\` \${item.name}\`) - } - } - } else if (payload.kind === 'fresh') { - const migrations = [] - for (const entry of payload.migrations) { - const migration = resolveExport(await loadModule(entry), isMigration) - if (!migration) { - throw new Error(\`Registered migration "\${entry}" does not export a Holo migration.\`) - } - migrations.push(normalizeRuntimeMigration(entry, migration)) - } - - const schema = createSchemaService(manager.connection()) - await dropAllTablesForFresh(manager.connection(), schema) - manager.connection().getSchemaRegistry().clear() - - const executed = await createMigrationService(manager.connection(), migrations).migrate({}) - await writeGeneratedSchemaArtifact(manager, payload.generatedSchemaOutputPath) - syncGeneratedSchemaFromManager(manager) - if (executed.length === 0) { - console.log('No migrations were executed.') - } else { - console.log('Migrations executed:') - for (const item of executed) { - console.log(\` \${item.name}\`) - } - } - - if (payload.options?.seed) { - const seeders = [] - for (const entry of payload.seeders) { - const seeder = resolveExport(await loadModule(entry), isSeeder) - if (!seeder) { - throw new Error(\`Registered seeder "\${entry}" does not export a Holo seeder.\`) - } - seeders.push(seeder) - } - - const seeded = await createSeederService(manager.connection(), seeders).seed({ - ...(Array.isArray(payload.options.only) ? { only: payload.options.only } : {}), - quietly: payload.options.quietly === true, - force: payload.options.force === true, - environment: payload.options.environment ?? 'development', - }) - if (seeded.length === 0) { - console.log('No seeders were executed.') - } else { - console.log('Seeders executed:') - for (const item of seeded) { - console.log(\` \${item.name}\`) - } - } - } - } else if (payload.kind === 'rollback') { - await preloadGeneratedSchema(manager, payload.generatedSchema) - const migrations = [] - for (const entry of payload.migrations) { - const migration = resolveExport(await loadModule(entry), isMigration) - if (!migration) { - throw new Error(\`Registered migration "\${entry}" does not export a Holo migration.\`) - } - migrations.push(normalizeRuntimeMigration(entry, migration)) - } - - const rolledBack = await createMigrationService(manager.connection(), migrations).rollback(payload.options ?? {}) - await writeGeneratedSchemaArtifact(manager, payload.generatedSchemaOutputPath) - if (rolledBack.length === 0) { - console.log('No migrations were rolled back.') - } else { - console.log('Migrations rolled back:') - for (const item of rolledBack) { - console.log(\` \${item.name}\`) - } - } - } else if (payload.kind === 'seed') { - if (payload.generatedSchema) { - await preloadGeneratedSchema(manager, payload.generatedSchema) - } - - const seeders = [] - for (const entry of payload.seeders) { - const seeder = resolveExport(await loadModule(entry), isSeeder) - if (!seeder) { - throw new Error(\`Registered seeder "\${entry}" does not export a Holo seeder.\`) - } - seeders.push(seeder) - } - - const executed = await createSeederService(manager.connection(), seeders).seed(payload.options ?? {}) - if (executed.length === 0) { - console.log('No seeders were executed.') - } else { - console.log('Seeders executed:') - for (const item of executed) { - console.log(\` \${item.name}\`) - } - } - } else if (payload.kind === 'prune') { - const models = [] - for (const entry of payload.models) { - const model = resolveExport(await loadModule(entry), isModel) - if (!model) { - throw new Error(\`Registered model "\${entry}" does not export a Holo model.\`) - } - models.push(model) - } - - const byName = new Map(models.map(model => [model.definition.name, model])) - const requested = payload.options?.models ?? [] - const selected = [] - - if (requested.length === 0) { - selected.push(...models.filter(model => Boolean(model.definition.prunable))) - } else { - for (const name of requested) { - const model = byName.get(name) - if (!model) { - throw new Error(\`Unknown model "\${name}".\`) - } - if (!model.definition.prunable) { - throw new Error(\`Model "\${name}" does not define a prunable query.\`) - } - selected.push(model) - } - } - - if (selected.length === 0) { - console.log('No prunable models were registered.') - } else { - let total = 0 - for (const model of selected) { - const deleted = await model.prune() - total += deleted - console.log(\`\${model.definition.name}: deleted \${deleted}\`) - } - console.log(\`Total deleted: \${total}\`) - } - } else { - throw new Error(\`Unknown runtime command "\${payload.kind}".\`) - } -} finally { - await manager.disconnectAll() - resetDB() -} -` - export async function resolvePackageRootFromSpecifier(specifier: string): Promise { let current = dirname(fileURLToPath(import.meta.resolve(specifier))) @@ -628,10 +362,19 @@ export async function cleanupRuntimeDependencyLink(projectRoot: string): Promise } /* v8 ignore stop */ -export function createRuntimeInvocation(script: string): { command: string, args: string[] } { +export function resolveRuntimeWorkerPath(): string { + const runtimePath = fileURLToPath(import.meta.url) + return runtimePath.endsWith('/src/runtime.ts') + ? resolve(dirname(runtimePath), 'runtime-worker.ts') + : resolve(dirname(runtimePath), 'runtime-worker.mjs') +} + +export function createRuntimeInvocation(workerPath = resolveRuntimeWorkerPath()): { command: string, args: string[] } { return { command: 'node', - args: ['--input-type=module', '--eval', script], + args: workerPath.endsWith('.ts') + ? ['--experimental-strip-types', workerPath] + : [workerPath], } } @@ -757,7 +500,7 @@ export async function withRuntimeEnvironment( generatedSchemaOutputPath: resolveGeneratedSchemaPath(projectRoot, environment.project.config), options, }) - const runtime = createRuntimeInvocation(nodeRuntimeScript) + const runtime = createRuntimeInvocation() const result = await runRuntimeInvocation(runtime.command, runtime.args, { cwd: runtimeRoot, env: { diff --git a/packages/cli/src/security.ts b/packages/cli/src/security.ts index 78cd08b6..912a30c8 100644 --- a/packages/cli/src/security.ts +++ b/packages/cli/src/security.ts @@ -18,7 +18,7 @@ type SecurityCliModule = { config: NormalizedHoloSecurityConfig, options?: { readonly projectRoot?: string - readonly redisAdapter?: unknown + readonly redisAdapter?: SecurityRedisAdapter }, ): { clear(key: string): Promise @@ -86,14 +86,14 @@ export async function runRateLimitClearCommand( if (driver === 'redis') { const { createSecurityRedisAdapter } = await loadRedisAdapter(projectRoot) - redisAdapter = createSecurityRedisAdapter(securityConfig.rateLimit.redis) as SecurityRedisAdapter + redisAdapter = createSecurityRedisAdapter(securityConfig.rateLimit.redis) } try { await redisAdapter?.connect() const rateLimitStoreOptions = redisAdapter - ? { projectRoot, redisAdapter: redisAdapter as unknown } + ? { projectRoot, redisAdapter } : { projectRoot } const rateLimitStore = securityModule.createRateLimitStoreFromConfig(securityConfig, rateLimitStoreOptions) diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index ad77ea3f..70165c26 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -33,17 +33,139 @@ import type * as DevInternalModule from '../src/dev' import type * as HoloQueueModule from '@holo-js/queue' import type * as HoloQueueDbModule from '@holo-js/queue-db' import type { QueueWorkerRunOptions } from '@holo-js/queue' -import { defineCommand } from '../src' +import { + defineCommand, +} from '../src' +import { + createAppCommandDefinition, + createCommandContext, + createInternalCommands, + commandTokens, + findCommand, + findCommandConflict, + printCommandHelp, + printCommandList, + resolvePackageManagerDevCommand, + resolvePackageManagerInstallCommand, +} from '../src/cli' import { generateProjectAppKey, renderEnvWithAppKey, } from '../src/app-key' -import { cliInternals } from '../src/cli-internals' -import { promptMultiChoice } from '../src/parsing' -import { cleanupRuntimeDependencyLink, ensureRuntimeDependencyLink, runRuntimeInvocation } from '../src/runtime' -import { collectDiscoveryWatchRoots, isDiscoveryRelevantPath } from '../src/dev' -import { generatorInternals } from '../src/generators' -import { loadSecurityCliModule } from '../src/security' +import { + collectMultiStringFlag, + isInteractive, + normalizeChoice, + normalizeOptionalPackages, + parseNumberFlag, + parseTokens, + promptChoice, + promptMultiChoice, + promptOptionalPackages, + resolveBooleanFlag, + resolveNewProjectInput, + resolveStringFlag, + splitCsv, +} from '../src/parsing' +import { + cacheProjectConfig, + cleanupRuntimeDependencyLink, + createEnvRuntimeConfig, + createRuntimeInvocation, + dropAllTablesForFresh, + ensureRuntimeDependencyLink, + getRuntimeEnvironment, + getRuntimeFailureMessage, + inferRuntimeMigrationName, + mergeRuntimeDatabaseConfig, + normalizeRuntimeMigration, + parseBooleanEnv, + resolveConfigModuleUrl, + runRuntimeInvocation, + withRuntimeEnvironment, +} from '../src/runtime' +import { + buildQueueWorkArgs, + collectQueueWatchRoots, + getQueueRuntimeEnvironment, + hasQueueRestartSignalSince, + isQueueListenRelevantPath, + readQueueRestartSignal, + resolveCliEntrypointPath, + resolveModuleExport, + resolveQueueRestartSignalPath, + resolveRunnableCliEntrypoint, + runQueueClearCommand, + runQueueFailedCommand, + runQueueFlushCommand, + runQueueForgetCommand, + runQueueListen, + runQueueRestartCommand, + runQueueRetryCommand, + runQueueWorkCommand, + writeQueueRestartSignal, +} from '../src/queue' +import { runBroadcastWorkCommand } from '../src/broadcast' +import { + loadSecurityCliModule, + runRateLimitClearCommand, +} from '../src/security' +import { + initializeCacheMaintenanceEnvironment, + loadCacheCliModule, + runCacheClearCommand, + runCacheForgetCommand, +} from '../src/cache' +import { + cacheMigrationInternals, + DEFAULT_CACHE_DATABASE_LOCK_TABLE, + DEFAULT_CACHE_DATABASE_TABLE, + loadCacheConfig, + normalizeCacheMigrationName, + renderCacheTableMigration, + resolveDatabaseCacheTables, + runCacheTableCommand, +} from '../src/cache-migrations' +import { + renderFailedJobsTableMigration, + renderQueueTableMigration, + resolveDatabaseQueueTables, + runQueueFailedTableCommand, + runQueueTableCommand, +} from '../src/queue-migrations' +import { + isIgnorableWatchError, + collectDiscoveryWatchRoots, + isDiscoveryRelevantPath, + resolvePackageManagerCommand, + resolvePackageManagerInstallInvocation, + runProjectDependencyInstall, + runProjectDevServer, + runProjectLifecycleScript, + runProjectPrepare, +} from '../src/dev' +import { + generatorInternals, + runMakeBroadcast, + runMakeChannel, + runMakeEvent, + runMakeFactory, + runMakeJob, + runMakeListener, + runMakeMail, + runMakeMigration, + runMakeModel, + runMakeObserver, + runMakeSeeder, +} from '../src/generators' +import { ensureAbsent, fileExists } from '../src/fs-utils' +import { hasProjectDependency } from '../src/package-json' +import { + getRegistryMigrationSlug, + hasRegisteredCreateTableMigration, + hasRegisteredMigrationSlug, + nextMigrationTemplate, +} from '../src/migrations' import { installRealtimeIntoProject } from '../src/project/scaffold' import { bundleProjectModule, @@ -1155,7 +1277,7 @@ export default { await writeProjectFile(commandRoot, '.env', 'APP_KEY=\n') const commandIo = createIo(commandRoot) - const keyCommand = cliInternals.createInternalCommands({ + const keyCommand = createInternalCommands({ ...commandIo.io, projectRoot: commandRoot, registry: [], @@ -1178,7 +1300,7 @@ export default { await writeProjectFile(commandSkippedRoot, '.env', 'APP_KEY=already-set\n') const skippedCommandIo = createIo(commandSkippedRoot) - const skippedKeyCommand = cliInternals.createInternalCommands({ + const skippedKeyCommand = createInternalCommands({ ...skippedCommandIo.io, projectRoot: commandSkippedRoot, registry: [], @@ -2762,7 +2884,7 @@ export default { })).rejects.toThrow('Refusing to scaffold into a non-empty directory') const fallbackIo = createIo(baseRoot) - const newCommand = cliInternals.createInternalCommands({ + const newCommand = createInternalCommands({ ...fallbackIo.io, projectRoot: baseRoot, registry: [], @@ -2878,7 +3000,7 @@ export default { const rootDir = await createTempDirectory() tempDirs.push(rootDir) const rootIo = createIo(rootDir) - const rootCommand = cliInternals.createInternalCommands({ + const rootCommand = createInternalCommands({ ...rootIo.io, projectRoot: rootDir, registry: [], @@ -3700,10 +3822,10 @@ export default defineAppConfig({ const commandContext = { ...installCommandIo.io, projectRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const installCommand = cliInternals.createInternalCommands(commandContext as never) + const installCommand = createInternalCommands(commandContext as never) .find(command => command.name === 'install') expect(installCommand).toBeDefined() @@ -4081,10 +4203,10 @@ export default defineRedisConfig({ const runCommandContext = { ...runIo.io, projectRoot: runRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const runInstallCommand = cliInternals.createInternalCommands(runCommandContext as never) + const runInstallCommand = createInternalCommands(runCommandContext as never) .find(command => command.name === 'install') await expect(runInstallCommand?.run({ @@ -4101,10 +4223,10 @@ export default defineRedisConfig({ const rerunContext = { ...rerunIo.io, projectRoot: runRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const rerunInstallCommand = cliInternals.createInternalCommands(rerunContext as never) + const rerunInstallCommand = createInternalCommands(rerunContext as never) .find(command => command.name === 'install') await expect(rerunInstallCommand?.run({ projectRoot: runRoot, @@ -4124,10 +4246,10 @@ export default defineRedisConfig({ const redisRunContext = { ...redisRunIo.io, projectRoot: redisRunRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const redisInstallCommand = cliInternals.createInternalCommands(redisRunContext as never) + const redisInstallCommand = createInternalCommands(redisRunContext as never) .find(command => command.name === 'install') await expect(redisInstallCommand?.run({ projectRoot: redisRunRoot, @@ -4152,10 +4274,10 @@ export default defineRedisConfig({ const authProviderRunContext = { ...authProviderRunIo.io, projectRoot: authProviderRunRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const authProviderInstallCommand = cliInternals.createInternalCommands(authProviderRunContext as never) + const authProviderInstallCommand = createInternalCommands(authProviderRunContext as never) .find(command => command.name === 'install') await expect(authProviderInstallCommand?.run({ projectRoot: authProviderRunRoot, @@ -4173,10 +4295,10 @@ export default defineRedisConfig({ const eventsRunContext = { ...eventsRunIo.io, projectRoot: eventsRunRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const eventsInstallCommand = cliInternals.createInternalCommands(eventsRunContext as never) + const eventsInstallCommand = createInternalCommands(eventsRunContext as never) .find(command => command.name === 'install') await expect(eventsInstallCommand?.run({ projectRoot: eventsRunRoot, @@ -4210,10 +4332,10 @@ export default defineRedisConfig({ const interactiveEventsContext = { ...interactiveEventsIo.io, projectRoot: interactiveEventsRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const interactiveEventsCommand = cliInternals.createInternalCommands(interactiveEventsContext as never) + const interactiveEventsCommand = createInternalCommands(interactiveEventsContext as never) .find(command => command.name === 'install') await expect(interactiveEventsCommand?.run({ projectRoot: interactiveEventsRoot, @@ -4259,10 +4381,10 @@ export default defineRedisConfig({ const queueOnlyEventsContext = { ...queueOnlyEventsIo.io, projectRoot: queueOnlyEventsRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const queueOnlyEventsCommand = cliInternals.createInternalCommands(queueOnlyEventsContext as never) + const queueOnlyEventsCommand = createInternalCommands(queueOnlyEventsContext as never) .find(command => command.name === 'install') await expect(queueOnlyEventsCommand?.run({ projectRoot: queueOnlyEventsRoot, @@ -4575,10 +4697,10 @@ export default defineDatabaseConfig({ const context = { ...io.io, projectRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const installCommand = cliInternals.createInternalCommands(context as never) + const installCommand = createInternalCommands(context as never) .find(command => command.name === 'install') return installCommand?.prepare?.({ args, flags: {} }, context as never) @@ -5281,12 +5403,12 @@ export default defineCacheConfig({ const baseRoot = await createTempDirectory() tempDirs.push(baseRoot) - await expect(cliInternals.resolveNewProjectInput(createIo(baseRoot).io, { + await expect(resolveNewProjectInput(createIo(baseRoot).io, { args: [], flags: {}, })).rejects.toThrow('Missing required argument: Project name.') - await expect(cliInternals.resolveNewProjectInput(createIo(baseRoot).io, { + await expect(resolveNewProjectInput(createIo(baseRoot).io, { args: ['optional-app'], flags: { 'framework': 'nuxt', @@ -5303,7 +5425,7 @@ export default defineCacheConfig({ storageDefaultDisk: 'local', optionalPackages: ['forms', 'validation'], }) - await expect(cliInternals.resolveNewProjectInput(createIo(baseRoot).io, { + await expect(resolveNewProjectInput(createIo(baseRoot).io, { args: ['optional-array-app'], flags: { package: ['validation', 'forms'], @@ -5316,7 +5438,7 @@ export default defineCacheConfig({ storageDefaultDisk: 'local', optionalPackages: ['forms', 'validation'], }) - await expect(cliInternals.resolveNewProjectInput(createIo(baseRoot).io, { + await expect(resolveNewProjectInput(createIo(baseRoot).io, { args: ['forms-app'], flags: { package: 'forms', @@ -5330,7 +5452,7 @@ export default defineCacheConfig({ optionalPackages: ['forms', 'validation'], }) - await expect(cliInternals.resolveNewProjectInput(createIo(baseRoot).io, { + await expect(resolveNewProjectInput(createIo(baseRoot).io, { args: ['defaults-app'], flags: {}, })).resolves.toEqual({ @@ -5341,7 +5463,7 @@ export default defineCacheConfig({ storageDefaultDisk: 'local', optionalPackages: [], }) - await expect(cliInternals.resolveNewProjectInput(createIo(baseRoot).io, { + await expect(resolveNewProjectInput(createIo(baseRoot).io, { args: ['storage-flag-app'], flags: { framework: 'nuxt', @@ -5359,7 +5481,7 @@ export default defineCacheConfig({ optionalPackages: ['storage'], }) - await expect(cliInternals.resolveNewProjectInput(createIo(baseRoot).io, { + await expect(resolveNewProjectInput(createIo(baseRoot).io, { args: ['storage-default-app'], flags: { framework: 'nuxt', @@ -5497,10 +5619,10 @@ export const limit = Object.freeze({ const context = { ...io.io, projectRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const commands = cliInternals.createInternalCommands( + const commands = createInternalCommands( context, async (_projectRoot, _kind, _options, callback) => callback(''), {}, @@ -6078,7 +6200,7 @@ export default defineBroadcastConfig({ tty: true, input: 'default-prompt-app\n', }) - await expect(cliInternals.resolveNewProjectInput(defaultNamePromptIo.io, { + await expect(resolveNewProjectInput(defaultNamePromptIo.io, { args: [], flags: { framework: 'nuxt', @@ -6099,7 +6221,7 @@ export default defineBroadcastConfig({ tty: true, input: 'next\n', }) - await expect(cliInternals.resolveNewProjectInput(defaultChoicePromptIo.io, { + await expect(resolveNewProjectInput(defaultChoicePromptIo.io, { args: ['default-choice-app'], flags: { database: 'sqlite', @@ -6119,7 +6241,7 @@ export default defineBroadcastConfig({ tty: true, input: 'forms,validation\n', }) - await expect(cliInternals.resolveNewProjectInput(defaultOptionalPackagesIo.io, { + await expect(resolveNewProjectInput(defaultOptionalPackagesIo.io, { args: ['default-packages-app'], flags: { framework: 'nuxt', @@ -6137,7 +6259,7 @@ export default defineBroadcastConfig({ }) const io = createIo(baseRoot, { tty: true }) - await expect(cliInternals.resolveNewProjectInput(io.io, { args: [], flags: {} }, { + await expect(resolveNewProjectInput(io.io, { args: [], flags: {} }, { prompt: async () => 'prompted-app', choose: async (label, _allowed, defaultValue) => { if (label === 'Framework') return 'sveltekit' as typeof defaultValue @@ -6155,7 +6277,7 @@ export default defineBroadcastConfig({ optionalPackages: ['validation'], }) - await expect(cliInternals.resolveNewProjectInput(io.io, { args: [], flags: {} }, { + await expect(resolveNewProjectInput(io.io, { args: [], flags: {} }, { prompt: async () => 'storage-app', choose: async (label, _allowed, defaultValue) => { if (label === 'Framework') return 'nuxt' as typeof defaultValue @@ -6173,7 +6295,7 @@ export default defineBroadcastConfig({ optionalPackages: ['storage'], }) - await expect(cliInternals.resolveNewProjectInput(io.io, { args: [], flags: {} }, { + await expect(resolveNewProjectInput(io.io, { args: [], flags: {} }, { prompt: async () => 'bad-app', choose: async () => { throw new Error('Unsupported Framework: invalid-framework. Expected one of nuxt, next, sveltekit.') @@ -6181,7 +6303,7 @@ export default defineBroadcastConfig({ optionalPackages: async () => [], })).rejects.toThrow('Unsupported') - await expect(cliInternals.resolveNewProjectInput(io.io, { args: [], flags: {} }, { + await expect(resolveNewProjectInput(io.io, { args: [], flags: {} }, { prompt: async () => '', choose: async (_label, _allowed, defaultValue) => defaultValue, optionalPackages: async () => [], @@ -6196,18 +6318,18 @@ export default defineBroadcastConfig({ tty: true, input: 'next\n', }) - await expect(cliInternals.promptChoice(choiceIo.io, 'Framework', ['nuxt', 'next'], 'nuxt')).resolves.toBe('next') + await expect(promptChoice(choiceIo.io, 'Framework', ['nuxt', 'next'], 'nuxt')).resolves.toBe('next') const invalidChoiceIo = createIo(baseRoot, { tty: true, input: 'astro\n', }) - await expect(cliInternals.promptChoice(invalidChoiceIo.io, 'Framework', ['nuxt', 'next'], 'nuxt')).rejects.toThrow('Unsupported Framework') + await expect(promptChoice(invalidChoiceIo.io, 'Framework', ['nuxt', 'next'], 'nuxt')).rejects.toThrow('Unsupported Framework') const optionalPromptIo = createIo(baseRoot, { tty: true, input: 'forms,validation\n', }) - await expect(cliInternals.promptOptionalPackages(optionalPromptIo.io)).resolves.toEqual(['forms', 'validation']) + await expect(promptOptionalPackages(optionalPromptIo.io)).resolves.toEqual(['forms', 'validation']) const multiChoicePromptIo = createIo(baseRoot, { tty: true, input: 'workos, clerk\n', @@ -6217,34 +6339,34 @@ export default defineBroadcastConfig({ tty: true, input: 'security\n', }) - await expect(cliInternals.promptOptionalPackages(securityPromptIo.io)).resolves.toEqual(['security']) + await expect(promptOptionalPackages(securityPromptIo.io)).resolves.toEqual(['security']) const broadcastPromptIo = createIo(baseRoot, { tty: true, input: 'broadcast\n', }) - await expect(cliInternals.promptOptionalPackages(broadcastPromptIo.io)).resolves.toEqual(['broadcast']) + await expect(promptOptionalPackages(broadcastPromptIo.io)).resolves.toEqual(['broadcast']) const formsOnlyPromptIo = createIo(baseRoot, { tty: true, input: 'forms\n', }) - await expect(cliInternals.promptOptionalPackages(formsOnlyPromptIo.io)).resolves.toEqual(['forms', 'validation']) - expect(cliInternals.normalizeChoice('next', ['nuxt', 'next'], 'Framework')).toBe('next') - expect(() => cliInternals.normalizeChoice('astro', ['nuxt', 'next'], 'Framework')).toThrow('Unsupported Framework') - expect(() => cliInternals.normalizeChoice(undefined, ['nuxt', 'next'], 'Framework')).toThrow('(empty)') - expect(() => cliInternals.normalizeOptionalPackages(['weird-package'])).toThrow('Unsupported optional package') - expect(cliInternals.normalizeOptionalPackages(['broadcast'])).toEqual(['broadcast']) - expect(cliInternals.normalizeOptionalPackages(['realtime'])).toEqual(['realtime']) - expect(cliInternals.normalizeOptionalPackages(['security'])).toEqual(['security']) - expect(cliInternals.normalizeOptionalPackages(['forms'])).toEqual(['forms', 'validation']) - expect(cliInternals.normalizeOptionalPackages(['forms', 'validation', 'forms'])).toEqual(['forms', 'validation']) - expect(cliInternals.normalizeOptionalPackages(['form', 'validate', 'storage', 'queue', 'events'])).toEqual([ + await expect(promptOptionalPackages(formsOnlyPromptIo.io)).resolves.toEqual(['forms', 'validation']) + expect(normalizeChoice('next', ['nuxt', 'next'], 'Framework')).toBe('next') + expect(() => normalizeChoice('astro', ['nuxt', 'next'], 'Framework')).toThrow('Unsupported Framework') + expect(() => normalizeChoice(undefined, ['nuxt', 'next'], 'Framework')).toThrow('(empty)') + expect(() => normalizeOptionalPackages(['weird-package'])).toThrow('Unsupported optional package') + expect(normalizeOptionalPackages(['broadcast'])).toEqual(['broadcast']) + expect(normalizeOptionalPackages(['realtime'])).toEqual(['realtime']) + expect(normalizeOptionalPackages(['security'])).toEqual(['security']) + expect(normalizeOptionalPackages(['forms'])).toEqual(['forms', 'validation']) + expect(normalizeOptionalPackages(['forms', 'validation', 'forms'])).toEqual(['forms', 'validation']) + expect(normalizeOptionalPackages(['form', 'validate', 'storage', 'queue', 'events'])).toEqual([ 'events', 'forms', 'queue', 'storage', 'validation', ]) - expect(cliInternals.normalizeOptionalPackages(['none'])).toEqual([]) + expect(normalizeOptionalPackages(['none'])).toEqual([]) }) it('rejects conflicting flags, unsupported values, non-empty targets, and generated project regressions', async () => { @@ -6253,17 +6375,17 @@ export default defineBroadcastConfig({ await writeProjectFile(baseRoot, 'occupied/file.txt', 'taken') - await expect(cliInternals.resolveNewProjectInput(createIo(baseRoot).io, { + await expect(resolveNewProjectInput(createIo(baseRoot).io, { args: ['demo'], flags: { name: 'other' }, })).rejects.toThrow('Conflicting project names') - await expect(cliInternals.resolveNewProjectInput(createIo(baseRoot).io, { + await expect(resolveNewProjectInput(createIo(baseRoot).io, { args: ['demo'], flags: { framework: 'astro' }, })).rejects.toThrow('Unsupported framework') - await expect(cliInternals.resolveNewProjectInput(createIo(baseRoot).io, { + await expect(resolveNewProjectInput(createIo(baseRoot).io, { args: ['demo'], flags: { package: 'weird-package' }, })).rejects.toThrow('Unsupported optional package') @@ -6317,7 +6439,7 @@ export default defineBroadcastConfig({ }, null, 2)) await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot) + await runProjectPrepare(projectRoot) }) expect(await readFile(join(projectRoot, '.holo-js/generated/registry.json'), 'utf8')).toContain('"version": 1') @@ -6392,7 +6514,7 @@ printf 'fake npm install\n' process.env.PATH = `${fakeBinRoot}:${originalPath ?? ''}` try { await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot, io.io) + await runProjectPrepare(projectRoot, io.io) }) } finally { process.env.PATH = originalPath @@ -6455,7 +6577,7 @@ printf 'fake npm install\n' try { await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot, io.io) + await runProjectPrepare(projectRoot, io.io) }) const syncedDependencies = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8')).dependencies ?? {} @@ -6480,7 +6602,7 @@ export default defineCacheConfig({ `) await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot, io.io) + await runProjectPrepare(projectRoot, io.io) }) } finally { process.env.PATH = originalPath @@ -6523,7 +6645,7 @@ printf 'fake npm install\n' try { await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot, io.io) + await runProjectPrepare(projectRoot, io.io) }) } finally { process.env.PATH = originalPath @@ -7973,7 +8095,7 @@ export default { expect(defaultIo.read().stdout).toContain('Internal Commands') const listedIo = createIo(projectRoot) - await expect(cliInternals.findCommand([], 'missing')).toBeUndefined() + await expect(findCommand([], 'missing')).toBeUndefined() await expect(import('../src/cli').then(module => module.runCli(['list'], listedIo.io))).resolves.toBe(0) expect(listedIo.read().stdout).toContain('Internal Commands') expect(listedIo.read().stdout).toContain('holo courses:reindex') @@ -8102,7 +8224,7 @@ export default { usage: 'holo example', async run() {}, }) - const appDefinition = cliInternals.createAppCommandDefinition({ + const appDefinition = createAppCommandDefinition({ sourcePath: 'server/commands/example.ts', name: 'example', aliases: ['sample'], @@ -8115,7 +8237,7 @@ export default { const io = createIo('/tmp/project') expect(Object.isFrozen(command)).toBe(true) - expect(cliInternals.parseTokens(['name', '--step', '2', '--quietly', '-f'])).toEqual({ + expect(parseTokens(['name', '--step', '2', '--quietly', '-f'])).toEqual({ args: ['name'], flags: { step: '2', @@ -8123,7 +8245,7 @@ export default { f: true, }, }) - expect(cliInternals.parseTokens(['--only=roles', '--only', 'users', '--only', 'teachers', '-o', 'admins', '-abc', '--', '--literal'])).toEqual({ + expect(parseTokens(['--only=roles', '--only', 'users', '--only', 'teachers', '-o', 'admins', '-abc', '--', '--literal'])).toEqual({ args: ['--literal'], flags: { only: ['roles', 'users', 'teachers'], @@ -8133,45 +8255,49 @@ export default { c: true, }, }) - expect(cliInternals.parseTokens(['--step', '-1'])).toEqual({ + expect(parseTokens(['--step', '-1'])).toEqual({ args: [], flags: { step: '-1', }, }) - expect(cliInternals.parseTokens(['-s', '-1'])).toEqual({ + expect(parseTokens(['-s', '-1'])).toEqual({ args: [], flags: { s: '-1', }, }) - expect(cliInternals.resolveStringFlag({ only: ['roles', 'users'] }, 'only')).toBe('users') - expect(cliInternals.resolveStringFlag({ o: 'roles' }, 'only', 'o')).toBe('roles') - expect(cliInternals.resolveStringFlag({ step: 2 }, 'step')).toBeUndefined() - expect(cliInternals.resolveBooleanFlag({ force: 'true' }, 'force')).toBe(true) - expect(cliInternals.resolveBooleanFlag({ force: ['false', 'true'] }, 'force')).toBe(true) - expect(cliInternals.resolveBooleanFlag({ f: 'false' }, 'force', 'f')).toBe(false) - expect(cliInternals.parseNumberFlag({ step: '3' }, 'step')).toBe(3) - expect(() => cliInternals.parseNumberFlag({ step: '-1' }, 'step')).toThrow('Flag "--step" must be a non-negative integer.') - expect(cliInternals.parseNumberFlag({}, 'step')).toBeUndefined() - expect(() => cliInternals.parseNumberFlag({ step: 'abc' }, 'step')).toThrow('Flag "--step" must be a non-negative integer.') - expect(() => cliInternals.parseNumberFlag({ step: '1abc' }, 'step')).toThrow('Flag "--step" must be a non-negative integer.') + expect(resolveStringFlag({ only: ['roles', 'users'] }, 'only')).toBe('users') + expect(resolveStringFlag({ o: 'roles' }, 'only', 'o')).toBe('roles') + expect(resolveStringFlag({ step: 2 }, 'step')).toBeUndefined() + expect(resolveBooleanFlag({ force: 'true' }, 'force')).toBe(true) + expect(resolveBooleanFlag({ force: ['false', 'true'] }, 'force')).toBe(true) + expect(resolveBooleanFlag({ f: 'false' }, 'force', 'f')).toBe(false) + expect(parseNumberFlag({ step: '3' }, 'step')).toBe(3) + expect(() => parseNumberFlag({ step: '-1' }, 'step')).toThrow('Flag "--step" must be a non-negative integer.') + expect(parseNumberFlag({}, 'step')).toBeUndefined() + expect(() => parseNumberFlag({ step: 'abc' }, 'step')).toThrow('Flag "--step" must be a non-negative integer.') + expect(() => parseNumberFlag({ step: '1abc' }, 'step')).toThrow('Flag "--step" must be a non-negative integer.') const parseIntSpy = vi.spyOn(Number, 'parseInt').mockReturnValue(Number.POSITIVE_INFINITY) - expect(() => cliInternals.parseNumberFlag({ step: '7' }, 'step')).toThrow('Flag "--step" must be a non-negative integer.') + expect(() => parseNumberFlag({ step: '7' }, 'step')).toThrow('Flag "--step" must be a non-negative integer.') parseIntSpy.mockRestore() - expect(cliInternals.splitCsv('roles, users')).toEqual(['roles', 'users']) - expect(cliInternals.splitCsv(undefined)).toBeUndefined() - expect(cliInternals.isInteractive(io.io, {})).toBe(false) - expect(cliInternals.isInteractive(createIo('/tmp/project', { tty: true }).io, {})).toBe(true) - expect(cliInternals.isInteractive(createIo('/tmp/project', { tty: true }).io, { 'no-interactive': true })).toBe(false) - expect(cliInternals.findCommand([appDefinition], 'sample')).toBe(appDefinition) - expect(cliInternals.collectMultiStringFlag({ only: ['users', 'roles'] }, 'only')).toEqual(['users', 'roles']) - expect(cliInternals.collectMultiStringFlag({ only: 'users' }, 'only')).toEqual(['users']) - expect(cliInternals.collectMultiStringFlag({ o: 'users' }, 'only', 'o')).toEqual(['users']) - expect(cliInternals.collectMultiStringFlag({ only: ' ' }, 'only')).toBeUndefined() - expect(cliInternals.createRuntimeInvocation('console.log(1)')).toEqual({ + expect(splitCsv('roles, users')).toEqual(['roles', 'users']) + expect(splitCsv(undefined)).toBeUndefined() + expect(isInteractive(io.io, {})).toBe(false) + expect(isInteractive(createIo('/tmp/project', { tty: true }).io, {})).toBe(true) + expect(isInteractive(createIo('/tmp/project', { tty: true }).io, { 'no-interactive': true })).toBe(false) + expect(findCommand([appDefinition], 'sample')).toBe(appDefinition) + expect(collectMultiStringFlag({ only: ['users', 'roles'] }, 'only')).toEqual(['users', 'roles']) + expect(collectMultiStringFlag({ only: 'users' }, 'only')).toEqual(['users']) + expect(collectMultiStringFlag({ o: 'users' }, 'only', 'o')).toEqual(['users']) + expect(collectMultiStringFlag({ only: ' ' }, 'only')).toBeUndefined() + expect(createRuntimeInvocation('/tmp/holo-runtime-worker.mjs')).toEqual({ + command: 'node', + args: ['/tmp/holo-runtime-worker.mjs'], + }) + expect(createRuntimeInvocation('/tmp/holo-runtime-worker.ts')).toEqual({ command: 'node', - args: ['--input-type=module', '--eval', 'console.log(1)'], + args: ['--experimental-strip-types', '/tmp/holo-runtime-worker.ts'], }) const largeRuntimeOutputSize = 1024 * 1024 + 1024 const largeRuntimeOutput = await runRuntimeInvocation('node', [ @@ -8192,7 +8318,7 @@ export default { expect(missingRuntimeOutput.error).toBeInstanceOf(Error) const executed: string[] = [] let foreignKeysScoped = false - await cliInternals.dropAllTablesForFresh( + await dropAllTablesForFresh( { getDialect: () => ({ name: 'postgres', @@ -8223,7 +8349,7 @@ export default { 'DROP TABLE IF EXISTS "tenant_app"."users" CASCADE', ]) const defaultSchemaExecuted: string[] = [] - await cliInternals.dropAllTablesForFresh( + await dropAllTablesForFresh( { getDialect: () => ({ name: 'postgres', @@ -8252,7 +8378,7 @@ export default { ]) const droppedTables: string[] = [] let scopedDrops = 0 - await cliInternals.dropAllTablesForFresh( + await dropAllTablesForFresh( { getDialect: () => ({ name: 'sqlite', @@ -8278,35 +8404,35 @@ export default { ) expect(scopedDrops).toBe(1) expect(droppedTables).toEqual(['roles', 'users']) - expect(cliInternals.inferRuntimeMigrationName('file:///tmp/2026_01_01_000001_create_sessions_table.js')).toBe('2026_01_01_000001_create_sessions_table') - expect(() => cliInternals.inferRuntimeMigrationName('file:///tmp/create_sessions_table.js')).toThrow( + expect(inferRuntimeMigrationName('file:///tmp/2026_01_01_000001_create_sessions_table.js')).toBe('2026_01_01_000001_create_sessions_table') + expect(() => inferRuntimeMigrationName('file:///tmp/create_sessions_table.js')).toThrow( 'Registered migration "file:///tmp/create_sessions_table.js" must use a timestamped file name matching YYYY_MM_DD_HHMMSS_description.', ) - expect(cliInternals.normalizeRuntimeMigration( + expect(normalizeRuntimeMigration( 'file:///tmp/2026_01_01_000001_create_sessions_table.js', { up() {} }, )).toMatchObject({ name: '2026_01_01_000001_create_sessions_table' }) - expect(cliInternals.normalizeRuntimeMigration( + expect(normalizeRuntimeMigration( 'file:///tmp/ignored.js', { name: 'custom_name', up() {} }, )).toMatchObject({ name: 'custom_name' }) - expect(cliInternals.getRuntimeFailureMessage('prune', { + expect(getRuntimeFailureMessage('prune', { status: null, error: { code: 'ENOENT' }, stdout: undefined, stderr: undefined, })).toContain('Failed to launch runtime command "prune"') - expect(cliInternals.getRuntimeFailureMessage('seed', { + expect(getRuntimeFailureMessage('seed', { status: 1, stdout: '', stderr: 'seed failed\n', })).toBe('seed failed') - expect(cliInternals.getRuntimeFailureMessage('seed', { + expect(getRuntimeFailureMessage('seed', { status: 1, stdout: 'seed output\n', stderr: '', })).toBe('seed output') - expect(cliInternals.getRuntimeFailureMessage('migrate', { + expect(getRuntimeFailureMessage('migrate', { status: 1, stdout: '', stderr: [ @@ -8318,18 +8444,18 @@ export default { ' at initialize (file:///app/.holo-js/runtime/index.mjs:12:11)', ].join('\n'), })).toBe('Unable to open SQLite database "./missing/database.sqlite": unable to open database file') - expect(cliInternals.getRuntimeFailureMessage('seed', { + expect(getRuntimeFailureMessage('seed', { status: 1, stdout: undefined, stderr: undefined, error: undefined, })).toBe('Runtime command "seed" failed.') - cliInternals.printCommandList(io.io, [ + printCommandList(io.io, [ { ...appDefinition, source: 'internal', usage: 'holo list' }, appDefinition, ]) - cliInternals.printCommandHelp(io.io, appDefinition) + printCommandHelp(io.io, appDefinition) const listed = io.read().stdout expect(listed).toContain('Internal Commands') @@ -8346,24 +8472,24 @@ export default { process.env.DB_DRIVER = 'postgres' process.env.DB_SSL = 'true' process.env.DB_LOGGING = 'false' - expect(cliInternals.parseBooleanEnv(undefined)).toBeUndefined() - expect(cliInternals.parseBooleanEnv('true')).toBe(true) - expect(cliInternals.parseBooleanEnv('false')).toBe(false) - expect(cliInternals.parseBooleanEnv('wat')).toBeUndefined() - expect(cliInternals.getRegistryMigrationSlug('2026_01_01_000000_create_users_table')).toBe('create_users_table') - expect(() => cliInternals.getRegistryMigrationSlug('2026_01_01_000000_!!!')).toThrow() - expect(cliInternals.hasRegisteredMigrationSlug({ + expect(parseBooleanEnv(undefined)).toBeUndefined() + expect(parseBooleanEnv('true')).toBe(true) + expect(parseBooleanEnv('false')).toBe(false) + expect(parseBooleanEnv('wat')).toBeUndefined() + expect(getRegistryMigrationSlug('2026_01_01_000000_create_users_table')).toBe('create_users_table') + expect(() => getRegistryMigrationSlug('2026_01_01_000000_!!!')).toThrow() + expect(hasRegisteredMigrationSlug({ migrations: [ { name: '2026_01_01_000000_create_users_table' }, { name: '2026_01_01_000000_!!!' }, ], } as never, 'create_users_table')).toBe(true) - expect(cliInternals.hasRegisteredMigrationSlug({ + expect(hasRegisteredMigrationSlug({ migrations: [ { name: '2026_01_01_000000_!!!' }, ], } as never, 'create_users_table')).toBe(false) - expect(cliInternals.hasRegisteredCreateTableMigration({ + expect(hasRegisteredCreateTableMigration({ migrations: [ { name: '2026_01_01_000000_create_users_table' }, { name: '2026_01_01_000001_add_status_to_users_table' }, @@ -8371,20 +8497,20 @@ export default { { name: '2026_01_01_000003_!!!' }, ], } as never, 'users')).toBe(true) - expect(cliInternals.hasRegisteredCreateTableMigration({ + expect(hasRegisteredCreateTableMigration({ migrations: [ { name: '2026_01_01_000001_add_status_to_users_table' }, { name: '2026_01_01_000002_create_table' }, { name: '2026_01_01_000003_!!!' }, ], } as never, 'users')).toBe(false) - expect(cliInternals.resolvePackageManagerInstallCommand('pnpm')).toBe('pnpm install') - expect(cliInternals.resolvePackageManagerDevCommand('pnpm')).toBe('pnpm dev') - expect(cliInternals.resolvePackageManagerInstallCommand('yarn')).toBe('yarn install') - expect(cliInternals.resolvePackageManagerDevCommand('yarn')).toBe('yarn dev') + expect(resolvePackageManagerInstallCommand('pnpm')).toBe('pnpm install') + expect(resolvePackageManagerDevCommand('pnpm')).toBe('pnpm dev') + expect(resolvePackageManagerInstallCommand('yarn')).toBe('yarn install') + expect(resolvePackageManagerDevCommand('yarn')).toBe('yarn dev') expect(projectInternals.resolveNamedExport('nope', (_value): _value is { ok: true } => false)).toBeUndefined() expect(projectInternals.resolveNamedExportEntry('nope', (_value): _value is { ok: true } => false)).toBeUndefined() - expect(cliInternals.createEnvRuntimeConfig()).toMatchObject({ + expect(createEnvRuntimeConfig()).toMatchObject({ db: { defaultConnection: 'default', connections: { @@ -8396,10 +8522,10 @@ export default { }, }, }) - expect(cliInternals.mergeRuntimeDatabaseConfig(undefined, cliInternals.createEnvRuntimeConfig())).toEqual( - cliInternals.createEnvRuntimeConfig().db, + expect(mergeRuntimeDatabaseConfig(undefined, createEnvRuntimeConfig())).toEqual( + createEnvRuntimeConfig().db, ) - expect(cliInternals.mergeRuntimeDatabaseConfig({ + expect(mergeRuntimeDatabaseConfig({ defaultConnection: 'primary', connections: { primary: { @@ -8434,7 +8560,7 @@ export default { }, }, }) - expect(cliInternals.mergeRuntimeDatabaseConfig({ + expect(mergeRuntimeDatabaseConfig({ defaultConnection: 'primary', connections: { primary: 'postgresql://manifest', @@ -8474,7 +8600,7 @@ export default { }, }, }) - expect(cliInternals.mergeRuntimeDatabaseConfig({}, { + expect(mergeRuntimeDatabaseConfig({}, { db: { defaultConnection: 'default', connections: { @@ -8509,7 +8635,7 @@ export default { }, }, }) - expect(cliInternals.mergeRuntimeDatabaseConfig({ + expect(mergeRuntimeDatabaseConfig({ defaultConnection: 'primary', connections: { primary: { @@ -8545,7 +8671,7 @@ export default { }, }, }) - expect(cliInternals.mergeRuntimeDatabaseConfig({ + expect(mergeRuntimeDatabaseConfig({ defaultConnection: 'primary', connections: { primary: { @@ -8588,18 +8714,18 @@ export default { }, }, }) - expect(cliInternals.resolveConfigModuleUrl()).toContain('/config/dist/index.mjs') - expect(cliInternals.resolveConfigModuleUrl(specifier => `mock:${specifier}`)).toBe('mock:@holo-js/config') - expect(cliInternals.resolveConfigModuleUrl(null as never)).toContain('/node_modules/@holo-js/config/dist/index.mjs') - expect(cliInternals.resolveConfigModuleUrl(() => pathToFileURL(join(workspaceRoot, 'packages/config/src/index.ts')).href)) + expect(resolveConfigModuleUrl()).toContain('/config/dist/index.mjs') + expect(resolveConfigModuleUrl(specifier => `mock:${specifier}`)).toBe('mock:@holo-js/config') + expect(resolveConfigModuleUrl(null as never)).toContain('/node_modules/@holo-js/config/dist/index.mjs') + expect(resolveConfigModuleUrl(() => pathToFileURL(join(workspaceRoot, 'packages/config/src/index.ts')).href)) .toBe(pathToFileURL(join(workspaceRoot, 'packages/config/dist/index.mjs')).href) - expect(cliInternals.resolveConfigModuleUrl(() => pathToFileURL(join(workspaceRoot, 'packages/config/src/index.mts')).href)) + expect(resolveConfigModuleUrl(() => pathToFileURL(join(workspaceRoot, 'packages/config/src/index.mts')).href)) .toBe(pathToFileURL(join(workspaceRoot, 'packages/config/dist/index.mjs')).href) - expect(cliInternals.resolveConfigModuleUrl(() => pathToFileURL(join(workspaceRoot, 'packages/config/src/index.js')).href)) + expect(resolveConfigModuleUrl(() => pathToFileURL(join(workspaceRoot, 'packages/config/src/index.js')).href)) .toBe(pathToFileURL(join(workspaceRoot, 'packages/config/dist/index.mjs')).href) - expect(cliInternals.resolveConfigModuleUrl(() => pathToFileURL(join(workspaceRoot, 'packages/config/src/index.mjs')).href)) + expect(resolveConfigModuleUrl(() => pathToFileURL(join(workspaceRoot, 'packages/config/src/index.mjs')).href)) .toBe(pathToFileURL(join(workspaceRoot, 'packages/config/dist/index.mjs')).href) - await expect(cliInternals.resolvePackageManagerInstallInvocation(projectRoot)).resolves.toEqual({ + await expect(resolvePackageManagerInstallInvocation(projectRoot)).resolves.toEqual({ command: 'bun', args: ['install'], }) @@ -8609,7 +8735,7 @@ export default { stdout: 'installed ok\n', stderr: 'warning\n', })) - await expect(cliInternals.runProjectDependencyInstall(installIo.io, projectRoot, spawnInstall as never)).resolves.toBeUndefined() + await expect(runProjectDependencyInstall(installIo.io, projectRoot, spawnInstall as never)).resolves.toBeUndefined() expect(installIo.read().stdout).toContain('installed ok') expect(installIo.read().stderr).toContain('warning') const spawnInstallFailure = vi.fn(() => ({ @@ -8617,55 +8743,55 @@ export default { stdout: '', stderr: 'install failed', })) - await expect(cliInternals.runProjectDependencyInstall(installIo.io, projectRoot, spawnInstallFailure as never)) + await expect(runProjectDependencyInstall(installIo.io, projectRoot, spawnInstallFailure as never)) .rejects.toThrow('install failed') const spawnInstallSilentFailure = vi.fn(() => ({ status: 1, stdout: '', stderr: '', })) - await expect(cliInternals.runProjectDependencyInstall(installIo.io, projectRoot, spawnInstallSilentFailure as never)) + await expect(runProjectDependencyInstall(installIo.io, projectRoot, spawnInstallSilentFailure as never)) .rejects.toThrow('Project dependency installation failed.') - await expect(cliInternals.fileExists(notePath)).resolves.toBe(true) - await expect(cliInternals.fileExists(join(projectRoot, 'missing.txt'))).resolves.toBe(false) - await expect(cliInternals.hasProjectDependency(join(projectRoot, 'missing-project'), '@holo-js/queue')).resolves.toBe(false) + await expect(fileExists(notePath)).resolves.toBe(true) + await expect(fileExists(join(projectRoot, 'missing.txt'))).resolves.toBe(false) + await expect(hasProjectDependency(join(projectRoot, 'missing-project'), '@holo-js/queue')).resolves.toBe(false) await writeFile(join(projectRoot, 'package.json'), '{ invalid json', 'utf8') - await expect(cliInternals.hasProjectDependency(projectRoot, '@holo-js/queue')).resolves.toBe(false) + await expect(hasProjectDependency(projectRoot, '@holo-js/queue')).resolves.toBe(false) await writeFile(join(projectRoot, 'package.json'), JSON.stringify({ dependencies: { '@holo-js/queue': outdatedHoloPackageRange, }, }), 'utf8') - await expect(cliInternals.hasProjectDependency(projectRoot, '@holo-js/queue')).resolves.toBe(true) + await expect(hasProjectDependency(projectRoot, '@holo-js/queue')).resolves.toBe(true) await writeFile(join(projectRoot, 'package.json'), JSON.stringify({ devDependencies: { '@holo-js/queue': outdatedHoloPackageRange, }, }), 'utf8') - await expect(cliInternals.hasProjectDependency(projectRoot, '@holo-js/queue')).resolves.toBe(true) - await expect(cliInternals.ensureAbsent(join(projectRoot, 'missing.txt'))).resolves.toBeUndefined() - await expect(cliInternals.ensureAbsent(notePath)).rejects.toThrow('Refusing to overwrite existing file') + await expect(hasProjectDependency(projectRoot, '@holo-js/queue')).resolves.toBe(true) + await expect(ensureAbsent(join(projectRoot, 'missing.txt'))).resolves.toBeUndefined() + await expect(ensureAbsent(notePath)).rejects.toThrow('Refusing to overwrite existing file') const migrationsDir = join(projectRoot, 'server/db/migrations') await mkdir(migrationsDir, { recursive: true }) await writeFile(join(migrationsDir, '20000101000000_create_users_table.ts'), '') - const template = await cliInternals.nextMigrationTemplate('create_users_table', migrationsDir) + const template = await nextMigrationTemplate('create_users_table', migrationsDir) expect(template.fileName).toMatch(/create_users_table/) const collisionNow = Date.now() const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(collisionNow) try { - const firstCollisionTemplate = await cliInternals.nextMigrationTemplate('create_posts_table', migrationsDir) + const firstCollisionTemplate = await nextMigrationTemplate('create_posts_table', migrationsDir) await writeFile(join(migrationsDir, firstCollisionTemplate.fileName), '') - const collisionTemplate = await cliInternals.nextMigrationTemplate('create_posts_table', migrationsDir) + const collisionTemplate = await nextMigrationTemplate('create_posts_table', migrationsDir) expect(collisionTemplate.fileName).not.toBe(firstCollisionTemplate.fileName) } finally { nowSpy.mockRestore() } const listIo = createIo(projectRoot) - const registry: Array> = [] + const registry: Array> = [] const runtimeCalls: Array<{ kind: string, options: Record }> = [] const context = { ...listIo.io, @@ -8688,7 +8814,7 @@ export default { : '', ) } - const internal = cliInternals.createInternalCommands(context, runtimeExecutor) + const internal = createInternalCommands(context, runtimeExecutor) registry.push(...internal) await expect(internal.find(command => command.name === 'list')?.run({ @@ -8726,7 +8852,7 @@ export default { registry, loadProject: context.loadProject, } - const interactiveModelCommands = cliInternals.createInternalCommands(interactiveModelContext as never) + const interactiveModelCommands = createInternalCommands(interactiveModelContext as never) const interactiveModelPrepared = await interactiveModelCommands.find(command => command.name === 'make:model')!.prepare!( { args: ['Lesson'], flags: {} }, interactiveModelContext as never, @@ -8836,7 +8962,7 @@ export default { registry: [] as typeof registry, loadProject: context.loadProject, } - const fallbackInternal = cliInternals.createInternalCommands( + const fallbackInternal = createInternalCommands( fallbackContext, async ( _projectRoot: string, @@ -8931,10 +9057,10 @@ export default { expect(emptyOutputIo.read().stdout).toContain('No migrations were executed.') expect(emptyOutputIo.read().stdout).toContain('No seeders were executed.') - await expect(cliInternals.cacheProjectConfig(projectRoot, async () => { + await expect(cacheProjectConfig(projectRoot, async () => { throw new Error('cache failed') })).rejects.toThrow('cache failed') - await expect(cliInternals.cacheProjectConfig(projectRoot, async () => { + await expect(cacheProjectConfig(projectRoot, async () => { throw 'cache failed from stdout' })).rejects.toThrow('Failed to cache config.') @@ -8965,7 +9091,7 @@ export default { const modelIo = createIo(modelProjectRoot) await withFakeBun(async () => { - await cliInternals.runMakeModel(modelIo.io, modelProjectRoot, { + await runMakeModel(modelIo.io, modelProjectRoot, { args: ['courses/Course'], flags: { migration: true, @@ -8978,7 +9104,7 @@ export default { expect(modelIo.read().stdout).toContain('Created model: server/models/courses/Course.ts') await expect(withFakeBun(async () => { - await cliInternals.runMakeModel(modelIo.io, modelProjectRoot, { + await runMakeModel(modelIo.io, modelProjectRoot, { args: ['courses/Course'], flags: { migration: true, @@ -8994,7 +9120,7 @@ export default { await linkWorkspaceDb(plainModelProjectRoot) const plainModelIo = createIo(plainModelProjectRoot) await withFakeBun(async () => { - await cliInternals.runMakeModel(plainModelIo.io, plainModelProjectRoot, { + await runMakeModel(plainModelIo.io, plainModelProjectRoot, { args: ['Lesson'], flags: { migration: false, @@ -9011,7 +9137,7 @@ export default { await linkWorkspaceDb(sharedTableProjectRoot) const sharedTableIo = createIo(sharedTableProjectRoot) await withFakeBun(async () => { - await cliInternals.runMakeModel(sharedTableIo.io, sharedTableProjectRoot, { + await runMakeModel(sharedTableIo.io, sharedTableProjectRoot, { args: ['User'], flags: { migration: true, @@ -9022,7 +9148,7 @@ export default { }) }) await expect(withFakeBun(async () => { - await cliInternals.runMakeModel(sharedTableIo.io, sharedTableProjectRoot, { + await runMakeModel(sharedTableIo.io, sharedTableProjectRoot, { args: ['Admin'], flags: { table: 'users', @@ -9034,7 +9160,7 @@ export default { }) })).rejects.toThrow('Discovered duplicate model "User"') await expect(withFakeBun(async () => { - await cliInternals.runMakeModel(sharedTableIo.io, sharedTableProjectRoot, { + await runMakeModel(sharedTableIo.io, sharedTableProjectRoot, { args: ['Person'], flags: { table: 'users', @@ -9062,7 +9188,7 @@ export default defineMigration({ `, ) await expect(withFakeBun(async () => { - await cliInternals.runMakeModel(createIo(duplicateMigrationModelRoot).io, duplicateMigrationModelRoot, { + await runMakeModel(createIo(duplicateMigrationModelRoot).io, duplicateMigrationModelRoot, { args: ['Lesson'], flags: { migration: true, @@ -9080,10 +9206,10 @@ export default defineMigration({ const modelCommandContext = { ...modelCommandIo.io, projectRoot: modelCommandRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const modelCommands = cliInternals.createInternalCommands(modelCommandContext, async (_projectRoot, _kind, _options, callback) => callback('')) + const modelCommands = createInternalCommands(modelCommandContext, async (_projectRoot, _kind, _options, callback) => callback('')) const makeModel = modelCommands.find(command => command.name === 'make:model') await expect(withFakeBun(async () => makeModel?.run({ projectRoot: modelCommandRoot, @@ -9104,13 +9230,13 @@ export default defineMigration({ tempDirs.push(migrationProjectRoot) await linkWorkspaceDb(migrationProjectRoot) const migrationIo = createIo(migrationProjectRoot) - await withFakeBun(async () => cliInternals.runMakeMigration(migrationIo.io, migrationProjectRoot, { + await withFakeBun(async () => runMakeMigration(migrationIo.io, migrationProjectRoot, { args: ['create_roles_table'], flags: {}, })) expect(migrationIo.read().stdout).toContain('Created migration: server/db/migrations/') - await expect(withFakeBun(async () => cliInternals.runMakeMigration(migrationIo.io, migrationProjectRoot, { + await expect(withFakeBun(async () => runMakeMigration(migrationIo.io, migrationProjectRoot, { args: ['create_roles_table'], flags: {}, }))).rejects.toThrow('A migration named "create_roles_table" already exists') @@ -9119,12 +9245,12 @@ export default defineMigration({ tempDirs.push(createMigrationProjectRoot) await linkWorkspaceDb(createMigrationProjectRoot) const createMigrationIo = createIo(createMigrationProjectRoot) - await withFakeBun(async () => cliInternals.runMakeMigration(createMigrationIo.io, createMigrationProjectRoot, { + await withFakeBun(async () => runMakeMigration(createMigrationIo.io, createMigrationProjectRoot, { args: ['create_audit_logs_table'], flags: { create: 'audit_logs' }, })) expect(createMigrationIo.read().stdout).toContain('Created migration: server/db/migrations/') - await expect(withFakeBun(async () => cliInternals.runMakeMigration(createMigrationIo.io, createMigrationProjectRoot, { + await expect(withFakeBun(async () => runMakeMigration(createMigrationIo.io, createMigrationProjectRoot, { args: ['create_audit_logs_table'], flags: { create: 'audit_logs' }, }))).rejects.toThrow('A migration for table "audit_logs" already exists') @@ -9163,7 +9289,7 @@ export default defineMigration({ `, ) const createMigrationScanIo = createIo(createMigrationScanRoot) - await withFakeBun(async () => cliInternals.runMakeMigration(createMigrationScanIo.io, createMigrationScanRoot, { + await withFakeBun(async () => runMakeMigration(createMigrationScanIo.io, createMigrationScanRoot, { args: ['create_projects_table'], flags: { create: 'projects' }, })) @@ -9173,12 +9299,12 @@ export default defineMigration({ tempDirs.push(alterMigrationProjectRoot) await linkWorkspaceDb(alterMigrationProjectRoot) const alterMigrationIo = createIo(alterMigrationProjectRoot) - await withFakeBun(async () => cliInternals.runMakeMigration(alterMigrationIo.io, alterMigrationProjectRoot, { + await withFakeBun(async () => runMakeMigration(alterMigrationIo.io, alterMigrationProjectRoot, { args: ['add_status_to_users_table'], flags: { table: 'users' }, })) expect(alterMigrationIo.read().stdout).toContain('Created migration: server/db/migrations/') - await expect(withFakeBun(async () => cliInternals.runMakeMigration(alterMigrationIo.io, alterMigrationProjectRoot, { + await expect(withFakeBun(async () => runMakeMigration(alterMigrationIo.io, alterMigrationProjectRoot, { args: ['add_status_to_users_table'], flags: { table: 'users' }, }))).rejects.toThrow('A migration named "add_status_to_users_table" already exists') @@ -9193,7 +9319,7 @@ export default defineMigration({ tempDirs.push(seederProjectRoot) await linkWorkspaceDb(seederProjectRoot) const seederIo = createIo(seederProjectRoot) - await withFakeBun(async () => cliInternals.runMakeSeeder(seederIo.io, seederProjectRoot, { + await withFakeBun(async () => runMakeSeeder(seederIo.io, seederProjectRoot, { args: ['RoleSeeder'], flags: {}, })) @@ -9202,13 +9328,13 @@ export default defineMigration({ const markdownMailProjectRoot = await createTempProject() tempDirs.push(markdownMailProjectRoot) const markdownMailIo = createIo(markdownMailProjectRoot) - await withFakeBun(async () => cliInternals.runMakeMail(markdownMailIo.io, markdownMailProjectRoot, { + await withFakeBun(async () => runMakeMail(markdownMailIo.io, markdownMailProjectRoot, { args: ['auth/verify-email'], flags: { type: 'markdown' }, })) expect(markdownMailIo.read().stdout).toContain('Created mail: server/mail/auth/verify-email.ts') await expect(readFile(join(markdownMailProjectRoot, 'server/mail/auth/verify-email.ts'), 'utf8')).resolves.toContain('defineMail') - await expect(withFakeBun(async () => cliInternals.runMakeMail(markdownMailIo.io, markdownMailProjectRoot, { + await expect(withFakeBun(async () => runMakeMail(markdownMailIo.io, markdownMailProjectRoot, { args: ['auth/verify-email'], flags: { type: 'markdown' }, }))).rejects.toThrow('Refusing to overwrite existing file') @@ -9216,7 +9342,7 @@ export default defineMigration({ const viewMailProjectRoot = await createTempProject() tempDirs.push(viewMailProjectRoot) const viewMailIo = createIo(viewMailProjectRoot) - await expect(withFakeBun(async () => cliInternals.runMakeMail(viewMailIo.io, viewMailProjectRoot, { + await expect(withFakeBun(async () => runMakeMail(viewMailIo.io, viewMailProjectRoot, { args: ['billing/invoice-paid'], flags: { type: 'view' }, }))).rejects.toThrow('View-backed mail scaffolding requires a renderView runtime binding') @@ -9226,81 +9352,25 @@ export default defineMigration({ expect(renderSvelteMailViewTemplate('ExampleMailInput', './example')).toContain('export let to: ExampleMailInput[\'to\']') expect(renderGenericMailViewTemplate('ExampleMail', 'ExampleMailInput', './example')).toContain('ExampleMailInput') - const brokenPackageMailProjectRoot = await createTempProject() - tempDirs.push(brokenPackageMailProjectRoot) - await writeProjectFile(brokenPackageMailProjectRoot, 'package.json', '{') - await expect(generatorInternals.resolveProjectMailViewFramework(brokenPackageMailProjectRoot)).resolves.toBe('generic') - - const nuxtMailProjectRoot = await createTempProject() - tempDirs.push(nuxtMailProjectRoot) - await writeProjectFile(nuxtMailProjectRoot, 'package.json', JSON.stringify({ - name: 'nuxt-mail-fixture', - private: true, - dependencies: { - nuxt: expectedNuxtPackageRange, - }, - }, null, 2)) - await expect(generatorInternals.resolveProjectMailViewFramework(nuxtMailProjectRoot)).resolves.toBe('nuxt') - - const nextMailProjectRoot = await createTempProject() - tempDirs.push(nextMailProjectRoot) - await writeProjectFile(nextMailProjectRoot, 'package.json', JSON.stringify({ - name: 'next-mail-fixture', - private: true, - dependencies: { - next: expectedNextPackageRange, - }, - }, null, 2)) - await expect(generatorInternals.resolveProjectMailViewFramework(nextMailProjectRoot)).resolves.toBe('next') - - const svelteMailProjectRoot = await createTempProject() - tempDirs.push(svelteMailProjectRoot) - await writeProjectFile(svelteMailProjectRoot, 'package.json', JSON.stringify({ - name: 'svelte-mail-fixture', - private: true, - dependencies: { - '@sveltejs/kit': expectedSvelteKitPackageRange, - }, - }, null, 2)) - await expect(generatorInternals.resolveProjectMailViewFramework(svelteMailProjectRoot)).resolves.toBe('sveltekit') - - const genericMailProjectRoot = await createTempProject() - tempDirs.push(genericMailProjectRoot) - await writeProjectFile(genericMailProjectRoot, 'package.json', JSON.stringify({ - name: 'generic-mail-fixture', - private: true, - dependencies: {}, - devDependencies: {}, - }, null, 2)) - await expect(generatorInternals.resolveProjectMailViewFramework(genericMailProjectRoot)).resolves.toBe('generic') - - const genericMailProjectNoDepsRoot = await createTempProject() - tempDirs.push(genericMailProjectNoDepsRoot) - await writeProjectFile(genericMailProjectNoDepsRoot, 'package.json', JSON.stringify({ - name: 'generic-mail-no-deps-fixture', - private: true, - }, null, 2)) - await expect(generatorInternals.resolveProjectMailViewFramework(genericMailProjectNoDepsRoot)).resolves.toBe('generic') - const jobProjectRoot = await createTempProject() tempDirs.push(jobProjectRoot) const jobIo = createIo(jobProjectRoot) - await withFakeBun(async () => cliInternals.runMakeJob(jobIo.io, jobProjectRoot, { + await withFakeBun(async () => runMakeJob(jobIo.io, jobProjectRoot, { args: ['media/GenerateConversions'], flags: {}, })) expect(jobIo.read().stdout).toContain('Created job: server/jobs/media/generate-conversions.ts') await expect(readFile(join(jobProjectRoot, 'server/jobs/media/generate-conversions.ts'), 'utf8')).resolves.toContain('defineJob') - await expect(withFakeBun(async () => cliInternals.runMakeJob(jobIo.io, jobProjectRoot, { + await expect(withFakeBun(async () => runMakeJob(jobIo.io, jobProjectRoot, { args: ['media/generate-conversions'], flags: {}, }))).rejects.toThrow('Job with the same name already exists: media.generate-conversions.') - await withFakeBun(async () => cliInternals.runMakeJob(jobIo.io, jobProjectRoot, { + await withFakeBun(async () => runMakeJob(jobIo.io, jobProjectRoot, { args: ['SendDigest'], flags: {}, })) expect(jobIo.read().stdout).toContain('Created job: server/jobs/send-digest.ts') - await expect(withFakeBun(async () => cliInternals.runMakeJob(jobIo.io, jobProjectRoot, { + await expect(withFakeBun(async () => runMakeJob(jobIo.io, jobProjectRoot, { args: [], flags: {}, }))).rejects.toThrow('A name is required.') @@ -9308,21 +9378,21 @@ export default defineMigration({ const eventProjectRoot = await createTempProject() tempDirs.push(eventProjectRoot) const eventIo = createIo(eventProjectRoot) - await withFakeBun(async () => cliInternals.runMakeEvent(eventIo.io, eventProjectRoot, { + await withFakeBun(async () => runMakeEvent(eventIo.io, eventProjectRoot, { args: ['user/registered'], flags: {}, })) expect(eventIo.read().stdout).toContain('Created event: server/events/user/registered.ts') await expect(readFile(join(eventProjectRoot, 'server/events/user/registered.ts'), 'utf8')).resolves.toContain('defineEvent') - await expect(withFakeBun(async () => cliInternals.runMakeEvent(eventIo.io, eventProjectRoot, { + await expect(withFakeBun(async () => runMakeEvent(eventIo.io, eventProjectRoot, { args: ['user/registered'], flags: {}, }))).rejects.toThrow('Event with the same name already exists: user.registered.') - await expect(withFakeBun(async () => cliInternals.runMakeEvent(eventIo.io, eventProjectRoot, { + await expect(withFakeBun(async () => runMakeEvent(eventIo.io, eventProjectRoot, { args: [], flags: {}, }))).rejects.toThrow('A name is required.') - await withFakeBun(async () => cliInternals.runMakeEvent(eventIo.io, eventProjectRoot, { + await withFakeBun(async () => runMakeEvent(eventIo.io, eventProjectRoot, { args: ['OrderPlaced'], flags: {}, })) @@ -9332,22 +9402,22 @@ export default defineMigration({ tempDirs.push(broadcastProjectRoot) await linkWorkspaceBroadcast(broadcastProjectRoot) const broadcastIo = createIo(broadcastProjectRoot) - await withFakeBun(async () => cliInternals.runMakeBroadcast(broadcastIo.io, broadcastProjectRoot, { + await withFakeBun(async () => runMakeBroadcast(broadcastIo.io, broadcastProjectRoot, { args: ['orders/shipment-updated'], flags: {}, })) expect(broadcastIo.read().stdout).toContain('Created broadcast: server/broadcast/orders/shipment-updated.ts') await expect(readFile(join(broadcastProjectRoot, 'server/broadcast/orders/shipment-updated.ts'), 'utf8')).resolves.toContain('defineBroadcast') - await expect(withFakeBun(async () => cliInternals.runMakeBroadcast(broadcastIo.io, broadcastProjectRoot, { + await expect(withFakeBun(async () => runMakeBroadcast(broadcastIo.io, broadcastProjectRoot, { args: ['orders/shipment-updated'], flags: {}, }))).rejects.toThrow('Broadcast with the same name already exists: orders.shipment-updated.') - await withFakeBun(async () => cliInternals.runMakeBroadcast(broadcastIo.io, broadcastProjectRoot, { + await withFakeBun(async () => runMakeBroadcast(broadcastIo.io, broadcastProjectRoot, { args: ['ShipmentUpdated'], flags: {}, })) expect(broadcastIo.read().stdout).toContain('Created broadcast: server/broadcast/shipment-updated.ts') - await expect(withFakeBun(async () => cliInternals.runMakeBroadcast(broadcastIo.io, broadcastProjectRoot, { + await expect(withFakeBun(async () => runMakeBroadcast(broadcastIo.io, broadcastProjectRoot, { args: [], flags: {}, }))).rejects.toThrow('A name is required.') @@ -9356,17 +9426,17 @@ export default defineMigration({ tempDirs.push(channelProjectRoot) await linkWorkspaceBroadcast(channelProjectRoot) const channelIo = createIo(channelProjectRoot) - await withFakeBun(async () => cliInternals.runMakeChannel(channelIo.io, channelProjectRoot, { + await withFakeBun(async () => runMakeChannel(channelIo.io, channelProjectRoot, { args: ['orders.{orderId}'], flags: {}, })) expect(channelIo.read().stdout).toContain('Created channel: server/channels/orders-order-id.ts') await expect(readFile(join(channelProjectRoot, 'server/channels/orders-order-id.ts'), 'utf8')).resolves.toContain('defineChannel') - await withFakeBun(async () => cliInternals.runMakeChannel(channelIo.io, channelProjectRoot, { + await withFakeBun(async () => runMakeChannel(channelIo.io, channelProjectRoot, { args: ['orders.{id}'], flags: {}, })) - await withFakeBun(async () => cliInternals.runMakeChannel(channelIo.io, channelProjectRoot, { + await withFakeBun(async () => runMakeChannel(channelIo.io, channelProjectRoot, { args: ['orders.id'], flags: {}, })) @@ -9379,17 +9449,17 @@ export default defineMigration({ expect.stringContaining("defineChannel('orders.{id}'"), expect.stringContaining("defineChannel('orders.id'"), ])) - await expect(withFakeBun(async () => cliInternals.runMakeChannel(channelIo.io, channelProjectRoot, { + await expect(withFakeBun(async () => runMakeChannel(channelIo.io, channelProjectRoot, { args: ['orders.{orderId}'], flags: {}, }))).rejects.toThrow('Channel with the same pattern already exists: orders.{orderId}.') - await expect(withFakeBun(async () => cliInternals.runMakeChannel(channelIo.io, channelProjectRoot, { + await expect(withFakeBun(async () => runMakeChannel(channelIo.io, channelProjectRoot, { args: [], flags: {}, }))).rejects.toThrow('A channel pattern is required.') expect(generatorInternals.toChannelTemplateFileStem('{}')).toBe('channel') expect(generatorInternals.toChannelTemplateFileStem('{orderId}')).not.toBe(generatorInternals.toChannelTemplateFileStem('{order-id}')) - await expect(withFakeBun(async () => cliInternals.runMakeChannel(channelIo.io, channelProjectRoot, { + await expect(withFakeBun(async () => runMakeChannel(channelIo.io, channelProjectRoot, { args: [''], flags: {}, }))).rejects.toThrow('A channel pattern is required.') @@ -9408,29 +9478,29 @@ import { defineEvent } from '@holo-js/events' export default defineEvent({ name: 'audit.activity' }) `) await withFakeBun(async () => prepareProjectDiscovery(listenerProjectRoot)) - await withFakeBun(async () => cliInternals.runMakeListener(listenerIo.io, listenerProjectRoot, { + await withFakeBun(async () => runMakeListener(listenerIo.io, listenerProjectRoot, { args: ['user/send-welcome-email'], flags: { event: 'user.registered' }, })) expect(listenerIo.read().stdout).toContain('Created listener: server/listeners/user/send-welcome-email.ts') await expect(readFile(join(listenerProjectRoot, 'server/listeners/user/send-welcome-email.ts'), 'utf8')).resolves.toContain('defineListener') - await expect(withFakeBun(async () => cliInternals.runMakeListener(listenerIo.io, listenerProjectRoot, { + await expect(withFakeBun(async () => runMakeListener(listenerIo.io, listenerProjectRoot, { args: ['user/send-welcome-email'], flags: { event: 'user.registered' }, }))).rejects.toThrow('Listener with the same id already exists: user.send-welcome-email.') - await expect(withFakeBun(async () => cliInternals.runMakeListener(listenerIo.io, listenerProjectRoot, { + await expect(withFakeBun(async () => runMakeListener(listenerIo.io, listenerProjectRoot, { args: ['user/other'], flags: { event: 'missing.event' }, }))).rejects.toThrow('Unknown event: missing.event.') - await expect(withFakeBun(async () => cliInternals.runMakeListener(listenerIo.io, listenerProjectRoot, { + await expect(withFakeBun(async () => runMakeListener(listenerIo.io, listenerProjectRoot, { args: [], flags: { event: 'user.registered' }, }))).rejects.toThrow('A name is required.') - await expect(withFakeBun(async () => cliInternals.runMakeListener(listenerIo.io, listenerProjectRoot, { + await expect(withFakeBun(async () => runMakeListener(listenerIo.io, listenerProjectRoot, { args: [], flags: {}, }))).rejects.toThrow('A name is required.') - await withFakeBun(async () => cliInternals.runMakeListener(listenerIo.io, listenerProjectRoot, { + await withFakeBun(async () => runMakeListener(listenerIo.io, listenerProjectRoot, { args: ['user/audit-user-events'], flags: { event: ['user.registered', 'audit.activity'] }, })) @@ -9442,7 +9512,7 @@ import { defineEvent } from '@holo-js/events' export const ActivityRecorded = defineEvent({ name: 'audit.activity.named' }) `) await withFakeBun(async () => prepareProjectDiscovery(listenerProjectRoot)) - await withFakeBun(async () => cliInternals.runMakeListener(listenerIo.io, listenerProjectRoot, { + await withFakeBun(async () => runMakeListener(listenerIo.io, listenerProjectRoot, { args: ['user/audit-named-event'], flags: { event: 'audit.activity.named' }, })) @@ -9461,7 +9531,7 @@ export const ActivityRecorded = defineEvent({ name: 'audit.activity.named' }) import { defineEvent } from '@holo-js/events' export default defineEvent({ name: 'user.registered' }) `) - await withFakeBun(async () => cliInternals.runMakeListener(listenerFallbackIo.io, listenerFallbackProjectRoot, { + await withFakeBun(async () => runMakeListener(listenerFallbackIo.io, listenerFallbackProjectRoot, { args: ['SendInline'], flags: { event: 'user.registered' }, })) @@ -9471,7 +9541,7 @@ export default defineEvent({ name: 'user.registered' }) tempDirs.push(observerProjectRoot) await linkWorkspaceDb(observerProjectRoot) const observerIo = createIo(observerProjectRoot) - await withFakeBun(async () => cliInternals.runMakeObserver(observerIo.io, observerProjectRoot, { + await withFakeBun(async () => runMakeObserver(observerIo.io, observerProjectRoot, { args: ['RoleObserver'], flags: {}, })) @@ -9481,7 +9551,7 @@ export default defineEvent({ name: 'user.registered' }) tempDirs.push(factoryProjectRoot) await linkWorkspaceDb(factoryProjectRoot) const factoryIo = createIo(factoryProjectRoot) - await withFakeBun(async () => cliInternals.runMakeFactory(factoryIo.io, factoryProjectRoot, { + await withFakeBun(async () => runMakeFactory(factoryIo.io, factoryProjectRoot, { args: ['RoleFactory'], flags: {}, })) @@ -9494,10 +9564,10 @@ export default defineEvent({ name: 'user.registered' }) const migrationCommandContext = { ...migrationCommandIo.io, projectRoot: migrationCommandRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const migrationCommands = cliInternals.createInternalCommands(migrationCommandContext, async (_projectRoot, _kind, _options, callback) => callback('')) + const migrationCommands = createInternalCommands(migrationCommandContext, async (_projectRoot, _kind, _options, callback) => callback('')) const makeMigration = migrationCommands.find(command => command.name === 'make:migration') const preparedMigration = await makeMigration?.prepare?.({ args: ['create_lessons_table'], flags: {} }, migrationCommandContext as never) const preparedCreateMigration = await makeMigration?.prepare?.({ args: ['create_audits_table'], flags: { create: 'audits' } }, migrationCommandContext as never) @@ -9515,7 +9585,7 @@ export default defineEvent({ name: 'user.registered' }) args: ['bad'], flags: { create: 'users', table: 'users' }, }, migrationCommandContext as never)).rejects.toThrow('Use either "--create" or "--table", not both.') - await expect(withFakeBun(async () => cliInternals.runMakeMigration(migrationCommandIo.io, migrationCommandRoot, { + await expect(withFakeBun(async () => runMakeMigration(migrationCommandIo.io, migrationCommandRoot, { args: ['bad'], flags: { create: 'users', table: 'users' }, }))).rejects.toThrow('Use either "--create" or "--table", not both.') @@ -9527,10 +9597,10 @@ export default defineEvent({ name: 'user.registered' }) const seederCommandContext = { ...seederCommandIo.io, projectRoot: seederCommandRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const seederCommands = cliInternals.createInternalCommands(seederCommandContext, async (_projectRoot, _kind, _options, callback) => callback('')) + const seederCommands = createInternalCommands(seederCommandContext, async (_projectRoot, _kind, _options, callback) => callback('')) const makeSeeder = seederCommands.find(command => command.name === 'make:seeder') const preparedSeeder = await makeSeeder?.prepare?.({ args: ['LessonSeeder'], flags: {} }, seederCommandContext as never) await expect(withFakeBun(async () => makeSeeder?.run({ @@ -9548,10 +9618,10 @@ export default defineEvent({ name: 'user.registered' }) const jobCommandContext = { ...jobCommandIo.io, projectRoot: jobCommandRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const jobCommands = cliInternals.createInternalCommands(jobCommandContext, async (_projectRoot, _kind, _options, callback) => callback('')) + const jobCommands = createInternalCommands(jobCommandContext, async (_projectRoot, _kind, _options, callback) => callback('')) const makeBroadcast = jobCommands.find(command => command.name === 'make:broadcast') const makeChannel = jobCommands.find(command => command.name === 'make:channel') const makeEvent = jobCommands.find(command => command.name === 'make:event') @@ -9571,10 +9641,10 @@ export default defineEvent({ name: 'user.registered' }) const promptedMailContext = { ...promptedMailIo.io, projectRoot: jobCommandRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const promptedMakeMail = cliInternals.createInternalCommands( + const promptedMakeMail = createInternalCommands( promptedMailContext, async (_projectRoot, _kind, _options, callback) => callback(''), ).find(command => command.name === 'make:mail') @@ -9641,7 +9711,7 @@ export default defineEvent({ name: 'user.registered' }) const interactiveMailContext = { ...interactiveMailIo.io, projectRoot: jobCommandRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } await expect(makeMail?.prepare?.({ @@ -9667,15 +9737,15 @@ export default defineEvent({ name: 'user.registered' }) args: ['bad'], flags: { markdown: true, view: true }, }, jobCommandContext as never)).rejects.toThrow('Use either "--markdown" or "--view", not both.') - await expect(withFakeBun(async () => cliInternals.runMakeListener(jobCommandIo.io, jobCommandRoot, { + await expect(withFakeBun(async () => runMakeListener(jobCommandIo.io, jobCommandRoot, { args: ['SendWelcomeEmail'], flags: { event: 'missing.event' }, }))).rejects.toThrow('Unknown event: missing.event.') - await expect(withFakeBun(async () => cliInternals.runMakeJob(jobCommandIo.io, jobCommandRoot, { + await expect(withFakeBun(async () => runMakeJob(jobCommandIo.io, jobCommandRoot, { args: [], flags: {}, }))).rejects.toThrow('A name is required.') - await expect(withFakeBun(async () => cliInternals.runMakeMail(jobCommandIo.io, jobCommandRoot, { + await expect(withFakeBun(async () => runMakeMail(jobCommandIo.io, jobCommandRoot, { args: [], flags: { type: 'markdown' }, }))).rejects.toThrow('A name is required.') @@ -9699,10 +9769,10 @@ export default defineEvent({ name: 'audit.activity' }) const observerCommandContext = { ...observerCommandIo.io, projectRoot: observerCommandRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const observerCommands = cliInternals.createInternalCommands(observerCommandContext, async (_projectRoot, _kind, _options, callback) => callback('')) + const observerCommands = createInternalCommands(observerCommandContext, async (_projectRoot, _kind, _options, callback) => callback('')) const makeObserver = observerCommands.find(command => command.name === 'make:observer') const preparedObserver = await makeObserver?.prepare?.({ args: ['CourseObserver'], flags: {} }, observerCommandContext as never) await expect(withFakeBun(async () => makeObserver?.run({ @@ -9720,10 +9790,10 @@ export default defineEvent({ name: 'audit.activity' }) const factoryCommandContext = { ...factoryCommandIo.io, projectRoot: factoryCommandRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const factoryCommands = cliInternals.createInternalCommands(factoryCommandContext, async (_projectRoot, _kind, _options, callback) => callback('')) + const factoryCommands = createInternalCommands(factoryCommandContext, async (_projectRoot, _kind, _options, callback) => callback('')) const makeFactory = factoryCommands.find(command => command.name === 'make:factory') const preparedFactory = await makeFactory?.prepare?.({ args: ['CourseFactory'], flags: {} }, factoryCommandContext as never) await expect(withFakeBun(async () => makeFactory?.run({ @@ -9939,7 +10009,7 @@ export default defineConfig({ `) await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot) + await runProjectPrepare(projectRoot) }) const registry = await withFakeBun(async () => loadGeneratedProjectRegistry(projectRoot)) expect(registry?.commands).toMatchObject([{ name: 'hello' }]) @@ -9955,14 +10025,14 @@ export default defineConfig({ await expect(readFile(join(projectRoot, '.holo-js/generated', 'queue.d.ts'), 'utf8')).resolves.toContain('"send-email": QueueJobDefinition') await expect(readFile(join(projectRoot, '.holo-js/generated', 'queue.d.ts'), 'utf8')).resolves.not.toContain('server/jobs/send-email') - const pnpmResolution = await cliInternals.resolvePackageManagerCommand(projectRoot, 'holo:build') + const pnpmResolution = await resolvePackageManagerCommand(projectRoot, 'holo:build') expect(pnpmResolution).toEqual({ command: 'pnpm', args: ['run', 'holo:build'] }) const yarnRoot = await createTempDirectory() tempDirs.push(yarnRoot) await writeProjectFile(yarnRoot, 'package.json', JSON.stringify({ name: 'fixture', private: true }, null, 2)) await writeProjectFile(yarnRoot, 'yarn.lock', '') - await expect(cliInternals.resolvePackageManagerCommand(yarnRoot, 'holo:dev')).resolves.toEqual({ + await expect(resolvePackageManagerCommand(yarnRoot, 'holo:dev')).resolves.toEqual({ command: 'yarn', args: ['run', 'holo:dev'], }) @@ -9971,14 +10041,14 @@ export default defineConfig({ tempDirs.push(npmRoot) await writeProjectFile(npmRoot, 'package.json', '{ invalid json') await writeProjectFile(npmRoot, 'package-lock.json', '') - await expect(cliInternals.resolvePackageManagerCommand(npmRoot, 'holo:dev')).resolves.toEqual({ + await expect(resolvePackageManagerCommand(npmRoot, 'holo:dev')).resolves.toEqual({ command: 'npm', args: ['run', 'holo:dev'], }) const defaultRoot = await createTempDirectory() tempDirs.push(defaultRoot) - await expect(cliInternals.resolvePackageManagerCommand(defaultRoot, 'holo:dev')).resolves.toEqual({ + await expect(resolvePackageManagerCommand(defaultRoot, 'holo:dev')).resolves.toEqual({ command: 'bun', args: ['run', 'holo:dev'], }) @@ -9986,7 +10056,7 @@ export default defineConfig({ const bunLockRoot = await createTempDirectory() tempDirs.push(bunLockRoot) await writeProjectFile(bunLockRoot, 'bun.lock', '') - await expect(cliInternals.resolvePackageManagerCommand(bunLockRoot, 'holo:dev')).resolves.toEqual({ + await expect(resolvePackageManagerCommand(bunLockRoot, 'holo:dev')).resolves.toEqual({ command: 'bun', args: ['run', 'holo:dev'], }) @@ -9994,13 +10064,13 @@ export default defineConfig({ const pnpmLockRoot = await createTempDirectory() tempDirs.push(pnpmLockRoot) await writeProjectFile(pnpmLockRoot, 'pnpm-lock.yaml', '') - await expect(cliInternals.resolvePackageManagerCommand(pnpmLockRoot, 'holo:dev')).resolves.toEqual({ + await expect(resolvePackageManagerCommand(pnpmLockRoot, 'holo:dev')).resolves.toEqual({ command: 'pnpm', args: ['run', 'holo:dev'], }) const lifecycleIo = createIo(projectRoot) - await expect(cliInternals.runProjectLifecycleScript(lifecycleIo.io, projectRoot, 'holo:build', () => ({ + await expect(runProjectLifecycleScript(lifecycleIo.io, projectRoot, 'holo:build', () => ({ status: 0, stdout: 'built\n', stderr: 'warned\n', @@ -10010,7 +10080,7 @@ export default defineConfig({ } as never))).resolves.toBeUndefined() expect(lifecycleIo.read().stdout).toContain('built') expect(lifecycleIo.read().stderr).toContain('warned') - await expect(cliInternals.runProjectLifecycleScript(lifecycleIo.io, projectRoot, 'holo:build', () => ({ + await expect(runProjectLifecycleScript(lifecycleIo.io, projectRoot, 'holo:build', () => ({ status: 0, stdout: 'built-again\n', stderr: '', @@ -10019,7 +10089,7 @@ export default defineConfig({ signal: null, } as never))).resolves.toBeUndefined() expect(lifecycleIo.read().stdout).toContain('built-again') - await expect(cliInternals.runProjectLifecycleScript(lifecycleIo.io, projectRoot, 'holo:build', () => ({ + await expect(runProjectLifecycleScript(lifecycleIo.io, projectRoot, 'holo:build', () => ({ status: 1, stdout: '', stderr: '', @@ -10031,10 +10101,10 @@ export default defineConfig({ const buildCommandContext = { ...createIo(projectRoot).io, projectRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const buildCommands = cliInternals.createInternalCommands(buildCommandContext, async (_projectRoot, _kind, _options, callback) => callback('')) + const buildCommands = createInternalCommands(buildCommandContext, async (_projectRoot, _kind, _options, callback) => callback('')) const buildCommand = buildCommands.find(command => command.name === 'build') expect(await buildCommand?.prepare?.({ args: [], flags: {} }, buildCommandContext as never)).toEqual({ args: [], flags: {} }) @@ -10054,10 +10124,10 @@ export default defineConfig({ const lifecycleContext = { ...lifecycleCommandIo.io, projectRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } - const lifecycleCommands = cliInternals.createInternalCommands( + const lifecycleCommands = createInternalCommands( lifecycleContext, async (_projectRoot, _kind, _options, callback) => callback(''), {}, @@ -10149,7 +10219,7 @@ export default defineAbility('reports.export', () => true) `) await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot) + await runProjectPrepare(projectRoot) }) await expect(readFile(join(projectRoot, '.holo-js/generated/queue.d.ts'), 'utf8')).resolves.toContain('"send-email": ExportedQueueJobDefinition') @@ -10160,7 +10230,7 @@ export default defineAbility('reports.export', () => true) }, 30000) it('preserves manifest connection fields when env overrides are partial', () => { - expect(cliInternals.mergeRuntimeDatabaseConfig({ + expect(mergeRuntimeDatabaseConfig({ defaultConnection: 'primary', connections: { primary: { @@ -10204,7 +10274,7 @@ export default defineAbility('reports.export', () => true) const cacheWriter = vi.fn(async () => '/tmp/config-cache.json') - await expect(cliInternals.cacheProjectConfig(projectRoot, cacheWriter)).resolves.toBe('/tmp/config-cache.json') + await expect(cacheProjectConfig(projectRoot, cacheWriter)).resolves.toBe('/tmp/config-cache.json') expect(cacheWriter).toHaveBeenCalledWith( projectRoot, expect.objectContaining({ @@ -10225,7 +10295,7 @@ export default { `) await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot) + await runProjectPrepare(projectRoot) }) const generatedTsconfigPath = join(projectRoot, '.holo-js/generated/tsconfig.json') @@ -10237,7 +10307,7 @@ export default { await new Promise(resolvePromise => setTimeout(resolvePromise, 25)) await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot) + await runProjectPrepare(projectRoot) }) const secondTsconfigStat = await stat(generatedTsconfigPath) @@ -10274,7 +10344,7 @@ export default { let watchCallback: ((eventType: string, fileName: string | Buffer | null) => void) | undefined const closeWatcher = vi.fn() - const devPromise = withFakeBun(async () => cliInternals.runProjectDevServer( + const devPromise = withFakeBun(async () => runProjectDevServer( io.io, projectRoot, (() => child as never) as never, @@ -10334,7 +10404,7 @@ throw 'string discovery failure' let watchCallback: ((eventType: string, fileName: string | Buffer | null) => void) | undefined const prepare = vi.fn(async () => {}) - const devPromise = withFakeBun(async () => cliInternals.runProjectDevServer( + const devPromise = withFakeBun(async () => runProjectDevServer( io.io, projectRoot, (() => child as never) as never, @@ -10475,7 +10545,7 @@ throw 'string discovery failure' let watchCallback: ((eventType: string, fileName: string | Buffer | null) => void) | undefined const prepare = vi.fn(async () => {}) - const devPromise = withFakeBun(async () => cliInternals.runProjectDevServer( + const devPromise = withFakeBun(async () => runProjectDevServer( io.io, projectRoot, (() => child as never) as never, @@ -10553,7 +10623,7 @@ export default undefined })) await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot) + await runProjectPrepare(projectRoot) }) await withFakeBun(async () => { @@ -10586,7 +10656,7 @@ registerGeneratedTables(tables) `) await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot) + await runProjectPrepare(projectRoot) }) const schemaExists = await readFile(join(projectRoot, '.holo-js/generated/schema.generated.ts'), 'utf8') @@ -10617,7 +10687,7 @@ export default defineConfig({ await writeProjectFile(projectRoot, 'config/nested/hidden.ts', 'export default { hidden: true }') await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot) + await runProjectPrepare(projectRoot) }) const generatedTypes = await readFile(join(projectRoot, '.holo-js/generated/config.d.ts'), 'utf8') @@ -10668,7 +10738,7 @@ export default defineConfig({ `) await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot) + await runProjectPrepare(projectRoot) }) const generatedTypes = await readFile(join(projectRoot, '.holo-js/generated/config.d.ts'), 'utf8') @@ -10683,7 +10753,7 @@ export default defineConfig({ await writeProjectFile(projectRoot, '.holo-js/framework/project.json', JSON.stringify({ framework: 'nuxt' }, null, 2)) await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot) + await runProjectPrepare(projectRoot) }) const generatedTsconfig = JSON.parse( @@ -10708,7 +10778,7 @@ export default defineConfig({ await writeProjectFile(projectRoot, 'app/api/auth/clerk/login/route.ts', 'export { GET } from \'../../../../../.holo-js/generated/next/auth-clerk-login-route\'\n') await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot) + await runProjectPrepare(projectRoot) }) await expect(stat(join(projectRoot, '.holo-js/generated/next/health-route.ts'))).rejects.toThrow() @@ -10791,7 +10861,7 @@ export default defineConfig({ ].join('\n')) await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot) + await runProjectPrepare(projectRoot) }) // User-owned files remain at the standard SvelteKit paths @@ -10868,7 +10938,7 @@ export default defineConfig({ ].join('\n')) await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot) + await runProjectPrepare(projectRoot) }) await expect(readFile(join(projectRoot, 'src/hooks.user.ts'), 'utf8')).resolves.toContain('export const transport') @@ -10891,7 +10961,7 @@ export default defineConfig({ ].join('\n')) await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot) + await runProjectPrepare(projectRoot) }) const svelteConfig = await readFile(join(projectRoot, 'svelte.config.js'), 'utf8') @@ -10930,7 +11000,7 @@ export default defineConfig({ ].join('\n')) await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot) + await runProjectPrepare(projectRoot) }) const svelteConfig = await readFile(join(projectRoot, 'svelte.config.js'), 'utf8') @@ -10957,7 +11027,7 @@ export default defineConfig({ ].join('\n')) await withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot) + await runProjectPrepare(projectRoot) }) const svelteConfig = await readFile(join(projectRoot, 'svelte.config.js'), 'utf8') @@ -10989,7 +11059,7 @@ export default defineConfig({ ].join('\n')) await expect(withFakeBun(async () => { - await cliInternals.runProjectPrepare(projectRoot) + await runProjectPrepare(projectRoot) })).rejects.toThrow('Custom SvelteKit hook entrypoints are not supported') }, 30000) @@ -11007,7 +11077,7 @@ export default defineConfig({ errorChild.stderr = new PassThrough() errorChild.stdin = new PassThrough() const errorWatcherClose = vi.fn() - const devErrorPromise = withFakeBun(async () => cliInternals.runProjectDevServer( + const devErrorPromise = withFakeBun(async () => runProjectDevServer( io.io, projectRoot, (() => errorChild as never) as never, @@ -11031,7 +11101,7 @@ export default defineConfig({ closeChild.stderr = new PassThrough() closeChild.stdin = new PassThrough() const closeWatcher = vi.fn() - const devClosePromise = withFakeBun(async () => cliInternals.runProjectDevServer( + const devClosePromise = withFakeBun(async () => runProjectDevServer( io.io, projectRoot, (() => closeChild as never) as never, @@ -11074,7 +11144,7 @@ export default defineConfig({ }) }) - const devPromise = withFakeBun(async () => cliInternals.runProjectDevServer( + const devPromise = withFakeBun(async () => runProjectDevServer( io.io, projectRoot, (() => child as never) as never, @@ -11122,7 +11192,7 @@ export default { let watchCallback: ((eventType: string, fileName: string | Buffer | null) => void) | undefined const prepare = vi.fn(async () => {}) - const devPromise = withFakeBun(async () => cliInternals.runProjectDevServer( + const devPromise = withFakeBun(async () => runProjectDevServer( io.io, projectRoot, (() => { @@ -11189,7 +11259,7 @@ export default { let watchCallback: ((eventType: string, fileName: string | Buffer | null) => void) | undefined const prepare = vi.fn(async () => {}) - const devPromise = withFakeBun(async () => cliInternals.runProjectDevServer( + const devPromise = withFakeBun(async () => runProjectDevServer( io.io, projectRoot, (() => { @@ -11271,7 +11341,7 @@ export default defineAppConfig({ } }) - const devPromise = withFakeBun(async () => cliInternals.runProjectDevServer( + const devPromise = withFakeBun(async () => runProjectDevServer( io.io, projectRoot, (() => child as never) as never, @@ -11330,7 +11400,7 @@ export default { const watchModes: Array = [] const watchers = new Map void>() const prepare = vi.fn(async () => {}) - const devPromise = withFakeBun(async () => cliInternals.runProjectDevServer( + const devPromise = withFakeBun(async () => runProjectDevServer( io.io, projectRoot, (() => child as never) as never, @@ -11380,10 +11450,10 @@ export default { ...commandIo.io, projectRoot, loadProject: async () => ({ manifestPath: undefined, config: await loadProjectConfig(projectRoot, { required: true }).then(entry => entry.config) }), - registry: [] as Array>, + registry: [] as Array>, } - const commands = cliInternals.createInternalCommands( + const commands = createInternalCommands( commandContext as never, async (_projectRoot, _kind, _options, callback) => callback(''), { @@ -11494,7 +11564,7 @@ export default { flags: {}, }) - expect(cliInternals.buildQueueWorkArgs({ + expect(buildQueueWorkArgs({ connection: 'redis', queue: ['emails', 'critical'], once: true, @@ -11511,17 +11581,17 @@ export default { '--sleep', '3', ]) - expect(cliInternals.isQueueListenRelevantPath('server/jobs/send-email.ts', await loadProjectConfig(projectRoot, { required: true }))).toBe(true) - expect(cliInternals.isQueueListenRelevantPath('server/services/mail/send.ts', await loadProjectConfig(projectRoot, { required: true }))).toBe(true) - expect(cliInternals.isQueueListenRelevantPath('.holo-js/generated/jobs.ts', await loadProjectConfig(projectRoot, { required: true }))).toBe(true) - expect(cliInternals.isQueueListenRelevantPath('.holo-js/runtime/cli/bundle/report.mjs', await loadProjectConfig(projectRoot, { required: true }))).toBe(false) - expect(cliInternals.isQueueListenRelevantPath('.holo-js\\runtime\\cli\\bundle\\report.mjs', await loadProjectConfig(projectRoot, { required: true }))).toBe(false) - expect(cliInternals.isQueueListenRelevantPath('.env.test', await loadProjectConfig(projectRoot, { required: true }))).toBe(true) - expect(cliInternals.isQueueListenRelevantPath('node_modules/example/index.js', await loadProjectConfig(projectRoot, { required: true }))).toBe(false) - expect(cliInternals.isQueueListenRelevantPath('README.md', await loadProjectConfig(projectRoot, { required: true }))).toBe(false) - expect(cliInternals.resolveModuleExport({ default: { ok: true } }, (value): value is { ok: true } => Boolean((value as { ok?: boolean } | undefined)?.ok))).toEqual({ ok: true }) - expect(cliInternals.resolveModuleExport({ named: { ok: true } }, (value): value is { ok: true } => Boolean((value as { ok?: boolean } | undefined)?.ok))).toEqual({ ok: true }) - expect(cliInternals.resolveModuleExport('nope', (_value): _value is { ok: true } => false)).toBeUndefined() + expect(isQueueListenRelevantPath('server/jobs/send-email.ts', await loadProjectConfig(projectRoot, { required: true }))).toBe(true) + expect(isQueueListenRelevantPath('server/services/mail/send.ts', await loadProjectConfig(projectRoot, { required: true }))).toBe(true) + expect(isQueueListenRelevantPath('.holo-js/generated/jobs.ts', await loadProjectConfig(projectRoot, { required: true }))).toBe(true) + expect(isQueueListenRelevantPath('.holo-js/runtime/cli/bundle/report.mjs', await loadProjectConfig(projectRoot, { required: true }))).toBe(false) + expect(isQueueListenRelevantPath('.holo-js\\runtime\\cli\\bundle\\report.mjs', await loadProjectConfig(projectRoot, { required: true }))).toBe(false) + expect(isQueueListenRelevantPath('.env.test', await loadProjectConfig(projectRoot, { required: true }))).toBe(true) + expect(isQueueListenRelevantPath('node_modules/example/index.js', await loadProjectConfig(projectRoot, { required: true }))).toBe(false) + expect(isQueueListenRelevantPath('README.md', await loadProjectConfig(projectRoot, { required: true }))).toBe(false) + expect(resolveModuleExport({ default: { ok: true } }, (value): value is { ok: true } => Boolean((value as { ok?: boolean } | undefined)?.ok))).toEqual({ ok: true }) + expect(resolveModuleExport({ named: { ok: true } }, (value): value is { ok: true } => Boolean((value as { ok?: boolean } | undefined)?.ok))).toEqual({ ok: true }) + expect(resolveModuleExport('nope', (_value): _value is { ok: true } => false)).toBeUndefined() await queueTable?.run({ projectRoot, @@ -11646,10 +11716,10 @@ export default { ...commandIo.io, projectRoot, loadProject: async () => ({ manifestPath: undefined, config: await loadProjectConfig(projectRoot, { required: true }).then(entry => entry.config) }), - registry: [] as Array>, + registry: [] as Array>, } - const commands = cliInternals.createInternalCommands( + const commands = createInternalCommands( commandContext as never, async (_projectRoot, _kind, _options, callback) => callback(''), {}, @@ -11732,10 +11802,10 @@ export default { ...commandIo.io, projectRoot, loadProject: async () => ({ manifestPath: undefined, config: await loadProjectConfig(projectRoot, { required: true }).then(entry => entry.config) }), - registry: [] as Array>, + registry: [] as Array>, } - const commands = cliInternals.createInternalCommands( + const commands = createInternalCommands( commandContext as never, async (_projectRoot, _kind, _options, callback) => callback(''), {}, @@ -11787,7 +11857,7 @@ export default { const commands = isolatedCli.createInternalCommands({ ...io.io, projectRoot, - registry: [] as Array>, + registry: [] as Array>, loadProject: async () => ({ config: defaultProjectConfig() }), } as never) @@ -11838,10 +11908,10 @@ export default { ...commandIo.io, projectRoot, loadProject: async () => ({ manifestPath: undefined, config: await loadProjectConfig(projectRoot, { required: true }).then(entry => entry.config) }), - registry: [] as Array>, + registry: [] as Array>, } - const commands = cliInternals.createInternalCommands( + const commands = createInternalCommands( commandContext as never, async (_projectRoot, _kind, _options, callback) => callback(''), { @@ -11940,7 +12010,7 @@ export default { args: [], flags: {}, }) - expect(cliInternals.buildQueueWorkArgs({ + expect(buildQueueWorkArgs({ help: true, h: true, once: true, @@ -12047,8 +12117,8 @@ export default { const builtEntrypointPath = resolve(workspaceRoot, 'packages/cli/src/bin/holo.mjs') await writeFile(builtEntrypointPath, '#!/usr/bin/env node\n', 'utf8') try { - expect(cliInternals.resolveCliEntrypointPath()).toBe(builtEntrypointPath) - const runnableEntrypoint = await cliInternals.resolveRunnableCliEntrypoint() + expect(resolveCliEntrypointPath()).toBe(builtEntrypointPath) + const runnableEntrypoint = await resolveRunnableCliEntrypoint() try { expect(runnableEntrypoint.path).toBe(builtEntrypointPath) } finally { @@ -12093,7 +12163,7 @@ export default defineJob({ return child as never }) const watcherClose = vi.fn() - const listenPromise = withFakeBun(async () => cliInternals.runQueueListen( + const listenPromise = withFakeBun(async () => runQueueListen( io.io, projectRoot, {}, @@ -12133,23 +12203,23 @@ export default defineDatabaseConfig({ `) const io = createIo(projectRoot) - await expect(cliInternals.readQueueRestartSignal(projectRoot)).resolves.toBeUndefined() - const signalPath = await cliInternals.writeQueueRestartSignal(projectRoot, 1234) - expect(signalPath).toBe(cliInternals.resolveQueueRestartSignalPath(projectRoot)) - await expect(cliInternals.readQueueRestartSignal(projectRoot)).resolves.toBe(1234) - await expect(cliInternals.hasQueueRestartSignalSince(projectRoot, 1200)).resolves.toBe(true) - await expect(cliInternals.hasQueueRestartSignalSince(projectRoot, 1300)).resolves.toBe(false) - await writeFile(cliInternals.resolveQueueRestartSignalPath(projectRoot), 'NaN\n', 'utf8') - await expect(cliInternals.readQueueRestartSignal(projectRoot)).resolves.toBeUndefined() + await expect(readQueueRestartSignal(projectRoot)).resolves.toBeUndefined() + const signalPath = await writeQueueRestartSignal(projectRoot, 1234) + expect(signalPath).toBe(resolveQueueRestartSignalPath(projectRoot)) + await expect(readQueueRestartSignal(projectRoot)).resolves.toBe(1234) + await expect(hasQueueRestartSignalSince(projectRoot, 1200)).resolves.toBe(true) + await expect(hasQueueRestartSignalSince(projectRoot, 1300)).resolves.toBe(false) + await writeFile(resolveQueueRestartSignalPath(projectRoot), 'NaN\n', 'utf8') + await expect(readQueueRestartSignal(projectRoot)).resolves.toBeUndefined() - await expect(withFakeBun(async () => cliInternals.runQueueRestartCommand(io.io, projectRoot))).resolves.toBeUndefined() + await expect(withFakeBun(async () => runQueueRestartCommand(io.io, projectRoot))).resolves.toBeUndefined() expect(io.read().stdout).toContain('Restart signal written') - await expect(withFakeBun(async () => cliInternals.runQueueWorkCommand(io.io, projectRoot, { + await expect(withFakeBun(async () => runQueueWorkCommand(io.io, projectRoot, { once: true, }))).rejects.toThrow('requires an async-capable driver') - await expect(withFakeBun(async () => cliInternals.runQueueClearCommand(io.io, projectRoot, undefined, undefined))).resolves.toBeUndefined() + await expect(withFakeBun(async () => runQueueClearCommand(io.io, projectRoot, undefined, undefined))).resolves.toBeUndefined() expect(io.read().stdout).toContain('Cleared 0 pending job(s).') }) @@ -12203,7 +12273,7 @@ export default defineJob({ } as unknown as Awaited> try { - const { cliInternals: isolatedCliInternals } = await import('../src/cli-internals') + const isolatedCliInternals = await import('../src/queue') const queueModule = await import('@holo-js/queue') mockedRuntime.shutdown = vi.fn(async () => { queueModule.resetQueueRuntime() @@ -12330,7 +12400,7 @@ export default defineJob({ const cleanup = vi.fn(async () => {}) const onJobFailed = vi.fn() - await expect(cliInternals.runQueueWorkCommand(io.io, projectRoot, { + await expect(runQueueWorkCommand(io.io, projectRoot, { onJobFailed, }, { getEnvironment: async () => ({ @@ -12375,7 +12445,7 @@ export default defineJob({ const io = createIo(projectRoot) const cleanup = vi.fn(async () => {}) - await expect(cliInternals.runQueueWorkCommand(io.io, projectRoot, { + await expect(runQueueWorkCommand(io.io, projectRoot, { shouldStop: async () => false, }, { getEnvironment: async () => ({ @@ -12417,7 +12487,7 @@ export default defineJob({ const io = createIo(projectRoot) const cleanup = vi.fn(async () => {}) - await expect(cliInternals.runQueueWorkCommand(io.io, projectRoot, { + await expect(runQueueWorkCommand(io.io, projectRoot, { shouldStop: async () => true, }, { getEnvironment: async () => ({ @@ -12447,9 +12517,9 @@ export default defineJob({ tempDirs.push(projectRoot) const io = createIo(projectRoot) const cleanup = vi.fn(async () => {}) - await cliInternals.writeQueueRestartSignal(projectRoot, Date.now() + 1000) + await writeQueueRestartSignal(projectRoot, Date.now() + 1000) - await expect(cliInternals.runQueueWorkCommand(io.io, projectRoot, {}, { + await expect(runQueueWorkCommand(io.io, projectRoot, {}, { getEnvironment: async () => ({ runtime: {} as never, project: await loadProjectConfig(projectRoot, { required: true }), @@ -12478,7 +12548,7 @@ export default defineJob({ const shutdown = vi.fn(async () => {}) const clear = vi.fn(async () => 4) - await expect(cliInternals.runQueueClearCommand(io.io, projectRoot, 'redis', undefined, { + await expect(runQueueClearCommand(io.io, projectRoot, 'redis', undefined, { initialize: async () => ({ shutdown }) as never, clear: clear as never, })).resolves.toBeUndefined() @@ -12495,7 +12565,7 @@ export default defineJob({ const shutdown = vi.fn(async () => {}) const clear = vi.fn(async () => 1) - await expect(cliInternals.runQueueClearCommand(io.io, projectRoot, 'redis', [], { + await expect(runQueueClearCommand(io.io, projectRoot, 'redis', [], { initialize: async () => ({ shutdown }) as never, clear: clear as never, })).resolves.toBeUndefined() @@ -12503,7 +12573,7 @@ export default defineJob({ expect(clear).toHaveBeenCalledWith('redis', {}) expect(shutdown).toHaveBeenCalledTimes(1) - await expect(cliInternals.runQueueClearCommand(io.io, projectRoot, 'redis', ['emails'], { + await expect(runQueueClearCommand(io.io, projectRoot, 'redis', ['emails'], { initialize: async () => ({ shutdown }) as never, clear: clear as never, })).resolves.toBeUndefined() @@ -12530,7 +12600,7 @@ export default defineJob({ }) try { - const { cliInternals: isolatedCliInternals } = await import('../src/cli-internals') + const isolatedCliInternals = await import('../src/queue') await expect(isolatedCliInternals.runQueueClearCommand(io.io, projectRoot, 'redis', undefined, { initialize: async () => ({ shutdown }) as never, })).resolves.toBeUndefined() @@ -12561,7 +12631,7 @@ export default defineJob({ }) try { - const { cliInternals: isolatedCliInternals } = await import('../src/cli-internals') + const isolatedCliInternals = await import('../src/queue') const clear = vi.fn(async () => 0) const list = vi.fn(async () => []) const retry = vi.fn(async () => 0) @@ -12652,7 +12722,7 @@ export default defineJob({ }) try { - const { cliInternals: isolatedCliInternals } = await import('../src/cli-internals') + const isolatedCliInternals = await import('../src/queue') await expect(isolatedCliInternals.runQueueClearCommand(io.io, projectRoot, 'redis', ['emails'])).resolves.toBeUndefined() const queueModule = await import(queueModuleSpecifier) @@ -12744,7 +12814,7 @@ export default defineJob({ })) try { - const { cliInternals: isolatedCliInternals } = await import('../src/cli-internals') + const isolatedCliInternals = await import('../src/queue') await expect(isolatedCliInternals.runQueueClearCommand(io.io, projectRoot, 'database', undefined)).resolves.toBeUndefined() const dbModule = await import('@holo-js/db') as typeof HoloDbModule @@ -12853,7 +12923,7 @@ export default defineJob({ })) try { - const { cliInternals: isolatedCliInternals } = await import('../src/cli-internals') + const isolatedCliInternals = await import('../src/queue') await expect( isolatedCliInternals.runQueueClearCommand(io.io, projectRoot, 'database', undefined), ).rejects.toThrow('database queue init failed') @@ -12926,7 +12996,7 @@ export default defineJob({ })) try { - const { cliInternals: isolatedCliInternals } = await import('../src/cli-internals') + const isolatedCliInternals = await import('../src/cache') await isolatedCliInternals.runCacheClearCommand(io.io, projectRoot) await isolatedCliInternals.runCacheForgetCommand(io.io, projectRoot, 'present') @@ -13004,7 +13074,7 @@ export default defineJob({ })) try { - const { cliInternals: isolatedCliInternals } = await import('../src/cli-internals') + const isolatedCliInternals = await import('../src/cache') await isolatedCliInternals.runCacheClearCommand(io.io, projectRoot) expect(flush).toHaveBeenCalledTimes(1) } finally { @@ -13130,10 +13200,10 @@ export default defineCacheConfig({ const defaultProjectRoot = await createTempProject() tempDirs.push(defaultProjectRoot) - await withFakeBun(async () => cliInternals.runQueueTableCommand(createIo(defaultProjectRoot).io, defaultProjectRoot)) - await expect(withFakeBun(async () => cliInternals.runQueueTableCommand(createIo(defaultProjectRoot).io, defaultProjectRoot))).rejects.toThrow('A migration for table "jobs" already exists.') - await withFakeBun(async () => cliInternals.runQueueFailedTableCommand(createIo(defaultProjectRoot).io, defaultProjectRoot)) - await expect(withFakeBun(async () => cliInternals.runQueueFailedTableCommand(createIo(defaultProjectRoot).io, defaultProjectRoot))).rejects.toThrow('A migration for table "failed_jobs" already exists.') + await withFakeBun(async () => runQueueTableCommand(createIo(defaultProjectRoot).io, defaultProjectRoot)) + await expect(withFakeBun(async () => runQueueTableCommand(createIo(defaultProjectRoot).io, defaultProjectRoot))).rejects.toThrow('A migration for table "jobs" already exists.') + await withFakeBun(async () => runQueueFailedTableCommand(createIo(defaultProjectRoot).io, defaultProjectRoot)) + await expect(withFakeBun(async () => runQueueFailedTableCommand(createIo(defaultProjectRoot).io, defaultProjectRoot))).rejects.toThrow('A migration for table "failed_jobs" already exists.') const defaultMigrationFiles = await readdir(join(defaultProjectRoot, 'server/db/migrations')) const jobsMigrationPath = join(defaultProjectRoot, 'server/db/migrations', defaultMigrationFiles.find(name => name.endsWith('create_jobs_table.ts'))!) @@ -13168,19 +13238,19 @@ export default defineQueueConfig({ }) `) - expect(cliInternals.resolveDatabaseQueueTables(await loadConfigDirectory(customProjectRoot).then(config => config.queue))).toEqual(['jobs', 'report_jobs']) - await withFakeBun(async () => cliInternals.runQueueTableCommand(createIo(customProjectRoot).io, customProjectRoot)) - await withFakeBun(async () => cliInternals.runQueueFailedTableCommand(createIo(customProjectRoot).io, customProjectRoot)) + expect(resolveDatabaseQueueTables(await loadConfigDirectory(customProjectRoot).then(config => config.queue))).toEqual(['jobs', 'report_jobs']) + await withFakeBun(async () => runQueueTableCommand(createIo(customProjectRoot).io, customProjectRoot)) + await withFakeBun(async () => runQueueFailedTableCommand(createIo(customProjectRoot).io, customProjectRoot)) const customMigrations = await readdir(join(customProjectRoot, 'server/db/migrations')) expect(customMigrations.some(name => name.endsWith('create_jobs_table.ts'))).toBe(true) expect(customMigrations.some(name => name.endsWith('create_report_jobs_table.ts'))).toBe(true) expect(customMigrations.some(name => name.endsWith('create_queue_failed_jobs_table.ts'))).toBe(true) - expect(cliInternals.renderQueueTableMigration('jobs')).toContain('table.bigInteger(\'available_at\')') - expect(cliInternals.renderFailedJobsTableMigration('failed_jobs')).toContain('table.bigInteger(\'failed_at\')') + expect(renderQueueTableMigration('jobs')).toContain('table.bigInteger(\'available_at\')') + expect(renderFailedJobsTableMigration('failed_jobs')).toContain('table.bigInteger(\'failed_at\')') const failedOnlyProjectRoot = await createTempProject() tempDirs.push(failedOnlyProjectRoot) - await withFakeBun(async () => cliInternals.runQueueFailedTableCommand(createIo(failedOnlyProjectRoot).io, failedOnlyProjectRoot)) + await withFakeBun(async () => runQueueFailedTableCommand(createIo(failedOnlyProjectRoot).io, failedOnlyProjectRoot)) expect((await readdir(join(failedOnlyProjectRoot, 'server/db/migrations'))).some(name => name.endsWith('create_failed_jobs_table.ts'))).toBe(true) const disabledFailedProjectRoot = await createTempProject() @@ -13192,7 +13262,7 @@ export default defineQueueConfig({ failed: false, }) `) - await withFakeBun(async () => cliInternals.runQueueFailedTableCommand(createIo(disabledFailedProjectRoot).io, disabledFailedProjectRoot)) + await withFakeBun(async () => runQueueFailedTableCommand(createIo(disabledFailedProjectRoot).io, disabledFailedProjectRoot)) expect((await readdir(join(disabledFailedProjectRoot, 'server/db/migrations'))).some(name => name.endsWith('create_failed_jobs_table.ts'))).toBe(true) const queueIo = createIo(customProjectRoot) @@ -13220,19 +13290,19 @@ export default defineQueueConfig({ const forget = vi.fn(async () => true) const flush = vi.fn(async () => 3) - await cliInternals.runQueueFailedCommand(queueIo.io, customProjectRoot, { + await runQueueFailedCommand(queueIo.io, customProjectRoot, { initialize, list: list as never, }) - await cliInternals.runQueueRetryCommand(queueIo.io, customProjectRoot, 'all', { + await runQueueRetryCommand(queueIo.io, customProjectRoot, 'all', { initialize, retry: retry as never, }) - await cliInternals.runQueueForgetCommand(queueIo.io, customProjectRoot, 'failed-1', { + await runQueueForgetCommand(queueIo.io, customProjectRoot, 'failed-1', { initialize, forget: forget as never, }) - await cliInternals.runQueueFlushCommand(queueIo.io, customProjectRoot, { + await runQueueFlushCommand(queueIo.io, customProjectRoot, { initialize, flush: flush as never, }) @@ -13249,7 +13319,7 @@ export default defineQueueConfig({ expect(queueIo.read().stdout).toContain('Flushed 3 failed job(s).') const emptyIo = createIo(customProjectRoot) - await cliInternals.runQueueFailedCommand(emptyIo.io, customProjectRoot, { + await runQueueFailedCommand(emptyIo.io, customProjectRoot, { initialize, list: vi.fn(async () => []), }) @@ -13327,9 +13397,9 @@ export default defineQueueConfig({ const actualFlushIo = createIo(actualFlushProjectRoot) const actualFailedIo = createIo(actualFlushProjectRoot) - await cliInternals.runQueueFailedCommand(actualFailedIo.io, actualFlushProjectRoot) + await runQueueFailedCommand(actualFailedIo.io, actualFlushProjectRoot) expect(actualFailedIo.read().stdout).toContain('failed-2 reports.send connection=database queue=default failedAt=300') - await cliInternals.runQueueFlushCommand(actualFlushIo.io, actualFlushProjectRoot) + await runQueueFlushCommand(actualFlushIo.io, actualFlushProjectRoot) expect(actualFlushIo.read().stdout).toContain('Flushed 1 failed job(s).') const actualRetryRuntime = await initializeHolo(actualFlushProjectRoot) @@ -13344,11 +13414,11 @@ export default defineQueueConfig({ } const actualRetryIo = createIo(actualFlushProjectRoot) - await cliInternals.runQueueRetryCommand(actualRetryIo.io, actualFlushProjectRoot, 'failed-3') + await runQueueRetryCommand(actualRetryIo.io, actualFlushProjectRoot, 'failed-3') expect(actualRetryIo.read().stdout).toContain('Retried 1 failed job(s).') const actualForgetIo = createIo(actualFlushProjectRoot) - await cliInternals.runQueueForgetCommand(actualForgetIo.io, actualFlushProjectRoot, 'missing') + await runQueueForgetCommand(actualForgetIo.io, actualFlushProjectRoot, 'missing') expect(actualForgetIo.read().stdout).toContain('Failed job missing was not found.') }) @@ -13371,8 +13441,8 @@ export default defineCacheConfig({ }) `) - await withFakeBun(async () => cliInternals.runCacheTableCommand(createIo(defaultProjectRoot).io, defaultProjectRoot)) - await expect(withFakeBun(async () => cliInternals.runCacheTableCommand(createIo(defaultProjectRoot).io, defaultProjectRoot))).rejects.toThrow('A migration for cache tables "cache" and "cache_locks" already exists.') + await withFakeBun(async () => runCacheTableCommand(createIo(defaultProjectRoot).io, defaultProjectRoot)) + await expect(withFakeBun(async () => runCacheTableCommand(createIo(defaultProjectRoot).io, defaultProjectRoot))).rejects.toThrow('A migration for cache tables "cache" and "cache_locks" already exists.') const defaultMigrationFiles = await readdir(join(defaultProjectRoot, 'server/db/migrations')) const defaultMigrationPath = join(defaultProjectRoot, 'server/db/migrations', defaultMigrationFiles.find(name => name.endsWith('create_cache_cache_table.ts'))!) @@ -13408,18 +13478,18 @@ export default defineCacheConfig({ }) `) - expect(cliInternals.resolveDatabaseCacheTables(await loadConfigDirectory(customProjectRoot).then(config => config.cache))).toEqual([ + expect(resolveDatabaseCacheTables(await loadConfigDirectory(customProjectRoot).then(config => config.cache))).toEqual([ { table: 'cache_entries', lockTable: 'cache_entry_locks' }, { table: 'report_cache', lockTable: 'report_cache_locks' }, ]) - await withFakeBun(async () => cliInternals.runCacheTableCommand(createIo(customProjectRoot).io, customProjectRoot)) + await withFakeBun(async () => runCacheTableCommand(createIo(customProjectRoot).io, customProjectRoot)) const customMigrations = await readdir(join(customProjectRoot, 'server/db/migrations')) expect(customMigrations.some(name => name.endsWith('create_cache_entries_cache_table.ts'))).toBe(true) expect(customMigrations.some(name => name.endsWith('create_report_cache_cache_table.ts'))).toBe(true) - expect(cliInternals.renderCacheTableMigration('cache_entries', 'cache_entry_locks')).toContain('table.bigInteger(\'expires_at\').nullable()') - expect(cliInternals.renderCacheTableMigration(`cache'entries`, `cache\\locks`)).toContain('cache\\\'entries') - expect(cliInternals.renderCacheTableMigration(`cache'entries`, `cache\\locks`)).toContain('cache\\\\locks') + expect(renderCacheTableMigration('cache_entries', 'cache_entry_locks')).toContain('table.bigInteger(\'expires_at\').nullable()') + expect(renderCacheTableMigration(`cache'entries`, `cache\\locks`)).toContain('cache\\\'entries') + expect(renderCacheTableMigration(`cache'entries`, `cache\\locks`)).toContain('cache\\\\locks') const fileOnlyProjectRoot = await createTempProject() tempDirs.push(fileOnlyProjectRoot) @@ -13438,7 +13508,7 @@ export default defineCacheConfig({ `) await expect( - withFakeBun(async () => cliInternals.runCacheTableCommand(createIo(fileOnlyProjectRoot).io, fileOnlyProjectRoot)), + withFakeBun(async () => runCacheTableCommand(createIo(fileOnlyProjectRoot).io, fileOnlyProjectRoot)), ).rejects.toThrow('The configured cache drivers do not use the database driver.') vi.resetModules() @@ -13452,7 +13522,7 @@ export default defineCacheConfig({ } }) try { - const { cliInternals: isolatedCliInternals } = await import('../src/cli-internals') + const isolatedCliInternals = await import('../src/cache-migrations') await expect(isolatedCliInternals.loadCacheConfig(fileOnlyProjectRoot)).rejects.toThrow('Cache config is missing or malformed') } finally { vi.doUnmock('@holo-js/config') @@ -13478,7 +13548,7 @@ export default defineCacheConfig({ } }) try { - const { cliInternals: isolatedCliInternals } = await import('../src/cli-internals') + const isolatedCliInternals = await import('../src/cache-migrations') await expect(isolatedCliInternals.loadCacheConfig(fileOnlyProjectRoot)).rejects.toThrow('must define non-empty "table" and "lockTable" strings') } finally { vi.doUnmock('@holo-js/config') @@ -13510,7 +13580,7 @@ export default defineCacheConfig({ `) await expect( - withFakeBun(async () => cliInternals.runCacheTableCommand(createIo(duplicateProjectRoot).io, duplicateProjectRoot)), + withFakeBun(async () => runCacheTableCommand(createIo(duplicateProjectRoot).io, duplicateProjectRoot)), ).rejects.toThrow('A migration for cache tables "report_cache" and "other_cache_locks" already exists.') }) @@ -13542,7 +13612,7 @@ export default defineDatabaseConfig({ }) try { - const { cliInternals: isolatedCliInternals } = await import('../src/cli-internals') + const isolatedCliInternals = await import('../src/queue') const environment = await withFakeBun(async () => isolatedCliInternals.getQueueRuntimeEnvironment(projectRoot)) expect(environment.bundledJobs).toEqual([]) await environment.cleanup() @@ -13566,7 +13636,7 @@ export default defineDatabaseConfig({ clearAll: vi.fn(), })) - await expect(cliInternals.runRateLimitClearCommand(io.io, projectRoot, { + await expect(runRateLimitClearCommand(io.io, projectRoot, { limiter: 'login', }, { loadConfig: async () => ({ @@ -13640,13 +13710,13 @@ export default defineDatabaseConfig({ } as never), }) - await expect(cliInternals.runRateLimitClearCommand(io.io, projectRoot, { + await expect(runRateLimitClearCommand(io.io, projectRoot, { limiter: 'login', }, createDependencies(true))).resolves.toBeUndefined() expect(io.read().stdout).toContain('Cleared 1 rate-limit bucket(s).') const emptyIo = createIo(projectRoot) - await expect(cliInternals.runRateLimitClearCommand(emptyIo.io, projectRoot, { + await expect(runRateLimitClearCommand(emptyIo.io, projectRoot, { limiter: 'login', }, createDependencies(false))).resolves.toBeUndefined() expect(emptyIo.read().stdout).toContain('Cleared 0 rate-limit bucket(s).') @@ -13657,7 +13727,7 @@ export default defineDatabaseConfig({ tempDirs.push(projectRoot) const io = createIo(projectRoot) - await expect(cliInternals.runRateLimitClearCommand(io.io, projectRoot, { + await expect(runRateLimitClearCommand(io.io, projectRoot, { limiter: 'login', }, { loadConfig: async () => ({ @@ -13693,7 +13763,7 @@ export default defineDatabaseConfig({ loadProject: vi.fn(async () => await loadProjectConfig(projectRoot, { required: true })), } const rateLimitExecutor = vi.fn(async () => {}) - const commands = cliInternals.createInternalCommands( + const commands = createInternalCommands( internalContext as never, undefined, {}, @@ -13834,7 +13904,7 @@ export default defineDatabaseConfig({ const close = vi.fn(async () => {}) const resetSecurityRuntime = vi.fn() - await expect(cliInternals.runRateLimitClearCommand(io.io, projectRoot, { + await expect(runRateLimitClearCommand(io.io, projectRoot, { limiter: 'login', }, { loadConfig: async () => ({ @@ -13886,7 +13956,7 @@ export default defineDatabaseConfig({ const close = vi.fn(async () => {}) const resetSecurityRuntime = vi.fn() - await expect(cliInternals.runRateLimitClearCommand(io.io, projectRoot, { + await expect(runRateLimitClearCommand(io.io, projectRoot, { limiter: 'login', }, { loadConfig: async () => ({ @@ -13936,7 +14006,7 @@ export default defineDatabaseConfig({ }) const resetSecurityRuntime = vi.fn() - await expect(cliInternals.runRateLimitClearCommand(io.io, projectRoot, { + await expect(runRateLimitClearCommand(io.io, projectRoot, { limiter: 'login', }, { loadConfig: async () => ({ @@ -14067,7 +14137,7 @@ export function createSecurityRedisAdapter(config) { vi.stubGlobal('__holoCliSecurityCalls', []) try { - await expect(cliInternals.runRateLimitClearCommand(io.io, projectRoot, { + await expect(runRateLimitClearCommand(io.io, projectRoot, { limiter: 'login', key: 'user-1', })).resolves.toBeUndefined() @@ -14165,7 +14235,7 @@ export default defineJob({ `) await withFakeBun(async () => { - const environment = await cliInternals.getQueueRuntimeEnvironment(projectRoot) + const environment = await getQueueRuntimeEnvironment(projectRoot) try { const registry = await loadGeneratedProjectRegistry(projectRoot) expect(registry?.jobs).toEqual([ @@ -14250,7 +14320,7 @@ export default defineJob({ }) try { - const { cliInternals: isolatedCliInternals } = await import('../src/cli-internals') + const isolatedCliInternals = await import('../src/broadcast') const io = createIo(projectRoot) let sigintHandler: (() => void) | undefined const processOnSpy = vi.spyOn(process, 'on').mockImplementation(((event: string, listener: (...args: unknown[]) => void) => { @@ -14352,7 +14422,7 @@ export default defineJob({ }) try { - const { cliInternals: isolatedCliInternals } = await import('../src/cli-internals') + const isolatedCliInternals = await import('../src/queue') await expect(isolatedCliInternals.getQueueRuntimeEnvironment(projectRoot)).rejects.toThrow( 'Discovered job "server/jobs/broken.mjs" does not export a Holo job.', ) @@ -14417,7 +14487,7 @@ export default defineJob({ }) try { - const { cliInternals: isolatedCliInternals } = await import('../src/cli-internals') + const isolatedCliInternals = await import('../src/queue') await withFakeBun(async () => { const environment = await isolatedCliInternals.getQueueRuntimeEnvironment(projectRoot) try { @@ -14454,7 +14524,7 @@ export default defineJob({ let watchCallback: ((eventType: string, fileName: string | Buffer | null) => void) | undefined const prepare = vi.fn(async () => {}) - const listenPromise = withFakeBun(async () => cliInternals.runQueueListen( + const listenPromise = withFakeBun(async () => runQueueListen( io.io, projectRoot, { @@ -14542,7 +14612,7 @@ export default defineJob({ }) }) - const listenPromise = withFakeBun(async () => cliInternals.runQueueListen( + const listenPromise = withFakeBun(async () => runQueueListen( io.io, projectRoot, {}, @@ -14606,7 +14676,7 @@ export default defineJob({ }) }) - const listenPromise = withFakeBun(async () => cliInternals.runQueueListen( + const listenPromise = withFakeBun(async () => runQueueListen( io.io, projectRoot, {}, @@ -14656,7 +14726,7 @@ export default defineJob({ } }) - const listenPromise = withFakeBun(async () => cliInternals.runQueueListen( + const listenPromise = withFakeBun(async () => runQueueListen( io.io, projectRoot, {}, @@ -14707,7 +14777,7 @@ export default defineJob({ } }) - const listenPromise = withFakeBun(async () => cliInternals.runQueueListen( + const listenPromise = withFakeBun(async () => runQueueListen( io.io, projectRoot, {}, @@ -14737,7 +14807,7 @@ export default defineJob({ await mkdir(join(projectRoot, 'server/jobs'), { recursive: true }) await mkdir(join(projectRoot, 'server/models'), { recursive: true }) - const roots = await cliInternals.collectQueueWatchRoots( + const roots = await collectQueueWatchRoots( projectRoot, await loadProjectConfig(projectRoot, { required: true }), ) @@ -14762,7 +14832,7 @@ export default defineJob({ await writeProjectFile(projectRoot, '.holo-js/runtime/cli/bundle/generated.mjs', 'export default true\n') await writeProjectFile(projectRoot, 'storage/app/media/originals/image.txt', 'binary\n') - const roots = await cliInternals.collectQueueWatchRoots(projectRoot, await loadProjectConfig(projectRoot, { required: true })) + const roots = await collectQueueWatchRoots(projectRoot, await loadProjectConfig(projectRoot, { required: true })) expect(roots).toContain(projectRoot) expect(roots).toContain(join(projectRoot, 'server/jobs')) expect(roots).toContain(join(projectRoot, 'server/services/mail')) @@ -14779,7 +14849,7 @@ export default defineJob({ const filePath = join(tempDir, 'project.txt') await writeFile(filePath, 'not a directory\n', 'utf8') - await expect(cliInternals.collectQueueWatchRoots(filePath, {} as never)).resolves.toEqual([]) + await expect(collectQueueWatchRoots(filePath, {} as never)).resolves.toEqual([]) }) it('ignores invalid non-recursive queue:listen events and watcher registration errors', async () => { @@ -14806,7 +14876,7 @@ export default defineJob({ const watchers = new Map void>() const prepare = vi.fn(async () => {}) - const listenPromise = withFakeBun(async () => cliInternals.runQueueListen( + const listenPromise = withFakeBun(async () => runQueueListen( io.io, projectRoot, {}, @@ -14861,7 +14931,7 @@ export default defineJob({ failingWatcherChild.stderr = new PassThrough() failingWatcherChild.stdin = new PassThrough() - await expect(withFakeBun(async () => cliInternals.runQueueListen( + await expect(withFakeBun(async () => runQueueListen( io.io, projectRoot, {}, @@ -14880,7 +14950,7 @@ export default defineJob({ errorChild.stderr = new PassThrough() errorChild.stdin = new PassThrough() const errorWatcherClose = vi.fn() - const errorPromise = withFakeBun(async () => cliInternals.runQueueListen( + const errorPromise = withFakeBun(async () => runQueueListen( io.io, projectRoot, {}, @@ -14905,7 +14975,7 @@ export default defineJob({ closeChild.stderr = new PassThrough() closeChild.stdin = new PassThrough() const closeWatcher = vi.fn() - const closePromise = withFakeBun(async () => cliInternals.runQueueListen( + const closePromise = withFakeBun(async () => runQueueListen( io.io, projectRoot, {}, @@ -14943,7 +15013,7 @@ export default defineJob({ let watchCallback: ((eventType: string, fileName: string | Buffer | null) => void) | undefined const prepare = vi.fn(async () => {}) - const listenPromise = withFakeBun(async () => cliInternals.runQueueListen( + const listenPromise = withFakeBun(async () => runQueueListen( io.io, projectRoot, {}, @@ -15007,7 +15077,7 @@ export default defineJob({ }> = [] let watchCallback: ((eventType: string, fileName: string | Buffer | null) => void) | undefined - const listenPromise = withFakeBun(async () => cliInternals.runQueueListen( + const listenPromise = withFakeBun(async () => runQueueListen( io.io, projectRoot, {}, @@ -15035,7 +15105,7 @@ export default defineJob({ } await new Promise(resolve => setTimeout(resolve, 10)) - await cliInternals.writeQueueRestartSignal(projectRoot) + await writeQueueRestartSignal(projectRoot) spawnedChildren[0]?.emit('close', 0) for (let attempts = 0; attempts < 300 && spawnedChildren.length < 2; attempts += 1) { @@ -15077,7 +15147,7 @@ export default defineJob({ const watchers = new Map void>() const prepare = vi.fn(async () => {}) - const listenPromise = withFakeBun(async () => cliInternals.runQueueListen( + const listenPromise = withFakeBun(async () => runQueueListen( io.io, projectRoot, {}, @@ -15154,7 +15224,7 @@ export default defineJob({ child.stderr = new PassThrough() child.stdin = new PassThrough() - await expect(withFakeBun(async () => cliInternals.runQueueListen( + await expect(withFakeBun(async () => runQueueListen( io.io, projectRoot, {}, @@ -15174,9 +15244,9 @@ export default defineJob({ }) it('ignores ENOENT and EPERM errors while registering non-recursive fallback watchers', async () => { - expect(cliInternals.isIgnorableWatchError(Object.assign(new Error('missing'), { code: 'ENOENT' }))).toBe(true) - expect(cliInternals.isIgnorableWatchError(Object.assign(new Error('denied'), { code: 'EPERM' }))).toBe(true) - expect(cliInternals.isIgnorableWatchError(Object.assign(new Error('blocked'), { code: 'EACCES' }))).toBe(false) + expect(isIgnorableWatchError(Object.assign(new Error('missing'), { code: 'ENOENT' }))).toBe(true) + expect(isIgnorableWatchError(Object.assign(new Error('denied'), { code: 'EPERM' }))).toBe(true) + expect(isIgnorableWatchError(Object.assign(new Error('blocked'), { code: 'EACCES' }))).toBe(false) const projectRoot = await createTempProject() tempDirs.push(projectRoot) @@ -15196,7 +15266,7 @@ export default { child.stderr = new PassThrough() child.stdin = new PassThrough() - const devPromise = withFakeBun(async () => cliInternals.runProjectDevServer( + const devPromise = withFakeBun(async () => runProjectDevServer( io.io, projectRoot, (() => child as never) as never, @@ -15240,7 +15310,7 @@ export default { child.stderr = new PassThrough() child.stdin = new PassThrough() - await expect(withFakeBun(async () => cliInternals.runProjectDevServer( + await expect(withFakeBun(async () => runProjectDevServer( io.io, projectRoot, (() => child as never) as never, @@ -15275,7 +15345,7 @@ export default { const closeWatcher = vi.fn() const watchers = new Map void>() const prepare = vi.fn(async () => {}) - const devPromise = withFakeBun(async () => cliInternals.runProjectDevServer( + const devPromise = withFakeBun(async () => runProjectDevServer( io.io, projectRoot, (() => child as never) as never, @@ -15323,7 +15393,7 @@ export default { child.stderr = new PassThrough() child.stdin = new PassThrough() - await expect(withFakeBun(async () => cliInternals.runProjectDevServer( + await expect(withFakeBun(async () => runProjectDevServer( io.io, projectRoot, (() => child as never) as never, @@ -15953,7 +16023,7 @@ export default { } as never }) - await expect(cliInternals.getRuntimeEnvironment(projectRoot)).rejects.toThrow('boom') + await expect(getRuntimeEnvironment(projectRoot)).rejects.toThrow('boom') await expect(readdir(join(projectRoot, '.holo-js/runtime/cli'))).resolves.toEqual([]) }) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index c03f02e9..67b69a39 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -4,6 +4,7 @@ "outDir": "./dist", "baseUrl": ".", "paths": { + "@holo-js/cache-db": ["../cache-db/src/index.ts"], "@holo-js/config": ["../config/src/index.ts"], "@holo-js/db": ["../db/src/index.ts"], "@holo-js/events": ["../events/src/index.ts"], diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 09d220d0..07fde65b 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -16,6 +16,7 @@ export default defineConfig([ ...sharedOptions, entry: { 'index': 'src/index.ts', + 'runtime-worker': 'src/runtime-worker.ts', }, dts: true, clean: true, diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 8383d481..ee9c0dcd 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ { find: '@holo-js/auth-social', replacement: resolve(__dirname, '../auth-social/src/index.ts') }, { find: '@holo-js/auth-workos', replacement: resolve(__dirname, '../auth-workos/src/index.ts') }, { find: '@holo-js/auth-clerk', replacement: resolve(__dirname, '../auth-clerk/src/index.ts') }, + { find: '@holo-js/cache-db', replacement: resolve(__dirname, '../cache-db/src/index.ts') }, { find: '@holo-js/config', replacement: resolve(__dirname, '../config/src/index.ts') }, { find: '@holo-js/core', replacement: resolve(__dirname, '../core/src/index.ts') }, { find: '@holo-js/db', replacement: resolve(__dirname, '../db/src/index.ts') }, diff --git a/packages/config/src/access.ts b/packages/config/src/access.ts index 1d4a36f5..0cce09b3 100644 --- a/packages/config/src/access.ts +++ b/packages/config/src/access.ts @@ -38,17 +38,13 @@ export function createConfigAccessors(configMa useConfig: UseConfigAccessor config: ConfigAccessor } { - const useConfig = ((path: string) => { + const accessor = ((path: string) => { return getValueAtPath(configMap as Record, path) - }) as UseConfigAccessor - - const config = ((path: string) => { - return getValueAtPath(configMap as Record, path) - }) as ConfigAccessor + }) as UseConfigAccessor & ConfigAccessor return { - useConfig, - config, + useConfig: accessor, + config: accessor, } } @@ -78,15 +74,19 @@ function requireConfigRuntime(): RuntimeConfigMap { return runtimeConfigMap } +function getRuntimeConfigValue(path: string): unknown { + return getValueAtPath(requireConfigRuntime() as Record, path) +} + export function useConfig>(key: TKey): RuntimeConfigMap[TKey] export function useConfig>(path: TPath): ValueAtPath export function useConfig(path: string): unknown export function useConfig(path: string): unknown { - return getValueAtPath(requireConfigRuntime() as Record, path) + return getRuntimeConfigValue(path) } export function config>(path: TPath): ValueAtPath export function config(path: string): unknown export function config(path: string): unknown { - return getValueAtPath(requireConfigRuntime() as Record, path) + return getRuntimeConfigValue(path) } diff --git a/packages/config/src/defaults.ts b/packages/config/src/defaults.ts index d39b88ec..cfed2092 100644 --- a/packages/config/src/defaults.ts +++ b/packages/config/src/defaults.ts @@ -472,31 +472,76 @@ const DEFAULT_QUEUE_CONFIG: Readonly = Object.freeze( }), }) -function parseInteger( +type IntegerParser = 'number' | 'parse-int' | 'digits' + +function parseIntegerCandidate(value: number | string, parser: IntegerParser): number { + if (typeof value === 'number') { + return value + } + + const trimmed = value.trim() + if (!trimmed) { + return Number.NaN + } + + if (parser === 'parse-int') { + return Number.parseInt(value, 10) + } + + if (parser === 'digits') { + return /^\d+$/.test(trimmed) ? Number.parseInt(trimmed, 10) : Number.NaN + } + + return Number(trimmed) +} + +function parseScopedInteger( value: number | string | undefined, fallback: number, + namespace: string, label: string, - options: { minimum?: number } = {}, + options: { minimum?: number, parser?: IntegerParser } = {}, ): number { - if (typeof value === 'undefined') { - return fallback - } - - const normalized = typeof value === 'number' - ? value - : Number.parseInt(value, 10) + const normalized = typeof value === 'undefined' + ? fallback + : parseIntegerCandidate(value, options.parser ?? 'number') - if (!Number.isInteger(normalized)) { - throw new Error(`[Holo Queue] ${label} must be an integer.`) + if (!Number.isFinite(normalized) || !Number.isInteger(normalized)) { + throw new Error(`[${namespace}] ${label} must be an integer.`) } if (typeof options.minimum === 'number' && normalized < options.minimum) { - throw new Error(`[Holo Queue] ${label} must be greater than or equal to ${options.minimum}.`) + throw new Error(`[${namespace}] ${label} must be greater than or equal to ${options.minimum}.`) } return normalized } +function parseScopedOptionalInteger( + value: number | string | undefined, + namespace: string, + label: string, + options: { minimum?: number, parser?: IntegerParser } = {}, +): number | undefined { + if (typeof value === 'undefined') { + return undefined + } + + return parseScopedInteger(value, Number.NaN, namespace, label, options) +} + +function parseInteger( + value: number | string | undefined, + fallback: number, + label: string, + options: { minimum?: number } = {}, +): number { + return parseScopedInteger(value, fallback, 'Holo Queue', label, { + ...options, + parser: 'parse-int', + }) +} + function normalizeNonEmptyString(value: string | undefined, label: string): string { const normalized = value?.trim() if (!normalized) { @@ -506,22 +551,21 @@ function normalizeNonEmptyString(value: string | undefined, label: string): stri return normalized } -function normalizeConnectionName(value: string | undefined, label: string): string { +function normalizeScopedName(value: string | undefined, namespace: string, label: string): string { const normalized = value?.trim() if (!normalized) { - throw new Error(`[Holo Queue] ${label} must be a non-empty string.`) + throw new Error(`[${namespace}] ${label} must be a non-empty string.`) } return normalized } -function normalizeCacheName(value: string | undefined, label: string): string { - const normalized = value?.trim() - if (!normalized) { - throw new Error(`[Holo Cache] ${label} must be a non-empty string.`) - } +function normalizeConnectionName(value: string | undefined, label: string): string { + return normalizeScopedName(value, 'Holo Queue', label) +} - return normalized +function normalizeCacheName(value: string | undefined, label: string): string { + return normalizeScopedName(value, 'Holo Cache', label) } function normalizeCacheOptionalString(value: string | undefined): string | undefined { @@ -534,30 +578,7 @@ function parseCacheInteger( label: string, options: { minimum?: number } = {}, ): number | undefined { - if (typeof value === 'undefined') { - return undefined - } - - const normalized = typeof value === 'number' - ? value - : (() => { - const trimmed = value.trim() - if (!trimmed) { - return Number.NaN - } - - return Number(trimmed) - })() - - if (!Number.isFinite(normalized) || !Number.isInteger(normalized)) { - throw new Error(`[Holo Cache] ${label} must be an integer.`) - } - - if (typeof options.minimum === 'number' && normalized < options.minimum) { - throw new Error(`[Holo Cache] ${label} must be greater than or equal to ${options.minimum}.`) - } - - return normalized + return parseScopedOptionalInteger(value, 'Holo Cache', label, options) } function resolveCachePrefix(globalPrefix: string, localPrefix: string | undefined): string { @@ -672,39 +693,14 @@ function parseRedisInteger( label: string, options: { minimum?: number } = {}, ): number { - if (typeof value === 'undefined') { - return fallback - } - - const normalized = typeof value === 'number' - ? value - : (() => { - const trimmed = value.trim() - if (!trimmed || !/^\d+$/.test(trimmed)) { - return Number.NaN - } - - return Number.parseInt(trimmed, 10) - })() - - if (!Number.isInteger(normalized)) { - throw new Error(`[Holo Redis] ${label} must be an integer.`) - } - - if (typeof options.minimum === 'number' && normalized < options.minimum) { - throw new Error(`[Holo Redis] ${label} must be greater than or equal to ${options.minimum}.`) - } - - return normalized + return parseScopedInteger(value, fallback, 'Holo Redis', label, { + ...options, + parser: 'digits', + }) } function normalizeRedisConnectionName(value: string | undefined, label: string): string { - const normalized = value?.trim() - if (!normalized) { - throw new Error(`[Holo Redis] ${label} must be a non-empty string.`) - } - - return normalized + return normalizeScopedName(value, 'Holo Redis', label) } function normalizeOptionalRedisString(value: string | undefined, label: string): string | undefined { @@ -908,37 +904,11 @@ function parseSecurityInteger( label: string, options: { minimum?: number } = {}, ): number { - const normalized = typeof value === 'undefined' - ? fallback - : typeof value === 'number' - ? value - : (() => { - const trimmed = value.trim() - if (!trimmed) { - return Number.NaN - } - - return Number(trimmed) - })() - - if (!Number.isFinite(normalized) || !Number.isInteger(normalized)) { - throw new Error(`[Holo Security] ${label} must be an integer.`) - } - - if (typeof options.minimum === 'number' && normalized < options.minimum) { - throw new Error(`[Holo Security] ${label} must be greater than or equal to ${options.minimum}.`) - } - - return normalized + return parseScopedInteger(value, fallback, 'Holo Security', label, options) } function normalizeSecurityName(value: string | undefined, label: string): string { - const normalized = value?.trim() - if (!normalized) { - throw new Error(`[Holo Security] ${label} must be a non-empty string.`) - } - - return normalized + return normalizeScopedName(value, 'Holo Security', label) } function normalizeSecurityOptionalString(value: string | undefined): string | undefined { diff --git a/packages/core/tests/package.test.ts b/packages/core/tests/package.test.ts index f1fc5e2b..f3d9093b 100644 --- a/packages/core/tests/package.test.ts +++ b/packages/core/tests/package.test.ts @@ -5,18 +5,18 @@ import { describe, expect, it } from 'vitest' describe('@holo-js/core package boundaries', () => { it('keeps queue and storage support optional in the published surface', async () => { const packageJsonPath = resolve(import.meta.dirname, '../package.json') - const runtimeTypesPath = resolve(import.meta.dirname, '../src/runtime/index.d.ts') + const runtimeSourcePath = resolve(import.meta.dirname, '../src/portable/holo.ts') const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as { dependencies?: Record peerDependencies?: Record peerDependenciesMeta?: Record } - const runtimeTypes = await readFile(runtimeTypesPath, 'utf8') + const runtimeSource = await readFile(runtimeSourcePath, 'utf8') - expect(runtimeTypes).not.toContain("from '@holo-js/queue'") - expect(runtimeTypes).not.toContain('ReadonlyMap') - expect(runtimeTypes).toContain("readonly mode: 'async' | 'sync';") - expect(runtimeTypes).toContain('readonly driver: string;') + expect(runtimeSource).not.toContain("from '@holo-js/queue'") + expect(runtimeSource).not.toContain('ReadonlyMap') + expect(runtimeSource).toContain("readonly mode: 'async' | 'sync'") + expect(runtimeSource).toContain('readonly driver: string') const optionalRuntimePackages = [ '@holo-js/auth', '@holo-js/auth-clerk', diff --git a/packages/create-holo-js/tsconfig.json b/packages/create-holo-js/tsconfig.json index a31b431a..341323c1 100644 --- a/packages/create-holo-js/tsconfig.json +++ b/packages/create-holo-js/tsconfig.json @@ -4,6 +4,9 @@ "outDir": "./dist", "baseUrl": ".", "paths": { + "@holo-js/cache-db": [ + "../cache-db/src/index.ts" + ], "@holo-js/cli": [ "../cli/src/index.ts" ] diff --git a/packages/db-postgres/src/index.ts b/packages/db-postgres/src/index.ts index efac02ab..dc952cb5 100644 --- a/packages/db-postgres/src/index.ts +++ b/packages/db-postgres/src/index.ts @@ -38,7 +38,6 @@ export interface PostgresAdapterOptions { type ScopedPostgresTransaction = { client: PostgresClientLike leased: boolean - released: boolean } type BootstrapTarget = { @@ -100,13 +99,6 @@ function resolveBootstrapTarget(config?: PoolConfig): BootstrapTarget | undefine return undefined } -function isPostgresDatabaseMissing(error: unknown): boolean { - return typeof error === 'object' - && error !== null - && 'code' in error - && error.code === '3D000' -} - export class PostgresAdapter implements DriverAdapter { private pool?: PostgresPoolLike private readonly directClient?: PostgresClientLike @@ -140,7 +132,10 @@ export class PostgresAdapter implements DriverAdapter { } isDatabaseMissingError(error: unknown): boolean { - return isPostgresDatabaseMissing(error) + return typeof error === 'object' + && error !== null + && 'code' in error + && error.code === '3D000' } async disconnect(): Promise { @@ -180,7 +175,6 @@ export class PostgresAdapter implements DriverAdapter { return this.transactionScope.run({ client: this.directClient, leased: false, - released: false, }, callback) } @@ -191,7 +185,6 @@ export class PostgresAdapter implements DriverAdapter { const state: ScopedPostgresTransaction = { client: await this.pool.connect(), leased: true, - released: false, } return this.transactionScope.run(state, async () => { @@ -366,12 +359,11 @@ export class PostgresAdapter implements DriverAdapter { } private releaseScopedTransaction(state: ScopedPostgresTransaction): void { - if (!state.leased || state.released) { + if (!state.leased) { return } state.client.release?.() - state.released = true } private normalizeSavepointName(name: string): string { From e3ebedfadf6246b5882fb20451ce41dbbf901bc2 Mon Sep 17 00:00:00 2001 From: Mohamed Melouk <42706279+cobraprojects@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:37:51 +0300 Subject: [PATCH 2/2] Harden runtime validation and config normalization --- packages/adapter-sveltekit/src/realtime.ts | 16 ++++---- packages/auth-social-apple/src/index.ts | 9 +++- .../tests/package.test.ts | 2 +- packages/broadcast/src/worker.ts | 20 +++++---- packages/cache-db/src/index.ts | 15 ++++++- packages/cache/src/flexible.ts | 2 +- packages/cli/src/runtime-worker.ts | 1 + packages/cli/src/runtime.ts | 25 +++++++++-- packages/config/src/defaults.ts | 41 ++++++++++--------- 9 files changed, 86 insertions(+), 45 deletions(-) diff --git a/packages/adapter-sveltekit/src/realtime.ts b/packages/adapter-sveltekit/src/realtime.ts index ca729f80..50f94fa1 100644 --- a/packages/adapter-sveltekit/src/realtime.ts +++ b/packages/adapter-sveltekit/src/realtime.ts @@ -30,18 +30,16 @@ export const query = createRealtimeQuery export const mutation = createRealtimeMutation function isPlainObject(value: unknown): value is Record { - return Boolean(value) - && typeof value === 'object' - && !Array.isArray(value) - && !(value instanceof Date) - && !(value instanceof Blob) + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null } function isReactiveObject(value: unknown): value is object { - return Boolean(value) - && typeof value === 'object' - && !(value instanceof Date) - && !(value instanceof Blob) + return Array.isArray(value) || isPlainObject(value) } function createRealtimeReactiveValue(value: TValue, subscribe: () => void): TValue { diff --git a/packages/auth-social-apple/src/index.ts b/packages/auth-social-apple/src/index.ts index 4255a3c5..665667d7 100644 --- a/packages/auth-social-apple/src/index.ts +++ b/packages/auth-social-apple/src/index.ts @@ -12,6 +12,8 @@ const APPLE_JWKS_URL = 'https://appleid.apple.com/auth/keys' const APPLE_TOKEN_CLOCK_SKEW_MS = 60_000 type JwkKey = Parameters[1] +const APPLE_JWKS_CACHE = new Map>() +let appleJwksCacheFetch: typeof fetch | undefined async function readAppleUserPayload(request: Request): Promise<{ readonly email?: string @@ -74,8 +76,13 @@ function verifyJwtSignatureWithJwk( } async function fetchAppleJwks(): Promise { + if (appleJwksCacheFetch !== globalThis.fetch) { + APPLE_JWKS_CACHE.clear() + appleJwksCacheFetch = globalThis.fetch + } + return authRuntimeInternals.jwt.fetchCachedJwks(APPLE_JWKS_URL, { - cache: new Map(), + cache: APPLE_JWKS_CACHE, requestUrl: APPLE_JWKS_URL, errorMessage: '[@holo-js/auth-social-apple] Failed to load Apple JWKS.', }) diff --git a/packages/auth-social-linkedin/tests/package.test.ts b/packages/auth-social-linkedin/tests/package.test.ts index 18aa2532..15afdecf 100644 --- a/packages/auth-social-linkedin/tests/package.test.ts +++ b/packages/auth-social-linkedin/tests/package.test.ts @@ -25,7 +25,7 @@ function createCallbackContext(overrides: { codeVerifier: 'verifier', config: { ...callbackConfig, - scopes: overrides.scopes ?? callbackConfig.scopes, + scopes: overrides.scopes ?? [...callbackConfig.scopes], }, } as const } diff --git a/packages/broadcast/src/worker.ts b/packages/broadcast/src/worker.ts index 5fcee4fc..71b0b586 100644 --- a/packages/broadcast/src/worker.ts +++ b/packages/broadcast/src/worker.ts @@ -415,12 +415,14 @@ function verifyPusherSignature(providedSignature: string, expectedSignature: str return timingSafeEqual(Buffer.from(providedSignature, 'hex'), Buffer.from(expectedSignature, 'hex')) } -function logSocketMessageError(socketId: string, error: Error): void { - console.error(`[@holo-js/broadcast] WebSocket message handling failed for socket "${socketId}": ${error.message}`) +function logSocketMessageError(socketId: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error) + console.error(`[@holo-js/broadcast] WebSocket message handling failed for socket "${socketId}": ${message}`) } -function logScalingMessageError(error: Error): void { - console.error(`[@holo-js/broadcast] Scaling message handling failed: ${error.message}`) +function logScalingMessageError(error: unknown): void { + const message = error instanceof Error ? error.message : String(error) + console.error(`[@holo-js/broadcast] Scaling message handling failed: ${message}`) } function logSocketCleanupError(socketId: string, channel: string, error: unknown): void { @@ -981,7 +983,7 @@ export function createBroadcastWorkerRuntime(options: WorkerRuntimeOptions): Bro : scaling && options.scalingAutoSubscribe !== false ? scaling.adapter.subscribe(scaling.eventChannel, (payload) => { void handleScalingMessage(payload).catch((error) => { - logScalingMessageError(error as Error) + logScalingMessageError(error) }) }) : Promise.resolve(async () => {}) @@ -1858,7 +1860,7 @@ export function createBroadcastWorkerRuntime(options: WorkerRuntimeOptions): Bro })) /* v8 ignore next 2 -- defensive guard; pendingMessage is always swallowed by .catch(() => {}) and inner tasks have their own catch handlers */ }).catch((error) => { - logSocketMessageError(socket.socketId, error as Error) + logSocketMessageError(socket.socketId, error) }) socket.pendingMessage = cleanupTask.catch(() => {}) }, @@ -2033,7 +2035,7 @@ export async function startBroadcastWorker( scalingUnsubscribe = await scalingConfig.adapter.subscribe(scalingConfig.eventChannel, (payload) => { /* v8 ignore next 3 -- equivalent path is covered via createBroadcastWorkerRuntime auto-subscribe; V8 coverage does not instrument this callback instance */ void runtime.receiveScalingMessage(payload).catch((error) => { - logScalingMessageError(error as Error) + logScalingMessageError(error) }) }).catch((subscribeError: unknown) => handleSubscribeFailure(runtime, subscribeError)) } @@ -2087,7 +2089,7 @@ export async function startBroadcastWorker( ? message : new TextDecoder().decode(message) void runtime.receiveWebSocketMessage(socket.data.socketId, value).catch((error) => { - logSocketMessageError(socket.data.socketId, error as Error) + logSocketMessageError(socket.data.socketId, error) runtime.disconnectWebSocket(socket.data.socketId) socket.close(4001, 'Protocol error') }) @@ -2144,7 +2146,7 @@ export async function startBroadcastWorker( socket.on('message', (message) => { const value = decodeNodeWebSocketMessage(message) void runtime.receiveWebSocketMessage(socketId, value).catch((error) => { - logSocketMessageError(socketId, error as Error) + logSocketMessageError(socketId, error) runtime.disconnectWebSocket(socketId) socket.close(4001, 'Protocol error') }) diff --git a/packages/cache-db/src/index.ts b/packages/cache-db/src/index.ts index 92c69b73..a0c28967 100644 --- a/packages/cache-db/src/index.ts +++ b/packages/cache-db/src/index.ts @@ -224,6 +224,15 @@ function applyCacheDatabaseTableDefinition( table.index([tableDefinition.indexColumn], tableDefinition.indexName(tableName)) } +function resolveCacheDatabaseTableDefinition(role: CacheDatabaseTableDefinition['role']): CacheDatabaseTableDefinition { + const tableDefinition = CACHE_DATABASE_TABLE_DEFINITIONS.find(definition => definition.role === role) + if (!tableDefinition) { + throw new Error(`Missing cache database table definition for "${role}".`) + } + + return tableDefinition +} + async function prepareCacheDatabaseTables( connection: DatabaseContext, tableName = DEFAULT_CACHE_DATABASE_TABLE, @@ -231,16 +240,18 @@ async function prepareCacheDatabaseTables( ): Promise { const schema = createSchemaService(connection) await connection.initialize() + const entryTableDefinition = resolveCacheDatabaseTableDefinition('entries') + const lockTableDefinition = resolveCacheDatabaseTableDefinition('locks') if (!(await schema.hasTable(tableName))) { await schema.createTable(tableName, (table) => { - applyCacheDatabaseTableDefinition(table, tableName, CACHE_DATABASE_TABLE_DEFINITIONS[0]) + applyCacheDatabaseTableDefinition(table, tableName, entryTableDefinition) }) } if (!(await schema.hasTable(lockTableName))) { await schema.createTable(lockTableName, (table) => { - applyCacheDatabaseTableDefinition(table, lockTableName, CACHE_DATABASE_TABLE_DEFINITIONS[1]) + applyCacheDatabaseTableDefinition(table, lockTableName, lockTableDefinition) }) } } diff --git a/packages/cache/src/flexible.ts b/packages/cache/src/flexible.ts index 24a16657..711aaea9 100644 --- a/packages/cache/src/flexible.ts +++ b/packages/cache/src/flexible.ts @@ -124,7 +124,7 @@ export async function resolveFlexibleCachedValue( const retried = await options.read() if ( isFlexibleEnvelope(retried) - && resolveFlexibleEnvelopeState(retried) !== 'expired' + && resolveFlexibleEnvelopeState(retried, now) !== 'expired' ) { return retried.value } diff --git a/packages/cli/src/runtime-worker.ts b/packages/cli/src/runtime-worker.ts index d8aeb3f9..04f5bbbf 100644 --- a/packages/cli/src/runtime-worker.ts +++ b/packages/cli/src/runtime-worker.ts @@ -341,6 +341,7 @@ try { } else { for (const name of requested) { if (typeof name !== 'string') { + console.warn(`Ignoring non-string prunable model name: ${JSON.stringify(name)}`) continue } diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index 332d5b27..1fbc43bb 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -369,12 +369,31 @@ export function resolveRuntimeWorkerPath(): string { : resolve(dirname(runtimePath), 'runtime-worker.mjs') } +function supportsNodeTypeStripping(version = process.versions.node): boolean { + const [major = '0', minor = '0'] = version.split('.') + const majorVersion = Number.parseInt(major, 10) + const minorVersion = Number.parseInt(minor, 10) + + return majorVersion > 22 || (majorVersion === 22 && minorVersion >= 6) +} + +function resolveCompiledRuntimeWorkerPath(workerPath: string): string { + return resolve(dirname(workerPath), '../dist/runtime-worker.mjs') +} + export function createRuntimeInvocation(workerPath = resolveRuntimeWorkerPath()): { command: string, args: string[] } { + if (workerPath.endsWith('.ts')) { + return { + command: 'node', + args: supportsNodeTypeStripping() + ? ['--experimental-strip-types', workerPath] + : [resolveCompiledRuntimeWorkerPath(workerPath)], + } + } + return { command: 'node', - args: workerPath.endsWith('.ts') - ? ['--experimental-strip-types', workerPath] - : [workerPath], + args: [workerPath], } } diff --git a/packages/config/src/defaults.ts b/packages/config/src/defaults.ts index cfed2092..efdaa3a3 100644 --- a/packages/config/src/defaults.ts +++ b/packages/config/src/defaults.ts @@ -533,10 +533,11 @@ function parseScopedOptionalInteger( function parseInteger( value: number | string | undefined, fallback: number, + namespace: string, label: string, options: { minimum?: number } = {}, ): number { - return parseScopedInteger(value, fallback, 'Holo Queue', label, { + return parseScopedInteger(value, fallback, namespace, label, { ...options, parser: 'parse-int', }) @@ -560,8 +561,8 @@ function normalizeScopedName(value: string | undefined, namespace: string, label return normalized } -function normalizeConnectionName(value: string | undefined, label: string): string { - return normalizeScopedName(value, 'Holo Queue', label) +function normalizeConnectionName(value: string | undefined, namespace: string, label: string): string { + return normalizeScopedName(value, namespace, label) } function normalizeCacheName(value: string | undefined, label: string): string { @@ -999,10 +1000,10 @@ function normalizeRedisConnection( driver: 'redis', connection: resolvedRedisConnection.name, queue: normalizeQueueName(config.queue), - retryAfter: parseInteger(config.retryAfter, DEFAULT_QUEUE_RETRY_AFTER, `queue connection "${name}" retryAfter`, { + retryAfter: parseInteger(config.retryAfter, DEFAULT_QUEUE_RETRY_AFTER, 'Holo Queue', `queue connection "${name}" retryAfter`, { minimum: 0, }), - blockFor: parseInteger(config.blockFor, DEFAULT_QUEUE_BLOCK_FOR, `queue connection "${name}" blockFor`, { + blockFor: parseInteger(config.blockFor, DEFAULT_QUEUE_BLOCK_FOR, 'Holo Queue', `queue connection "${name}" blockFor`, { minimum: 0, }), redis: Object.freeze({ @@ -1025,10 +1026,10 @@ function normalizeDatabaseConnection( name, driver: 'database', queue: normalizeQueueName(config.queue), - retryAfter: parseInteger(config.retryAfter, DEFAULT_QUEUE_RETRY_AFTER, `queue connection "${name}" retryAfter`, { + retryAfter: parseInteger(config.retryAfter, DEFAULT_QUEUE_RETRY_AFTER, 'Holo Queue', `queue connection "${name}" retryAfter`, { minimum: 0, }), - sleep: parseInteger(config.sleep, DEFAULT_QUEUE_SLEEP, `queue connection "${name}" sleep`, { + sleep: parseInteger(config.sleep, DEFAULT_QUEUE_SLEEP, 'Holo Queue', `queue connection "${name}" sleep`, { minimum: 0, }), connection: config.connection?.trim() || DEFAULT_FAILED_JOBS_CONNECTION, @@ -1062,7 +1063,7 @@ function normalizeConnections( } const normalizedEntries = Object.entries(connections).map(([name, config]) => { - const normalizedName = normalizeConnectionName(name, 'Queue connection name') + const normalizedName = normalizeConnectionName(name, 'Holo Queue', 'Queue connection name') return [normalizedName, normalizeConnectionConfig(normalizedName, config, redisConfig)] as const }) @@ -1196,7 +1197,7 @@ export function normalizeSessionConfig( const stores = !config.stores || Object.keys(config.stores).length === 0 ? holoSessionDefaults.stores : Object.freeze(Object.fromEntries(Object.entries(config.stores).map(([name, store]) => { - const normalizedName = normalizeConnectionName(name, 'Session store name') + const normalizedName = normalizeConnectionName(name, 'Holo Session', 'Session store name') return [normalizedName, normalizeSessionStoreConfig(normalizedName, store, redisConfig)] }))) @@ -1217,12 +1218,13 @@ export function normalizeSessionConfig( throw new Error(`[Holo Session] cookie sameSite must be "lax", "strict", or "none".`) } - const idleTimeout = parseInteger(config.idleTimeout, DEFAULT_SESSION_IDLE_TIMEOUT, 'session idleTimeout', { + const idleTimeout = parseInteger(config.idleTimeout, DEFAULT_SESSION_IDLE_TIMEOUT, 'Holo Session', 'session idleTimeout', { minimum: 0, }) const absoluteLifetime = parseInteger( config.absoluteLifetime, DEFAULT_SESSION_ABSOLUTE_LIFETIME, + 'Holo Session', 'session absoluteLifetime', { minimum: 0, @@ -1231,6 +1233,7 @@ export function normalizeSessionConfig( const rememberMeLifetime = parseInteger( config.rememberMeLifetime, DEFAULT_SESSION_REMEMBER_ME_LIFETIME, + 'Holo Session', 'session rememberMeLifetime', { minimum: 0, @@ -1248,7 +1251,7 @@ export function normalizeSessionConfig( httpOnly: cookie.httpOnly ?? true, sameSite, partitioned: cookie.partitioned ?? false, - maxAge: parseInteger(cookie.maxAge, absoluteLifetime, 'session cookie maxAge', { + maxAge: parseInteger(cookie.maxAge, absoluteLifetime, 'Holo Session', 'session cookie maxAge', { minimum: 0, }), }), @@ -1438,10 +1441,10 @@ function normalizePasswordBroker( name, provider, table: config.table?.trim() || DEFAULT_AUTH_PASSWORD_RESET_TABLE, - expire: parseInteger(config.expire, DEFAULT_AUTH_PASSWORD_EXPIRE, `auth password broker "${name}" expire`, { + expire: parseInteger(config.expire, DEFAULT_AUTH_PASSWORD_EXPIRE, 'Holo Auth', `auth password broker "${name}" expire`, { minimum: 0, }), - throttle: parseInteger(config.throttle, DEFAULT_AUTH_PASSWORD_THROTTLE, `auth password broker "${name}" throttle`, { + throttle: parseInteger(config.throttle, DEFAULT_AUTH_PASSWORD_THROTTLE, 'Holo Auth', `auth password broker "${name}" throttle`, { minimum: 0, }), route: config.route?.trim() || DEFAULT_AUTH_PASSWORD_RESET_ROUTE, @@ -1573,7 +1576,7 @@ function normalizeWorkosConfig( } const normalizedEntries = providerEntries.map(([name, providerConfig]) => { - const normalizedName = normalizeConnectionName(name, 'Auth WorkOS provider name') + const normalizedName = normalizeConnectionName(name, 'Holo Auth', 'Auth WorkOS provider name') return [normalizedName, providerConfig] as const }) if (provider && !normalizedEntries.some(([name]) => name === provider)) { @@ -1697,7 +1700,7 @@ function normalizeClerkConfig( } const normalizedEntries = providerEntries.map(([name, providerConfig]) => { - const normalizedName = normalizeConnectionName(name, 'Auth Clerk provider name') + const normalizedName = normalizeConnectionName(name, 'Holo Auth', 'Auth Clerk provider name') return [normalizedName, providerConfig] as const }) if (provider && !normalizedEntries.some(([name]) => name === provider)) { @@ -1723,7 +1726,7 @@ export function normalizeAuthConfig( const providers = !config.providers || Object.keys(config.providers).length === 0 ? holoAuthDefaults.providers : Object.freeze(Object.fromEntries(Object.entries(config.providers).map(([name, provider]) => { - const normalizedName = normalizeConnectionName(name, 'Auth provider name') + const normalizedName = normalizeConnectionName(name, 'Holo Auth', 'Auth provider name') return [normalizedName, normalizeAuthProvider(normalizedName, provider)] }))) @@ -1736,7 +1739,7 @@ export function normalizeAuthConfig( ), }) : Object.freeze(Object.fromEntries(Object.entries(config.guards).map(([name, guard]) => { - const normalizedName = normalizeConnectionName(name, 'Auth guard name') + const normalizedName = normalizeConnectionName(name, 'Holo Auth', 'Auth guard name') return [normalizedName, normalizeAuthGuard(normalizedName, guard, providers)] }))) @@ -1749,7 +1752,7 @@ export function normalizeAuthConfig( ), }) : Object.freeze(Object.fromEntries(Object.entries(config.passwords).map(([name, broker]) => { - const normalizedName = normalizeConnectionName(name, 'Auth password broker name') + const normalizedName = normalizeConnectionName(name, 'Holo Auth', 'Auth password broker name') return [normalizedName, normalizePasswordBroker(normalizedName, broker, providers)] }))) @@ -1766,7 +1769,7 @@ export function normalizeAuthConfig( const social = !config.social || Object.keys(config.social).length === 0 ? holoAuthDefaults.social : Object.freeze(Object.fromEntries(Object.entries(config.social).map(([name, provider]) => { - const normalizedName = normalizeConnectionName(name, 'Auth social provider name') + const normalizedName = normalizeConnectionName(name, 'Holo Auth', 'Auth social provider name') return [normalizedName, normalizeSocialProvider(normalizedName, provider, guards, providers)] })))