From ca3d90d8433b6ecac8a1dbd7a0f7d017bc011a09 Mon Sep 17 00:00:00 2001 From: Mohamed Melouk <42706279+cobraprojects@users.noreply.github.com> Date: Thu, 23 Apr 2026 07:10:50 +0200 Subject: [PATCH 1/4] typecheck --- package.json | 4 +- packages/adapter-nuxt/tests/setup.test.ts | 59 +++++-- packages/adapter-nuxt/tsconfig.tests.json | 11 ++ packages/auth-clerk/src/index.ts | 125 +++++++++++--- packages/auth-social/src/index.ts | 38 ++-- packages/auth-workos/src/index.ts | 125 +++++++++++--- packages/auth/src/contracts.ts | 13 +- packages/auth/src/runtime.ts | 144 ++++++++++++---- packages/auth/tsconfig.tests.json | 15 ++ packages/auth/tsconfig.type-tests.json | 14 ++ packages/authorization/src/contracts.ts | 9 +- packages/authorization/src/runtime.ts | 42 +++-- .../tests/contracts.type.test.ts | 2 +- packages/authorization/tsconfig.tests.json | 15 ++ .../authorization/tsconfig.type-tests.json | 14 ++ packages/broadcast/src/worker.ts | 11 +- packages/broadcast/tests/contracts.test.ts | 4 +- .../tests/broadcast-config.type.test.ts | 6 + .../config/tests/security-config.type.test.ts | 6 + packages/forms/tests/client.test.ts | 2 +- packages/forms/tests/security.test.ts | 2 +- packages/forms/tsconfig.tests.json | 18 ++ .../storage/src/runtime/composables/index.ts | 3 +- packages/validation/tsconfig.tests.json | 18 ++ scripts/run-test-typecheck.mjs | 162 ++++++++++++++++++ 25 files changed, 720 insertions(+), 142 deletions(-) create mode 100644 packages/adapter-nuxt/tsconfig.tests.json create mode 100644 packages/auth/tsconfig.tests.json create mode 100644 packages/auth/tsconfig.type-tests.json create mode 100644 packages/authorization/tsconfig.tests.json create mode 100644 packages/authorization/tsconfig.type-tests.json create mode 100644 packages/forms/tsconfig.tests.json create mode 100644 packages/validation/tsconfig.tests.json create mode 100644 scripts/run-test-typecheck.mjs diff --git a/package.json b/package.json index 7afbaf2..9df2614 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,9 @@ "lint:db": "eslint packages/db/src packages/db/tests", "lint:fix": "node scripts/run-eslint.mjs --fix && node scripts/run-generated-eslint.mjs --fix", "lint:generated": "node scripts/run-generated-eslint.mjs", - "typecheck": "bun run --workspaces --if-present --sequential typecheck", + "typecheck": "bun run typecheck:libraries && bun run typecheck:tests", + "typecheck:libraries": "bun run --workspaces --if-present --sequential typecheck", + "typecheck:tests": "node scripts/run-test-typecheck.mjs", "typecheck:db": "bun run --filter @holo-js/db typecheck", "test": "bun run --workspaces --if-present --sequential test", "test:watch": "vitest --workspace vitest.workspace.ts", diff --git a/packages/adapter-nuxt/tests/setup.test.ts b/packages/adapter-nuxt/tests/setup.test.ts index 0b99fed..5d869be 100644 --- a/packages/adapter-nuxt/tests/setup.test.ts +++ b/packages/adapter-nuxt/tests/setup.test.ts @@ -15,6 +15,24 @@ let adapterBuildPromise: Promise<{ adapterOutDir: string }> | null = null const dbRuntimeDependencyNames = ['better-sqlite3', 'mysql2', 'pg', 'ulid', 'uuid'] as const type RuntimeConfigShape = Record +type NuxtHarnessOptions = { + rootDir?: string + srcDir: string + runtimeConfig?: RuntimeConfigShape & { + holoStorage?: unknown + } + build: { + transpile: string[] + } + nitro?: { + storage?: Record + } +} + +type NuxtHarness = { + options: NuxtHarnessOptions + hook: ReturnType +} async function createTempBuildRoot(prefix: string): Promise { const baseDir = resolve(repoRoot, '.vitest-builds') @@ -39,13 +57,14 @@ async function provisionTempPackage(sourcePackageDir: string, tempPackageDir: st } async function runPackageBuild(command: string, args: string[], targetPackageDir: string, outDir?: string): Promise { - const env = outDir ? { - ...process.env, - HOLO_BUILD_OUT_DIR: outDir, - } : { + const env: NodeJS.ProcessEnv = { ...process.env, } + if (outDir) { + env.HOLO_BUILD_OUT_DIR = outDir + } + env.PATH = `${resolve(repoRoot, 'node_modules/.bin')}:${env.PATH ?? ''}` execFileSync(command, args, { @@ -130,7 +149,7 @@ async function runAdapterStub(): Promise<{ adapterOutDir: string }> { return adapterBuildPromise } -function createNuxtHarness(rootDir: string, runtimeConfig: RuntimeConfigShape = {}) { +function createNuxtHarness(rootDir: string, runtimeConfig: RuntimeConfigShape = {}): NuxtHarness { return { options: { rootDir, @@ -145,13 +164,21 @@ function createNuxtHarness(rootDir: string, runtimeConfig: RuntimeConfigShape = } function runHook( - nuxt: ReturnType, + nuxt: NuxtHarness, name: string, ): void { const callback = nuxt.hook.mock.calls.find(([hookName]) => hookName === name)?.[1] callback?.() } +function getNitroStorage(nuxt: NuxtHarness): Record | undefined { + return nuxt.options.nitro?.storage +} + +function getHoloStorageRuntimeConfig(nuxt: NuxtHarness): Record | undefined { + return nuxt.options.runtimeConfig?.holoStorage as Record | undefined +} + async function createProject(): Promise { const root = await mkdtemp(join(tmpdir(), 'holo-storage-module-')) tempDirs.push(root) @@ -359,7 +386,7 @@ export default defineStorageConfig({ await module.setup({}, nuxt as never) runHook(nuxt, 'modules:done') - expect((nuxt.options as { nitro: { storage: Record } }).nitro.storage).toEqual({ + expect(getNitroStorage(nuxt)).toEqual({ 'holo:local': { driver: 'fs', base: './storage/app', @@ -383,8 +410,8 @@ export default defineStorageConfig({ forcePathStyleEndpoint: true, }, }) - expect((nuxt.options.runtimeConfig.holoStorage as Record).defaultDisk).toBe('media') - expect((nuxt.options.runtimeConfig.holoStorage as Record).routePrefix).toBe('/files') + expect(getHoloStorageRuntimeConfig(nuxt)?.defaultDisk).toBe('media') + expect(getHoloStorageRuntimeConfig(nuxt)?.routePrefix).toBe('/files') expect(nuxt.options.build.transpile).toContain('./runtime') expect(addImports).toHaveBeenCalledTimes(1) expect(addImports.mock.calls[0]?.[0]).toHaveLength(6) @@ -417,7 +444,7 @@ export default defineStorageConfig({ await module.setup({}, nuxt as never) runHook(nuxt, 'modules:done') - expect((nuxt.options as { nitro: { storage: Record } }).nitro.storage).toEqual({ + expect(getNitroStorage(nuxt)).toEqual({ 'holo:local': { driver: 'fs', base: './storage/app', @@ -427,8 +454,8 @@ export default defineStorageConfig({ base: './storage/app/public', }, }) - expect((nuxt.options.runtimeConfig.holoStorage as Record).defaultDisk).toBe('local') - expect((nuxt.options.runtimeConfig.holoStorage as Record).routePrefix).toBe('/storage') + expect(getHoloStorageRuntimeConfig(nuxt)?.defaultDisk).toBe('local') + expect(getHoloStorageRuntimeConfig(nuxt)?.routePrefix).toBe('/storage') expect(addServerHandler).toHaveBeenCalledWith({ route: '/storage/**', handler: './runtime/server/routes/storage.get', @@ -473,7 +500,7 @@ export default defineStorageConfig({ process.chdir(previousCwd) } - expect((nuxt.options as { nitro: { storage: Record } }).nitro.storage).toEqual({ + expect(getNitroStorage(nuxt)).toEqual({ 'holo:legacy': { driver: 'fs', base: './legacy', @@ -483,8 +510,8 @@ export default defineStorageConfig({ delete (nuxt.options as { runtimeConfig?: Record }).runtimeConfig runHook(nuxt, 'modules:done') - expect((nuxt.options.runtimeConfig.holoStorage as Record).defaultDisk).toBe('media') - expect((nuxt.options as { nitro: { storage: Record } }).nitro.storage).toEqual({ + expect(getHoloStorageRuntimeConfig(nuxt)?.defaultDisk).toBe('media') + expect(getNitroStorage(nuxt)).toEqual({ 'holo:local': { driver: 'fs', base: './storage/app', @@ -533,7 +560,7 @@ export default defineStorageConfig({ await module.setup({}, nuxt as never) - expect((nuxt.options as { nitro: { storage: Record } }).nitro.storage).toMatchObject({ + expect(getNitroStorage(nuxt)).toMatchObject({ 'holo:local': { driver: 'fs', base: './storage/app', diff --git a/packages/adapter-nuxt/tsconfig.tests.json b/packages/adapter-nuxt/tsconfig.tests.json new file mode 100644 index 0000000..857ab19 --- /dev/null +++ b/packages/adapter-nuxt/tsconfig.tests.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*", + "tests/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/auth-clerk/src/index.ts b/packages/auth-clerk/src/index.ts index de2d746..1bf0186 100644 --- a/packages/auth-clerk/src/index.ts +++ b/packages/auth-clerk/src/index.ts @@ -2,7 +2,7 @@ import { createPublicKey, verify as verifySignature } from 'node:crypto' import { authRuntimeInternals } from '@holo-js/auth' import type { AuthEstablishedSession } from '@holo-js/auth' import { parseCookieHeader } from '@holo-js/session' -import type { AuthProviderAdapter, AuthUserLike } from '@holo-js/auth' +import type { AuthUserLike } from '@holo-js/auth' import type { AuthClerkProviderConfig } from '@holo-js/config' export interface ClerkEmailAddress { @@ -56,6 +56,8 @@ type JwkKey = Readonly> & { readonly kid?: string } +type RuntimeAuthProviderAdapter = ReturnType['providers'][string] + export interface HostedIdentityRecord { readonly provider: string readonly providerUserId: string @@ -504,7 +506,7 @@ function getProviderRuntime(provider?: string): ClerkProviderRuntime { function resolveGuardAndProvider(provider?: string): { readonly guard: string readonly authProvider: string - readonly adapter: AuthProviderAdapter + readonly adapter: RuntimeAuthProviderAdapter } { const authBindings = authRuntimeInternals.getRuntimeBindings() const providerConfig = getConfiguredProviderConfig(provider) @@ -530,7 +532,36 @@ function resolveGuardAndProvider(provider?: string): { } } -function serializeLocalUser(adapter: AuthProviderAdapter, user: unknown, providerName: string): AuthUserLike { +function requireUserId( + adapter: RuntimeAuthProviderAdapter, + user: unknown, + message: string, +): string | number { + if (!user || typeof user !== 'object') { + throw new Error(message) + } + + const userId = adapter.getId(user as Record) + if (typeof userId !== 'string' && typeof userId !== 'number') { + throw new Error(message) + } + + return userId +} + +function requireUserRecord(user: unknown, message: string): Record { + if (!user || typeof user !== 'object') { + throw new Error(message) + } + + return user as Record +} + +function serializeLocalUser( + adapter: RuntimeAuthProviderAdapter, + user: Record, + providerName: string, +): AuthUserLike { const id = adapter.getId(user) const serialized = adapter.serialize ? adapter.serialize(user) @@ -609,19 +640,22 @@ function normalizeHostedProfile(profile: ClerkUserProfile): Readonly { +): Promise | null> { if (!email?.trim()) { return null } - return adapter.findByCredentials({ email: email.trim() }) + const user = await adapter.findByCredentials({ email: email.trim() }) + return user + ? requireUserRecord(user, '[@holo-js/auth-clerk] Auth provider lookups must return object users.') + : null } async function updateLocalUser( - adapter: AuthProviderAdapter, - user: unknown, + adapter: RuntimeAuthProviderAdapter, + user: Record, input: { readonly name?: string readonly email?: string @@ -629,7 +663,7 @@ async function updateLocalUser( readonly emailVerified?: boolean }, ): Promise<{ - readonly user: unknown + readonly user: Record readonly changed: boolean }> { const nextInput = { @@ -659,7 +693,10 @@ async function updateLocalUser( if (adapter.update) { return { - user: await adapter.update(user, nextInput), + user: requireUserRecord( + await adapter.update(user, nextInput), + '[@holo-js/auth-clerk] Auth provider updates must return object users.', + ), changed: true, } } @@ -670,7 +707,7 @@ async function updateLocalUser( } async function ensureNoUnexpectedEmailCollision( - adapter: AuthProviderAdapter, + adapter: RuntimeAuthProviderAdapter, authProvider: string, profile: ClerkUserProfile, currentUserId: string | number, @@ -685,7 +722,13 @@ async function ensureNoUnexpectedEmailCollision( return } - if (adapter.getId(matched) !== currentUserId) { + if ( + requireUserId( + adapter, + matched, + '[@holo-js/auth-clerk] Matched local users must expose a serializable id.', + ) !== currentUserId + ) { throw new ClerkAuthConflictError({ provider: 'clerk', clerkUserId: profile.id, @@ -698,11 +741,15 @@ async function ensureNoUnexpectedEmailCollision( async function assertUserLinkAvailable( providerName: string, authProvider: string, - adapter: AuthProviderAdapter, - user: unknown, + adapter: RuntimeAuthProviderAdapter, + user: Record, clerkUserId: string, ): Promise { - const existing = await getBindings().identityStore.findByUserId(providerName, authProvider, adapter.getId(user)) + const existing = await getBindings().identityStore.findByUserId( + providerName, + authProvider, + requireUserId(adapter, user, '[@holo-js/auth-clerk] Linked users must expose a serializable id.'), + ) if (existing && existing.providerUserId !== clerkUserId) { throw new ClerkAuthConflictError({ provider: providerName, @@ -893,7 +940,10 @@ export async function syncIdentity( const existingIdentity = await identityStore.findByProviderUserId(providerName, profile.id) if (existingIdentity) { - let linkedUser = await adapter.findById(existingIdentity.userId) + const existingLinkedUser = await adapter.findById(existingIdentity.userId) + let linkedUser = existingLinkedUser + ? requireUserRecord(existingLinkedUser, '[@holo-js/auth-clerk] Auth provider lookups must return object users.') + : null if (!linkedUser) { linkedUser = verifiedEmail @@ -905,13 +955,13 @@ export async function syncIdentity( } if (!linkedUser) { - linkedUser = await adapter.create({ + linkedUser = requireUserRecord(await adapter.create({ name: resolveDisplayName(profile), email: resolveEmailForCreation(profile), password: null, avatar: profile.imageUrl ?? null, email_verified_at: resolvedEmail.emailVerified ? new Date() : null, - }) + }), '[@holo-js/auth-clerk] Auth provider create() must return an object user.') } const relinked = await updateLocalUser(adapter, linkedUser, { @@ -925,7 +975,11 @@ export async function syncIdentity( provider: providerName, guard, authProvider, - userId: adapter.getId(relinkedUser), + userId: requireUserId( + adapter, + relinkedUser, + '[@holo-js/auth-clerk] Relinked local users must expose a serializable id.', + ), profile, previous: existingIdentity, }) @@ -942,7 +996,16 @@ export async function syncIdentity( }) } - await ensureNoUnexpectedEmailCollision(adapter, authProvider, profile, adapter.getId(linkedUser)) + await ensureNoUnexpectedEmailCollision( + adapter, + authProvider, + profile, + requireUserId( + adapter, + linkedUser, + '[@holo-js/auth-clerk] Linked local users must expose a serializable id.', + ), + ) const updated = await updateLocalUser(adapter, linkedUser, { name: resolveDisplayName(profile), email: resolvedEmail.email, @@ -953,7 +1016,11 @@ export async function syncIdentity( provider: providerName, guard, authProvider, - userId: adapter.getId(updated.user), + userId: requireUserId( + adapter, + updated.user, + '[@holo-js/auth-clerk] Updated local users must expose a serializable id.', + ), profile, previous: existingIdentity, }) @@ -986,7 +1053,11 @@ export async function syncIdentity( provider: providerName, guard, authProvider, - userId: adapter.getId(linked.user), + userId: requireUserId( + adapter, + linked.user, + '[@holo-js/auth-clerk] Linked local users must expose a serializable id.', + ), profile, }) await identityStore.save(identity) @@ -1002,18 +1073,22 @@ export async function syncIdentity( }) } - localUser = await adapter.create({ + localUser = requireUserRecord(await adapter.create({ name: resolveDisplayName(profile), email: resolveEmailForCreation(profile), password: null, avatar: profile.imageUrl ?? null, email_verified_at: resolvedEmail.emailVerified ? new Date() : null, - }) + }), '[@holo-js/auth-clerk] Auth provider create() must return an object user.') const identity = createIdentityRecord({ provider: providerName, guard, authProvider, - userId: adapter.getId(localUser), + userId: requireUserId( + adapter, + localUser, + '[@holo-js/auth-clerk] Created local users must expose a serializable id.', + ), profile, }) await identityStore.save(identity) diff --git a/packages/auth-social/src/index.ts b/packages/auth-social/src/index.ts index e0471d9..fff9521 100644 --- a/packages/auth-social/src/index.ts +++ b/packages/auth-social/src/index.ts @@ -1,6 +1,6 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto' import { authRuntimeInternals } from '@holo-js/auth' -import type { AuthProviderAdapter, AuthUserLike } from '@holo-js/auth' +import type { AuthUserLike } from '@holo-js/auth' import type { AuthSocialProviderConfig } from '@holo-js/config' export interface SocialProviderProfile { @@ -90,6 +90,15 @@ export interface SocialAuthFacade { let socialBindings: SocialAuthBindings | undefined const AUTH_PROVIDER_MARKER = Symbol.for('holo-js.auth.provider') +type RuntimeAuthProviderAdapter = ReturnType['providers'][string] + +function requireUserRecord(user: unknown, message: string): Record { + if (!user || typeof user !== 'object') { + throw new Error(message) + } + + return user as Record +} function throwUnconfigured(): never { throw new Error('[@holo-js/auth-social] Social auth runtime is not configured yet.') @@ -196,7 +205,7 @@ function getProviderRuntime(provider: string): SocialProviderRuntime { function resolveGuardAndProvider(provider: string): { readonly guard: string readonly authProvider: string - readonly adapter: AuthProviderAdapter + readonly adapter: RuntimeAuthProviderAdapter } { const authBindings = authRuntimeInternals.getRuntimeBindings() const providerConfig = getConfiguredProviderConfig(provider) @@ -223,8 +232,8 @@ function resolveGuardAndProvider(provider: string): { } function serializeLocalUser( - adapter: AuthProviderAdapter, - user: unknown, + adapter: RuntimeAuthProviderAdapter, + user: Record, providerName: string, ): AuthUserLike { const id = adapter.getId(user) @@ -246,14 +255,17 @@ function serializeLocalUser( } async function findUserByEmail( - adapter: AuthProviderAdapter, + adapter: RuntimeAuthProviderAdapter, email: string | undefined, -): Promise { +): Promise | null> { if (!email) { return null } - return adapter.findByCredentials({ email }) + const user = await adapter.findByCredentials({ email }) + return user + ? requireUserRecord(user, '[@holo-js/auth-social] Auth provider lookups must return object users.') + : null } function resolveEmailForCreation( @@ -287,10 +299,10 @@ async function resolveLinkedUser( const verificationRequired = authBindings.config.emailVerification.required === true if (existingIdentity) { - const linkedUser = await adapter.findById(existingIdentity.userId) - if (!linkedUser) { - throw new Error(`[@holo-js/auth-social] Linked social identity "${provider}:${profile.id}" references a missing local user.`) - } + const linkedUser = requireUserRecord( + await adapter.findById(existingIdentity.userId), + `[@holo-js/auth-social] Linked social identity "${provider}:${profile.id}" references a missing local user.`, + ) const serialized = serializeLocalUser(adapter, linkedUser, authProvider) await bindings.identityStore.save({ @@ -319,7 +331,7 @@ async function resolveLinkedUser( const trustedEmail = hasVerifiedEmail ? profile.email?.trim() : undefined let localUser = await findUserByEmail(adapter, trustedEmail) if (!localUser) { - localUser = await adapter.create({ + localUser = requireUserRecord(await adapter.create({ name: profile.name, email: resolveEmailForCreation(provider, profile, { trustEmail: hasVerifiedEmail, @@ -327,7 +339,7 @@ async function resolveLinkedUser( password: null, avatar: profile.avatar, email_verified_at: hasVerifiedEmail ? new Date() : null, - }) + }), '[@holo-js/auth-social] Auth provider create() must return an object user.') } const serialized = serializeLocalUser(adapter, localUser, authProvider) diff --git a/packages/auth-workos/src/index.ts b/packages/auth-workos/src/index.ts index 3f2ca5e..6993921 100644 --- a/packages/auth-workos/src/index.ts +++ b/packages/auth-workos/src/index.ts @@ -2,7 +2,7 @@ import { createPublicKey, verify as verifySignature } from 'node:crypto' import { authRuntimeInternals } from '@holo-js/auth' import type { AuthEstablishedSession } from '@holo-js/auth' import { parseCookieHeader } from '@holo-js/session' -import type { AuthProviderAdapter, AuthUserLike } from '@holo-js/auth' +import type { AuthUserLike } from '@holo-js/auth' import type { AuthWorkosProviderConfig } from '@holo-js/config' export interface WorkosIdentityProfile { @@ -46,6 +46,8 @@ type JwkKey = Readonly> & { readonly kid?: string } +type RuntimeAuthProviderAdapter = ReturnType['providers'][string] + export interface HostedIdentityRecord { readonly provider: string readonly providerUserId: string @@ -418,7 +420,7 @@ function getProviderRuntime(provider?: string): WorkosProviderRuntime { function resolveGuardAndProvider(provider?: string): { readonly guard: string readonly authProvider: string - readonly adapter: AuthProviderAdapter + readonly adapter: RuntimeAuthProviderAdapter } { const authBindings = authRuntimeInternals.getRuntimeBindings() const providerConfig = getConfiguredProviderConfig(provider) @@ -444,7 +446,36 @@ function resolveGuardAndProvider(provider?: string): { } } -function serializeLocalUser(adapter: AuthProviderAdapter, user: unknown, providerName: string): AuthUserLike { +function requireUserId( + adapter: RuntimeAuthProviderAdapter, + user: unknown, + message: string, +): string | number { + if (!user || typeof user !== 'object') { + throw new Error(message) + } + + const userId = adapter.getId(user as Record) + if (typeof userId !== 'string' && typeof userId !== 'number') { + throw new Error(message) + } + + return userId +} + +function requireUserRecord(user: unknown, message: string): Record { + if (!user || typeof user !== 'object') { + throw new Error(message) + } + + return user as Record +} + +function serializeLocalUser( + adapter: RuntimeAuthProviderAdapter, + user: Record, + providerName: string, +): AuthUserLike { const id = adapter.getId(user) const serialized = adapter.serialize ? adapter.serialize(user) @@ -496,19 +527,22 @@ function normalizeHostedProfile(profile: WorkosIdentityProfile): Readonly { +): Promise | null> { if (!email?.trim()) { return null } - return adapter.findByCredentials({ email: email.trim() }) + const user = await adapter.findByCredentials({ email: email.trim() }) + return user + ? requireUserRecord(user, '[@holo-js/auth-workos] Auth provider lookups must return object users.') + : null } async function updateLocalUser( - adapter: AuthProviderAdapter, - user: unknown, + adapter: RuntimeAuthProviderAdapter, + user: Record, input: { readonly name?: string readonly email?: string @@ -516,7 +550,7 @@ async function updateLocalUser( readonly emailVerified?: boolean }, ): Promise<{ - readonly user: unknown + readonly user: Record readonly changed: boolean }> { const nextInput = { @@ -546,7 +580,10 @@ async function updateLocalUser( if (adapter.update) { return { - user: await adapter.update(user, nextInput), + user: requireUserRecord( + await adapter.update(user, nextInput), + '[@holo-js/auth-workos] Auth provider updates must return object users.', + ), changed: true, } } @@ -557,7 +594,7 @@ async function updateLocalUser( } async function ensureNoUnexpectedEmailCollision( - adapter: AuthProviderAdapter, + adapter: RuntimeAuthProviderAdapter, authProvider: string, profile: WorkosIdentityProfile, currentUserId: string | number, @@ -572,7 +609,13 @@ async function ensureNoUnexpectedEmailCollision( return } - if (adapter.getId(matched) !== currentUserId) { + if ( + requireUserId( + adapter, + matched, + '[@holo-js/auth-workos] Matched local users must expose a serializable id.', + ) !== currentUserId + ) { throw new WorkosAuthConflictError({ provider: 'workos', workosUserId: profile.id, @@ -585,11 +628,15 @@ async function ensureNoUnexpectedEmailCollision( async function assertUserLinkAvailable( providerName: string, authProvider: string, - adapter: AuthProviderAdapter, - user: unknown, + adapter: RuntimeAuthProviderAdapter, + user: Record, workosUserId: string, ): Promise { - const existing = await getBindings().identityStore.findByUserId(providerName, authProvider, adapter.getId(user)) + const existing = await getBindings().identityStore.findByUserId( + providerName, + authProvider, + requireUserId(adapter, user, '[@holo-js/auth-workos] Linked users must expose a serializable id.'), + ) if (existing && existing.providerUserId !== workosUserId) { throw new WorkosAuthConflictError({ provider: providerName, @@ -741,7 +788,10 @@ export async function syncIdentity( const existingIdentity = await identityStore.findByProviderUserId(providerName, profile.id) if (existingIdentity) { - let linkedUser = await adapter.findById(existingIdentity.userId) + const existingLinkedUser = await adapter.findById(existingIdentity.userId) + let linkedUser = existingLinkedUser + ? requireUserRecord(existingLinkedUser, '[@holo-js/auth-workos] Auth provider lookups must return object users.') + : null if (!linkedUser) { linkedUser = verifiedEmail @@ -753,13 +803,13 @@ export async function syncIdentity( } if (!linkedUser) { - linkedUser = await adapter.create({ + linkedUser = requireUserRecord(await adapter.create({ name: resolveDisplayName(profile), email: resolveEmailForCreation(profile), password: null, avatar: profile.avatar ?? null, email_verified_at: profile.emailVerified === true ? new Date() : null, - }) + }), '[@holo-js/auth-workos] Auth provider create() must return an object user.') } const relinked = await updateLocalUser(adapter, linkedUser, { @@ -773,7 +823,11 @@ export async function syncIdentity( provider: providerName, guard, authProvider, - userId: adapter.getId(relinkedUser), + userId: requireUserId( + adapter, + relinkedUser, + '[@holo-js/auth-workos] Relinked local users must expose a serializable id.', + ), profile, previous: existingIdentity, }) @@ -790,7 +844,16 @@ export async function syncIdentity( }) } - await ensureNoUnexpectedEmailCollision(adapter, authProvider, profile, adapter.getId(linkedUser)) + await ensureNoUnexpectedEmailCollision( + adapter, + authProvider, + profile, + requireUserId( + adapter, + linkedUser, + '[@holo-js/auth-workos] Linked local users must expose a serializable id.', + ), + ) const updated = await updateLocalUser(adapter, linkedUser, { name: resolveDisplayName(profile), email: profile.email?.trim(), @@ -801,7 +864,11 @@ export async function syncIdentity( provider: providerName, guard, authProvider, - userId: adapter.getId(updated.user), + userId: requireUserId( + adapter, + updated.user, + '[@holo-js/auth-workos] Updated local users must expose a serializable id.', + ), profile, previous: existingIdentity, }) @@ -834,7 +901,11 @@ export async function syncIdentity( provider: providerName, guard, authProvider, - userId: adapter.getId(linked.user), + userId: requireUserId( + adapter, + linked.user, + '[@holo-js/auth-workos] Linked local users must expose a serializable id.', + ), profile, }) await identityStore.save(identity) @@ -850,18 +921,22 @@ export async function syncIdentity( }) } - localUser = await adapter.create({ + localUser = requireUserRecord(await adapter.create({ name: resolveDisplayName(profile), email: resolveEmailForCreation(profile), password: null, avatar: profile.avatar ?? null, email_verified_at: profile.emailVerified === true ? new Date() : null, - }) + }), '[@holo-js/auth-workos] Auth provider create() must return an object user.') const identity = createIdentityRecord({ provider: providerName, guard, authProvider, - userId: adapter.getId(localUser), + userId: requireUserId( + adapter, + localUser, + '[@holo-js/auth-workos] Created local users must expose a serializable id.', + ), profile, }) await identityStore.save(identity) diff --git a/packages/auth/src/contracts.ts b/packages/auth/src/contracts.ts index 3d5e605..0557c67 100644 --- a/packages/auth/src/contracts.ts +++ b/packages/auth/src/contracts.ts @@ -77,7 +77,7 @@ export interface AuthFacade extends AuthGuardFacade { passwords: AuthPasswordResetFacade } -export interface AuthProviderAdapter { +type AuthProviderAdapterBase = { findById(id: string | number): Promise findByCredentials(credentials: Readonly>): Promise create(input: Readonly>): Promise @@ -86,9 +86,18 @@ export interface AuthProviderAdapter { getId(user: TUser): string | number getPasswordHash?(user: TUser): string | null | undefined getEmailVerifiedAt?(user: TUser): Date | string | null | undefined - serialize?(user: TUser): AuthUser } +export type AuthProviderAdapter = AuthProviderAdapterBase & ( + TUser extends AuthUser + ? { + serialize?(user: TUser): AuthUser + } + : { + serialize(user: TUser): AuthUser + } +) + export interface AuthPasswordHasher { hash(password: string): Promise verify(password: string, digest: string): Promise diff --git a/packages/auth/src/runtime.ts b/packages/auth/src/runtime.ts index a1a8462..347b022 100644 --- a/packages/auth/src/runtime.ts +++ b/packages/auth/src/runtime.ts @@ -15,7 +15,6 @@ import type { AuthLogoutResult, AuthPasswordResetFacade, AuthPasswordHasher, - AuthProviderAdapter, AuthRegistrationInput, AuthSessionLoginOptions, AuthTokenFacade, @@ -25,7 +24,6 @@ import type { AuthRuntimeContext, AuthRuntimeFacade, AuthSessionRecord, - AuthUserLike, EmailVerificationTokenRecord, EmailVerificationTokenResult, EmailVerificationTokenStore, @@ -41,10 +39,22 @@ const SCRYPT_PREFIX = 'scrypt' const TOKEN_HASH_PREFIX = 'sha256' const AUTH_PROVIDER_MARKER = Symbol.for('holo-js.auth.provider') -type SerializedAuthUser = AuthUserLike & { +type SerializedAuthUser = AuthUser & { readonly id: string | number } +type ErasedAuthProviderAdapter = { + findById(id: string | number): Promise + findByCredentials(credentials: Readonly>): Promise + create(input: Readonly>): Promise + update?(user: unknown, input: Readonly>): Promise + matchesUser?(user: unknown): boolean + getId(user: unknown): string | number + getPasswordHash?(user: unknown): string | null | undefined + getEmailVerifiedAt?(user: unknown): Date | string | null | undefined + serialize?(user: unknown): AuthUser +} + type SessionIdentityPayload = { readonly guard: string readonly provider: string @@ -82,7 +92,7 @@ type AsyncAuthContext = AuthRuntimeContext & { type RuntimeBindings = { readonly config: ReturnType readonly session: AuthRuntimeBindings['session'] - readonly providers: Readonly> + readonly providers: Readonly> readonly tokens?: AuthTokenStore readonly emailVerificationTokens?: EmailVerificationTokenStore readonly passwordResetTokens?: PasswordResetTokenStore @@ -193,6 +203,25 @@ function getRuntimeBindings(): RuntimeBindings { return bindings } +function getExposedRuntimeBindings(): { + readonly config: RuntimeBindings['config'] + readonly session: AuthRuntimeBindings['session'] + readonly providers: AuthRuntimeBindings['providers'] + readonly tokens?: AuthTokenStore + readonly emailVerificationTokens?: EmailVerificationTokenStore + readonly passwordResetTokens?: PasswordResetTokenStore + readonly delivery: AuthDeliveryHook + readonly context: AuthRuntimeContext + readonly passwordHasher: AuthPasswordHasher +} { + const bindings = getRuntimeBindings() + + return { + ...bindings, + providers: bindings.providers as unknown as AuthRuntimeBindings['providers'], + } +} + function createDefaultPasswordHasher(): AuthPasswordHasher { return { async hash(password: string): Promise { @@ -217,6 +246,14 @@ function createDefaultPasswordHasher(): AuthPasswordHasher { } } +function requireRecordValue(value: unknown, message: string): Record { + if (!value || typeof value !== 'object') { + throw new Error(message) + } + + return value as Record +} + async function resolveNeedsPasswordRehash( hasher: AuthPasswordHasher, digest: string, @@ -696,7 +733,7 @@ function getProviderAdapter( providerName: string, ): { readonly config: RuntimeBindings['config']['providers'][string] - readonly adapter: AuthProviderAdapter + readonly adapter: ErasedAuthProviderAdapter } { const bindings = getRuntimeBindings() const providerConfig = bindings.config.providers[providerName] @@ -724,11 +761,19 @@ function readMarkedProvider(user: unknown): string | undefined { return typeof marker === 'string' ? marker : undefined } +function requireUserRecord(user: unknown, message: string): Record { + if (!user || typeof user !== 'object') { + throw new Error(message) + } + + return user as Record +} + function getGuardProviderAdapter( guardName: string, ): { readonly guard: RuntimeBindings['config']['guards'][string] - readonly adapter: AuthProviderAdapter + readonly adapter: ErasedAuthProviderAdapter readonly provider: string } { const guard = getGuardConfig(guardName) @@ -780,10 +825,10 @@ function toLookupCredentials( } async function findUserByConfiguredIdentifiers( - adapter: AuthProviderAdapter, + adapter: ErasedAuthProviderAdapter, credentials: Readonly>, identifiers: readonly string[], -): Promise { +): Promise | null> { const lookup = toLookupCredentials(credentials, identifiers) for (const identifier of identifiers) { @@ -796,7 +841,7 @@ async function findUserByConfiguredIdentifiers( [identifier]: value, }) if (user) { - return user + return requireRecordValue(user, '[@holo-js/auth] Auth provider lookup must return an object user record.') } } @@ -819,17 +864,19 @@ function toRegistrationRecord(input: AuthRegistrationInput, password: string): R } function serializeUser( - adapter: AuthProviderAdapter, + adapter: ErasedAuthProviderAdapter, user: unknown, providerName?: string, ): SerializedAuthUser { const serialized = adapter.serialize ? adapter.serialize(user) - : { ...(user as Record) } + : requireRecordValue( + user, + '[@holo-js/auth] Auth provider users must be objects when serialize() is not implemented.', + ) const id = adapter.getId(user) - const result = { - ...serialized, + ...requireRecordValue(serialized, '[@holo-js/auth] Auth provider serialize() must return an object user.'), id, } if (providerName) { @@ -840,7 +887,7 @@ function serializeUser( }) } - return Object.freeze(result) + return Object.freeze(result) as SerializedAuthUser } function rehydrateSerializedUser( @@ -860,25 +907,25 @@ function rehydrateSerializedUser( } function getEmailVerifiedAt( - adapter: AuthProviderAdapter, + adapter: ErasedAuthProviderAdapter, user: unknown, ): Date | string | null | undefined { if (adapter.getEmailVerifiedAt) { return adapter.getEmailVerifiedAt(user) } - return (user as Record).email_verified_at as Date | string | null | undefined + return requireRecordValue(user, '[@holo-js/auth] Auth provider users must be objects.').email_verified_at as Date | string | null | undefined } function getPasswordHash( - adapter: AuthProviderAdapter, + adapter: ErasedAuthProviderAdapter, user: unknown, ): string | null | undefined { if (adapter.getPasswordHash) { return adapter.getPasswordHash(user) } - const value = (user as Record).password + const value = requireRecordValue(user, '[@holo-js/auth] Auth provider users must be objects.').password return typeof value === 'string' ? value : null } @@ -1292,11 +1339,11 @@ function assertTrustedUserProvider( } function extractUserId( - adapter: AuthProviderAdapter, + adapter: ErasedAuthProviderAdapter, user: unknown, ): string | number | undefined { try { - const resolved = adapter.getId(user as never) + const resolved = adapter.getId(user) if (typeof resolved === 'string' || typeof resolved === 'number') { return resolved } @@ -1314,6 +1361,19 @@ function extractUserId( : undefined } +function requireUserId( + adapter: ErasedAuthProviderAdapter, + user: unknown, + message: string, +): string | number { + const userId = extractUserId(adapter, user) + if (typeof userId !== 'string' && typeof userId !== 'number') { + throw new Error(message) + } + + return userId +} + function isCompatibleSerializedUserCandidate( candidate: unknown, serialized: SerializedAuthUser, @@ -1322,16 +1382,18 @@ function isCompatibleSerializedUserCandidate( return false } + const serializedRecord = serialized as Readonly> + for (const [key, value] of Object.entries(candidate)) { if (typeof value === 'undefined') { continue } - if (!(key in serialized)) { + if (!(key in serializedRecord)) { return false } - if (serialized[key] !== value) { + if (serializedRecord[key] !== value) { return false } } @@ -1344,8 +1406,8 @@ async function resolveTrustedUserForGuard( candidate: unknown, ): Promise<{ readonly provider: string - readonly adapter: AuthProviderAdapter - readonly user: unknown + readonly adapter: ErasedAuthProviderAdapter + readonly user: Record }> { const { provider, adapter } = getGuardProviderAdapter(guardName) @@ -1364,7 +1426,7 @@ async function resolveTrustedUserForGuard( return { provider, adapter, - user, + user: requireRecordValue(user, '[@holo-js/auth] Auth provider lookups must return object users.'), } } @@ -1389,7 +1451,7 @@ async function resolveTrustedUserForGuard( return { provider, adapter, - user, + user: requireRecordValue(user, '[@holo-js/auth] Auth provider lookups must return object users.'), } } @@ -1411,7 +1473,7 @@ async function resolveTrustedUserForGuard( return { provider, adapter, - user, + user: requireRecordValue(user, '[@holo-js/auth] Auth provider lookups must return object users.'), } } } @@ -1826,7 +1888,7 @@ async function updateUserRecord( let updated: unknown = user if (adapter.update) { - updated = await adapter.update(user as never, input) + updated = await adapter.update(user, input) } else if ( typeof input.name !== 'undefined' || typeof input.email !== 'undefined' @@ -1849,7 +1911,11 @@ function createEmailVerificationFacade(): AuthEmailVerificationFacade { ? getGuardConfig(options.guard).provider : findProviderNameForUser(user) const { adapter } = getProviderAdapter(providerName) - const serialized = serializeUser(adapter, user, providerName) + const serialized = serializeUser( + adapter, + requireUserRecord(user, '[@holo-js/auth] Email verification requires a serializable user object.'), + providerName, + ) const email = typeof serialized.email === 'string' ? serialized.email.trim() : '' if (!email) { throw new Error('[@holo-js/auth] Email verification requires a user with an email address.') @@ -2041,7 +2107,11 @@ function createTokenFacade(): AuthTokenFacade { ? getGuardConfig(options.guard).provider : findProviderNameForUser(user) const { adapter } = getProviderAdapter(providerName) - const userId = adapter.getId(user) + const userId = requireUserId( + adapter, + user, + '[@holo-js/auth] Personal access token creation requires a user with a serializable id.', + ) const id = createPersonalAccessTokenId() const secret = createPersonalAccessTokenSecret() const record = normalizeTokenRecord({ @@ -2066,7 +2136,11 @@ function createTokenFacade(): AuthTokenFacade { ? getGuardConfig(options.guard).provider : findProviderNameForUser(user) const { adapter } = getProviderAdapter(providerName) - const userId = adapter.getId(user) + const userId = requireUserId( + adapter, + user, + '[@holo-js/auth] Listing personal access tokens requires a user with a serializable id.', + ) const records = await tokenStore.listByUserId(providerName, userId) return records.map(normalizeTokenRecord) }, @@ -2081,7 +2155,11 @@ function createTokenFacade(): AuthTokenFacade { ? getGuardConfig(options.guard).provider : findProviderNameForUser(user) const { adapter } = getProviderAdapter(providerName) - const userId = adapter.getId(user) + const userId = requireUserId( + adapter, + user, + '[@holo-js/auth] Revoking personal access tokens requires a user with a serializable id.', + ) return tokenStore.deleteByUserId(providerName, userId) }, async authenticate(plainTextToken: string): Promise { @@ -2395,7 +2473,7 @@ export const authRuntimeInternals = { establishSessionForUser, getPasswordHash, getProviderIdentifiers, - getRuntimeBindings, + getRuntimeBindings: getExposedRuntimeBindings, hashTokenSecret, parsePlainTextToken, parseSetCookieDefinition, diff --git a/packages/auth/tsconfig.tests.json b/packages/auth/tsconfig.tests.json new file mode 100644 index 0000000..ca6a796 --- /dev/null +++ b/packages/auth/tsconfig.tests.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": [ + "src/**/*", + "tests/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "tests/**/*.type.test.ts" + ] +} diff --git a/packages/auth/tsconfig.type-tests.json b/packages/auth/tsconfig.type-tests.json new file mode 100644 index 0000000..6d17217 --- /dev/null +++ b/packages/auth/tsconfig.type-tests.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": [ + "src/**/*", + "tests/**/*.type.test.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/authorization/src/contracts.ts b/packages/authorization/src/contracts.ts index 135f4d4..3e8c79c 100644 --- a/packages/authorization/src/contracts.ts +++ b/packages/authorization/src/contracts.ts @@ -156,6 +156,7 @@ export interface AuthorizationGuardRegistry { type FallbackRegistryName = [TName] extends [never] ? string : TName type FallbackRegistryAction = [TAction] extends [never] ? string : TAction type FallbackRegistryInput = [TInput] extends [never] ? object : TInput +type FallbackRegistryActor = [TActor] extends [never] ? object : TActor export type HoloPolicyName = FallbackRegistryName> export type HoloAbilityName = FallbackRegistryName> @@ -177,22 +178,22 @@ export type PolicyActorForName = RegisteredAuthoriza string, infer TActor > - ? TActor + ? FallbackRegistryActor : RegisteredAuthorizationPolicyEntry extends { actor: infer TActor } - ? TActor + ? FallbackRegistryActor : object export type AbilityActorForName = RegisteredAuthorizationAbilityEntry extends AuthorizationAbilityRegistryEntry< object, infer TActor > - ? TActor + ? FallbackRegistryActor : RegisteredAuthorizationAbilityEntry extends { actor: infer TActor } - ? TActor + ? FallbackRegistryActor : object type RegisteredPolicyClassActionFor = { diff --git a/packages/authorization/src/runtime.ts b/packages/authorization/src/runtime.ts index c863be4..bb0c4c8 100644 --- a/packages/authorization/src/runtime.ts +++ b/packages/authorization/src/runtime.ts @@ -17,12 +17,16 @@ import type { AuthorizationTargetModel, AuthorizationTargetModelDefinition, AuthorizationTargetConstructor, + AuthorizationAbilityRegistry, + AuthorizationPolicyRegistry, AbilityInput, HoloAbilityName, - HoloAuthorizationGuardName, HoloPolicyName, + HoloAuthorizationGuardName, PolicyActionFor, PolicyActionForPolicy, + AbilityActorForName, + PolicyActorForName, } from './contracts' import { allow, @@ -41,6 +45,18 @@ import { type RegisteredPolicy = AuthorizationPolicyDefinition type RegisteredAbility = AuthorizationAbilityDefinition +type FallbackAuthorizationActor = [TActor] extends [never] + ? object + : Extract + +type PolicyActorForDefinition = [Extract] extends [never] + ? object + : FallbackAuthorizationActor>> + +type AbilityActorForDefinition = [Extract] extends [never] + ? object + : FallbackAuthorizationActor>> + type AuthorizationAuthIntegration = { hasGuard(guardName: string): boolean resolveDefaultActor(): Promise | object | null @@ -241,26 +257,25 @@ function freezeAbilityDefinition(definiti export function definePolicy< TName extends string, TTarget extends AuthorizationPolicyTarget, - TActor extends object, TDefinition extends { - readonly before?: AuthorizationPolicyBeforeHandler - readonly class?: Readonly>> - readonly record?: Readonly>> + readonly before?: AuthorizationPolicyBeforeHandler, TTarget> + readonly class?: Readonly, TTarget>>> + readonly record?: Readonly, TTarget>>> }, >( name: TName, target: TTarget, definition: TDefinition & { - readonly before?: AuthorizationPolicyBeforeHandler - readonly class?: Readonly>> - readonly record?: Readonly>> + readonly before?: AuthorizationPolicyBeforeHandler, TTarget> + readonly class?: Readonly, TTarget>>> + readonly record?: Readonly, TTarget>>> }, ): AuthorizationPolicyDefinition< TName, TTarget, Extract, string>, Extract, string>, - TActor + PolicyActorForDefinition > { const normalizedName = normalizePolicyName(name) const normalizedTarget = normalizeTarget(target) @@ -283,18 +298,17 @@ export function definePolicy< TTarget, Extract, string>, Extract, string>, - TActor + PolicyActorForDefinition > } export function defineAbility< TName extends string, TInput extends object, - TActor extends object, >( name: TName, - handle: AuthorizationAbilityHandler, -): AuthorizationAbilityDefinition { + handle: AuthorizationAbilityHandler, TInput>, +): AuthorizationAbilityDefinition { const normalizedName = normalizeAbilityName(name) const runtimeDefinition = { [AUTHORIZATION_ABILITY_MARKER]: true, @@ -305,7 +319,7 @@ export function defineAbility< const registered = registerAbilityDefinition(freezeAbilityDefinition(runtimeDefinition)) - return registered as unknown as AuthorizationAbilityDefinition + return registered as unknown as AuthorizationAbilityDefinition } function getPolicyByName(name: string): RegisteredPolicy { diff --git a/packages/authorization/tests/contracts.type.test.ts b/packages/authorization/tests/contracts.type.test.ts index 9c7c621..a4da4fa 100644 --- a/packages/authorization/tests/contracts.type.test.ts +++ b/packages/authorization/tests/contracts.type.test.ts @@ -102,7 +102,7 @@ describe('@holo-js/authorization typing', () => { defineAbility('reports.export', (_context, input: { reportId: string, format: 'csv' | 'pdf' }) => input.format === 'csv') - expectTypeOf<'articles' | 'documents' | 'locked-projects' | 'posts' | 'projects'>().toExtend() + expectTypeOf<'posts'>().toExtend() expectTypeOf<'typing-web'>().toExtend() expectTypeOf<'create' | 'viewAny'>().toExtend>() expectTypeOf<'view' | 'update' | 'delete'>().toExtend>() diff --git a/packages/authorization/tsconfig.tests.json b/packages/authorization/tsconfig.tests.json new file mode 100644 index 0000000..ca6a796 --- /dev/null +++ b/packages/authorization/tsconfig.tests.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": [ + "src/**/*", + "tests/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "tests/**/*.type.test.ts" + ] +} diff --git a/packages/authorization/tsconfig.type-tests.json b/packages/authorization/tsconfig.type-tests.json new file mode 100644 index 0000000..6d17217 --- /dev/null +++ b/packages/authorization/tsconfig.type-tests.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": [ + "src/**/*", + "tests/**/*.type.test.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/broadcast/src/worker.ts b/packages/broadcast/src/worker.ts index c15ff3d..91f53e6 100644 --- a/packages/broadcast/src/worker.ts +++ b/packages/broadcast/src/worker.ts @@ -1859,10 +1859,17 @@ export async function startBroadcastWorker( const httpServer = createServer(async (request, response) => { const requestUrl = toNodeRequestUrl(request, `${config.worker.host}:${config.worker.port}`) const requestBody = await readNodeRequestBody(request) - const runtimeRequest = new Request(requestUrl, { + const requestInit: RequestInit = { method: request.method, headers: toNodeHeaders(request.headers), - ...(typeof requestBody === 'undefined' ? {} : { body: requestBody }), + } + + if (typeof requestBody !== 'undefined') { + requestInit.body = new Uint8Array(requestBody) + } + + const runtimeRequest = new Request(requestUrl, { + ...requestInit, }) const runtimeResponse = await runtime.fetch(runtimeRequest) await writeNodeResponse(response, runtimeResponse) diff --git a/packages/broadcast/tests/contracts.test.ts b/packages/broadcast/tests/contracts.test.ts index c226f7d..7e8c770 100644 --- a/packages/broadcast/tests/contracts.test.ts +++ b/packages/broadcast/tests/contracts.test.ts @@ -255,9 +255,7 @@ describe('@holo-js/broadcast contracts', () => { expect(() => defineChannel('orders.{orderId}', { type: 'public' as never, - authorize() { - return true - }, + authorize: (async () => true) as never, })).toThrow('must use type "private" or "presence"') expect(() => broadcastInternals.extractChannelPatternParamNames('orders.{orderId}.{orderId}')).toThrow('duplicate params') diff --git a/packages/config/tests/broadcast-config.type.test.ts b/packages/config/tests/broadcast-config.type.test.ts index 0b39429..1c6f498 100644 --- a/packages/config/tests/broadcast-config.type.test.ts +++ b/packages/config/tests/broadcast-config.type.test.ts @@ -5,6 +5,12 @@ import { type HoloConfigRegistry, } from '../src' +declare module '../src/types' { + interface HoloConfigRegistry { + services: Record + } +} + describe('@holo-js/config broadcast typing', () => { it('preserves broadcast inference through config helpers and dot-path access', () => { const broadcast = defineBroadcastConfig({ diff --git a/packages/config/tests/security-config.type.test.ts b/packages/config/tests/security-config.type.test.ts index 9dd31e6..a93dbe9 100644 --- a/packages/config/tests/security-config.type.test.ts +++ b/packages/config/tests/security-config.type.test.ts @@ -5,6 +5,12 @@ import { type HoloConfigRegistry, } from '../src' +declare module '../src/types' { + interface HoloConfigRegistry { + services: Record + } +} + describe('@holo-js/config security typing', () => { it('preserves security inference through config helpers and dot-path access', () => { const security = defineSecurityConfig({ diff --git a/packages/forms/tests/client.test.ts b/packages/forms/tests/client.test.ts index d9b03e9..bd4636e 100644 --- a/packages/forms/tests/client.test.ts +++ b/packages/forms/tests/client.test.ts @@ -22,7 +22,7 @@ function createSecurityClientModule(config: { readonly field: string, readonly c afterEach(() => { globalThis.fetch = originalFetch if (typeof originalDocument === 'undefined') { - delete browserGlobal.document + delete (browserGlobal as { document?: Document }).document } else { browserGlobal.document = originalDocument } diff --git a/packages/forms/tests/security.test.ts b/packages/forms/tests/security.test.ts index 04af169..17f573f 100644 --- a/packages/forms/tests/security.test.ts +++ b/packages/forms/tests/security.test.ts @@ -48,7 +48,7 @@ afterEach(() => { delete (globalThis as typeof globalThis & { __holoFormsSecurityClientModule__?: unknown }).__holoFormsSecurityClientModule__ delete (globalThis as typeof globalThis & { __holoFormsSecurityImport__?: unknown }).__holoFormsSecurityImport__ delete (globalThis as typeof globalThis & { __holoFormsSecurityClientImport__?: unknown }).__holoFormsSecurityClientImport__ - delete (globalThis as typeof globalThis & { document?: Document }).document + delete ((globalThis as typeof globalThis & { document?: Document }) as { document?: Document }).document formsSecurityInternals.resetSecurityModuleCache() clientSecurityInternals.resetSecurityClientModuleCache() }) diff --git a/packages/forms/tsconfig.tests.json b/packages/forms/tsconfig.tests.json new file mode 100644 index 0000000..83cd148 --- /dev/null +++ b/packages/forms/tsconfig.tests.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ] + }, + "include": [ + "src/**/*", + "tests/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/storage/src/runtime/composables/index.ts b/packages/storage/src/runtime/composables/index.ts index 77c66bc..acd53d1 100644 --- a/packages/storage/src/runtime/composables/index.ts +++ b/packages/storage/src/runtime/composables/index.ts @@ -212,7 +212,8 @@ function asString(value: RawStorageValue): string | null { return value } - return new TextDecoder().decode(toUint8Array(value)) + const bytes = toUint8Array(value) + return bytes ? new TextDecoder().decode(bytes) : null } async function normalizeContent(value: StorageContent): Promise> { diff --git a/packages/validation/tsconfig.tests.json b/packages/validation/tsconfig.tests.json new file mode 100644 index 0000000..83cd148 --- /dev/null +++ b/packages/validation/tsconfig.tests.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ] + }, + "include": [ + "src/**/*", + "tests/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/scripts/run-test-typecheck.mjs b/scripts/run-test-typecheck.mjs new file mode 100644 index 0000000..adf28d3 --- /dev/null +++ b/scripts/run-test-typecheck.mjs @@ -0,0 +1,162 @@ +import { mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { spawn } from 'node:child_process' +import { join, relative, resolve } from 'node:path' +import { tmpdir } from 'node:os' + +const packagesRoot = resolve('packages') + +async function main() { + const packageDirs = await collectPackageDirsWithTests() + const generatedConfigDirs = [] + + try { + for (const packageDir of packageDirs) { + const configPaths = await resolveTestTsconfigs(packageDir, generatedConfigDirs) + for (const configPath of configPaths) { + await runTypecheck(configPath, packageDir) + } + } + } finally { + await Promise.all(generatedConfigDirs.map(path => rm(path, { + recursive: true, + force: true, + }))) + } +} + +async function collectPackageDirsWithTests() { + const entries = await readdir(packagesRoot, { withFileTypes: true }) + const packageDirs = [] + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue + } + + const packageDir = join(packagesRoot, entry.name) + if (!(await pathExists(join(packageDir, 'tests'))) || !(await pathExists(join(packageDir, 'tsconfig.json')))) { + continue + } + + packageDirs.push(packageDir) + } + + return packageDirs.sort() +} + +async function resolveTestTsconfigs(packageDir, generatedConfigDirs) { + const configPaths = [] + const explicitMainConfigPath = join(packageDir, 'tsconfig.tests.json') + + if (await pathExists(explicitMainConfigPath)) { + configPaths.push(explicitMainConfigPath) + } else { + configPaths.push(await createGeneratedMainTestConfig(packageDir, generatedConfigDirs)) + } + + const typeTestFiles = await collectTypeTestFiles(packageDir) + for (const typeTestFile of typeTestFiles) { + configPaths.push(await createGeneratedTypeTestConfig(packageDir, typeTestFile, generatedConfigDirs)) + } + + return configPaths +} + +async function createGeneratedMainTestConfig(packageDir, generatedConfigDirs) { + const generatedConfigDir = await mkdtemp(join(tmpdir(), 'holo-test-typecheck-')) + generatedConfigDirs.push(generatedConfigDir) + + const generatedConfigPath = join(generatedConfigDir, 'tsconfig.json') + const relativeExtendsPath = relative(generatedConfigDir, join(packageDir, 'tsconfig.json')) + + await writeFile(generatedConfigPath, JSON.stringify({ + extends: relativeExtendsPath, + compilerOptions: { + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + }, + include: [ + join(packageDir, 'src/**/*').replaceAll('\\', '/'), + join(packageDir, 'tests/**/*.ts').replaceAll('\\', '/'), + ], + exclude: [ + join(packageDir, 'node_modules').replaceAll('\\', '/'), + join(packageDir, 'dist').replaceAll('\\', '/'), + join(packageDir, 'tests/**/*.type.test.ts').replaceAll('\\', '/'), + ], + }, null, 2)) + + return generatedConfigPath +} + +async function createGeneratedTypeTestConfig(packageDir, typeTestFile, generatedConfigDirs) { + const generatedConfigDir = await mkdtemp(join(tmpdir(), 'holo-type-test-typecheck-')) + generatedConfigDirs.push(generatedConfigDir) + + const generatedConfigPath = join(generatedConfigDir, 'tsconfig.json') + const relativeExtendsPath = relative(generatedConfigDir, join(packageDir, 'tsconfig.json')) + + await writeFile(generatedConfigPath, JSON.stringify({ + extends: relativeExtendsPath, + compilerOptions: { + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + }, + include: [ + join(packageDir, 'src/**/*').replaceAll('\\', '/'), + typeTestFile.replaceAll('\\', '/'), + ], + exclude: [ + join(packageDir, 'node_modules').replaceAll('\\', '/'), + join(packageDir, 'dist').replaceAll('\\', '/'), + ], + }, null, 2)) + + return generatedConfigPath +} + +async function collectTypeTestFiles(packageDir) { + const testsDir = join(packageDir, 'tests') + const entries = await readdir(testsDir, { + recursive: true, + withFileTypes: true, + }) + + return entries + .filter(entry => entry.isFile() && entry.name.endsWith('.type.test.ts')) + .map(entry => join(entry.parentPath, entry.name)) + .sort() +} + +async function pathExists(path) { + try { + await stat(path) + return true + } catch { + return false + } +} + +function runTypecheck(configPath, packageDir) { + return new Promise((resolvePromise, rejectPromise) => { + const displayPath = relative(process.cwd(), packageDir) || packageDir + const child = spawn('bunx', ['tsc', '-p', configPath, '--noEmit'], { + stdio: 'inherit', + shell: process.platform === 'win32', + }) + + child.on('exit', code => { + if (code === 0) { + resolvePromise() + return + } + + rejectPromise(new Error(`Test typecheck failed for ${displayPath}`)) + }) + + child.on('error', rejectPromise) + }) +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +}) From d6d3ae019f2b032bb06b8022c5ca234510905717 Mon Sep 17 00:00:00 2001 From: Mohamed Melouk <42706279+cobraprojects@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:41:55 +0200 Subject: [PATCH 2/4] typecheck --- packages/authorization/src/contracts.ts | 24 +-- packages/cli/src/project/registry.ts | 4 +- .../cli/tests/authorization-registry.test.ts | 3 +- .../tests/broadcast-config.type.test.ts | 12 +- .../config/tests/security-config.type.test.ts | 12 +- packages/events/src/registry.ts | 6 +- packages/events/tests/afterCommit.test.ts | 12 +- packages/events/tests/boundaries.test.ts | 1 + packages/events/tests/contracts.test.ts | 14 +- packages/events/tests/contracts.type.test.ts | 2 +- packages/events/tests/registry.test.ts | 2 +- packages/flux-react/tests/package.test.ts | 53 ++++-- .../flux-react/tests/package.type.test.ts | 65 ++++--- .../flux-svelte/tests/package.type.test.ts | 53 +++--- packages/flux-vue/tests/package.type.test.ts | 56 +++--- packages/flux/tests/package.test.ts | 12 +- packages/mail/tests/contracts.test.ts | 2 +- packages/mail/tests/index.type.test.ts | 2 +- packages/mail/tests/runtime.test.ts | 166 ++++++++++++------ packages/media/tests/media.test.ts | 5 +- .../notifications/tests/contracts.test.ts | 2 +- .../tests/contracts.type.test.ts | 19 +- .../notifications/tests/index.type.test.ts | 11 +- packages/notifications/tests/runtime.test.ts | 56 ++++-- .../queue-db/tests/database-driver.test.ts | 16 +- packages/queue-db/tests/failed.test.ts | 16 +- packages/storage-s3/src/index.ts | 2 +- packages/storage/tests/facade.test.ts | 57 +++--- packages/storage/tests/s3Driver.test.ts | 52 +++--- 29 files changed, 431 insertions(+), 306 deletions(-) diff --git a/packages/authorization/src/contracts.ts b/packages/authorization/src/contracts.ts index 3e8c79c..d9de6ce 100644 --- a/packages/authorization/src/contracts.ts +++ b/packages/authorization/src/contracts.ts @@ -172,27 +172,15 @@ type RegisteredAuthorizationAbilityEntry = Authoriz Extract ] -export type PolicyActorForName = RegisteredAuthorizationPolicyEntry extends AuthorizationPolicyRegistryEntry< - AuthorizationPolicyTarget, - string, - string, - infer TActor -> - ? FallbackRegistryActor - : RegisteredAuthorizationPolicyEntry extends { - actor: infer TActor - } +export type PolicyActorForName = RegisteredAuthorizationPolicyEntry extends { + actor: infer TActor +} ? FallbackRegistryActor : object -export type AbilityActorForName = RegisteredAuthorizationAbilityEntry extends AuthorizationAbilityRegistryEntry< - object, - infer TActor -> - ? FallbackRegistryActor - : RegisteredAuthorizationAbilityEntry extends { - actor: infer TActor - } +export type AbilityActorForName = RegisteredAuthorizationAbilityEntry extends { + actor: infer TActor +} ? FallbackRegistryActor : object diff --git a/packages/cli/src/project/registry.ts b/packages/cli/src/project/registry.ts index a95a372..a7ea58b 100644 --- a/packages/cli/src/project/registry.ts +++ b/packages/cli/src/project/registry.ts @@ -441,7 +441,7 @@ export function renderGeneratedAuthorizationTypes( return [ ` ${JSON.stringify(entry.name)}: {`, - ` actor: typeof ${importName}[${JSON.stringify(entry.exportName)}] extends import('@holo-js/authorization/contracts').AuthorizationPolicyDefinition ? TActor : object`, + ' actor: object', ` target: typeof ${importName}[${JSON.stringify(entry.exportName)}] extends import('@holo-js/authorization/contracts').AuthorizationPolicyDefinition ? TTarget : object`, ` classActions: typeof ${importName}[${JSON.stringify(entry.exportName)}] extends import('@holo-js/authorization/contracts').AuthorizationPolicyDefinition ? {`, ...classActionEntries, @@ -466,7 +466,7 @@ export function renderGeneratedAuthorizationTypes( return [ ` ${JSON.stringify(entry.name)}: {`, - ` actor: typeof ${importName}[${JSON.stringify(entry.exportName)}] extends import('@holo-js/authorization/contracts').AuthorizationAbilityDefinition ? TActor : object`, + ' actor: object', ` input: typeof ${importName}[${JSON.stringify(entry.exportName)}] extends import('@holo-js/authorization/contracts').AuthorizationAbilityDefinition ? TInput : object`, ' }', ].join('\n') diff --git a/packages/cli/tests/authorization-registry.test.ts b/packages/cli/tests/authorization-registry.test.ts index 7c73ede..705b8d1 100644 --- a/packages/cli/tests/authorization-registry.test.ts +++ b/packages/cli/tests/authorization-registry.test.ts @@ -156,8 +156,7 @@ describe('@holo-js/cli authorization registry discovery', () => { expect(types).toContain('AuthorizationPolicyRegistry') expect(types).toContain('AuthorizationAbilityRegistry') expect(types).toContain('AuthorizationGuardRegistry') - expect(types).toContain('actor: typeof holoAuthorizationPolicyModule0["postPolicy"] extends import(\'@holo-js/authorization/contracts\').AuthorizationPolicyDefinition ? TActor : object') - expect(types).toContain('actor: typeof holoAuthorizationAbilityModule0["default"] extends import(\'@holo-js/authorization/contracts\').AuthorizationAbilityDefinition ? TActor : object') + expect(types).toContain('actor: object') expect(types).toContain('"web": {') expect(types).toContain('user: import(\'@holo-js/auth\').AuthUser') expect(types).toContain('"admin": {') diff --git a/packages/config/tests/broadcast-config.type.test.ts b/packages/config/tests/broadcast-config.type.test.ts index 1c6f498..8dfe239 100644 --- a/packages/config/tests/broadcast-config.type.test.ts +++ b/packages/config/tests/broadcast-config.type.test.ts @@ -5,12 +5,6 @@ import { type HoloConfigRegistry, } from '../src' -declare module '../src/types' { - interface HoloConfigRegistry { - services: Record - } -} - describe('@holo-js/config broadcast typing', () => { it('preserves broadcast inference through config helpers and dot-path access', () => { const broadcast = defineBroadcastConfig({ @@ -47,7 +41,11 @@ describe('@holo-js/config broadcast typing', () => { session: {} as HoloConfigRegistry['session'], security: {} as HoloConfigRegistry['security'], auth: {} as HoloConfigRegistry['auth'], - services: {} as HoloConfigRegistry['services'], + services: { + mailgun: { + secret: 'secret', + }, + }, }) const defaultConnection: string = accessors.useConfig('broadcast.default') diff --git a/packages/config/tests/security-config.type.test.ts b/packages/config/tests/security-config.type.test.ts index a93dbe9..27f14a1 100644 --- a/packages/config/tests/security-config.type.test.ts +++ b/packages/config/tests/security-config.type.test.ts @@ -5,12 +5,6 @@ import { type HoloConfigRegistry, } from '../src' -declare module '../src/types' { - interface HoloConfigRegistry { - services: Record - } -} - describe('@holo-js/config security typing', () => { it('preserves security inference through config helpers and dot-path access', () => { const security = defineSecurityConfig({ @@ -49,7 +43,11 @@ describe('@holo-js/config security typing', () => { session: {} as HoloConfigRegistry['session'], security: security as unknown as HoloConfigRegistry['security'], auth: {} as HoloConfigRegistry['auth'], - services: {} as HoloConfigRegistry['services'], + services: { + mailgun: { + secret: 'secret', + }, + }, }) const csrfField: string = accessors.useConfig('security.csrf.field') diff --git a/packages/events/src/registry.ts b/packages/events/src/registry.ts index 3537667..d66934d 100644 --- a/packages/events/src/registry.ts +++ b/packages/events/src/registry.ts @@ -164,10 +164,10 @@ function insertListenerIntoIndexes( } } -export function registerEvent( +export function registerEvent( definition: EventDefinition, options: RegisterEventOptions = {}, -): RegisteredEvent { +): RegisteredEvent extends never ? string : Extract> { if (!isEventDefinition(definition)) { throw new Error('[Holo Events] Events must be plain objects.') } @@ -187,7 +187,7 @@ export function registerEvent( ...normalizedDefinition, name, }), - }) as RegisteredEvent + }) as RegisteredEvent extends never ? string : Extract> state.events.set(name, entry as RegisteredEvent) return entry diff --git a/packages/events/tests/afterCommit.test.ts b/packages/events/tests/afterCommit.test.ts index 1104dbe..6cc1b76 100644 --- a/packages/events/tests/afterCommit.test.ts +++ b/packages/events/tests/afterCommit.test.ts @@ -91,13 +91,11 @@ function createTestDialect(savepoints = false): Dialect { ddlAlterSupport: false, introspection: true, }, - placeholders: { - indexed(startAt = 1) { - return (_value, index) => `?${startAt + index}` - }, - named() { - return name => `:${name}` - }, + quoteIdentifier(identifier: string) { + return `"${identifier}"` + }, + createPlaceholder(index: number) { + return `?${index}` }, } } diff --git a/packages/events/tests/boundaries.test.ts b/packages/events/tests/boundaries.test.ts index da5e1ea..034e44e 100644 --- a/packages/events/tests/boundaries.test.ts +++ b/packages/events/tests/boundaries.test.ts @@ -35,6 +35,7 @@ describe('@holo-js/events package boundaries', () => { const contents = await readFile(filePath, 'utf8') const imports = [...contents.matchAll(/from\s+['"](@holo-js\/[^'"]+)['"]/g)] .map(match => match[1]) + .filter((moduleSpecifier): moduleSpecifier is string => typeof moduleSpecifier === 'string') for (const moduleSpecifier of imports) { if (!allowedHoloImports.has(moduleSpecifier)) { diff --git a/packages/events/tests/contracts.test.ts b/packages/events/tests/contracts.test.ts index 9fc63bd..ab4e189 100644 --- a/packages/events/tests/contracts.test.ts +++ b/packages/events/tests/contracts.test.ts @@ -11,7 +11,7 @@ import { describe('@holo-js/events contracts', () => { it('normalizes and freezes valid event definitions', () => { - const event = defineEvent<{ userId: string }, 'user.registered'>({ + const event = defineEvent<{ userId: string }>({ name: ' user.registered ', }) const unnamed = defineEvent<{ @@ -64,7 +64,7 @@ describe('@holo-js/events contracts', () => { }) const single = defineListener({ - listensTo: userRegistered, + listensTo: [userRegistered] as const, async handle() { return 'ok' }, @@ -116,26 +116,26 @@ describe('@holo-js/events contracts', () => { })).toThrow('Listener event reference at index 0 must be a non-empty string.') expect(() => defineListener({ - listensTo: validEvent, + listensTo: [validEvent] as const, connection: 'redis', async handle() {}, })).toThrow('Listener queue metadata requires queue: true.') expect(() => defineListener({ - listensTo: validEvent, + listensTo: [validEvent] as const, queue: true, delay: -1, async handle() {}, })).toThrow('Listener delay must be a finite number greater than or equal to 0.') expect(() => defineListener({ - listensTo: validEvent, + listensTo: [validEvent] as const, queue: 'yes' as never, async handle() {}, })).toThrow('Listener queue must be a boolean when provided.') expect(() => defineListener({ - listensTo: validEvent, + listensTo: [validEvent] as const, queue: true, delay: new Date(Number.NaN), async handle() {}, @@ -146,7 +146,7 @@ describe('@holo-js/events contracts', () => { ) expect(() => normalizeListenerDefinition({ - listensTo: validEvent, + listensTo: [validEvent] as const, handle: 'not-a-function', } as never)).toThrow('Listeners must define "listensTo" and a "handle" function.') }) diff --git a/packages/events/tests/contracts.type.test.ts b/packages/events/tests/contracts.type.test.ts index d213832..f51a1af 100644 --- a/packages/events/tests/contracts.type.test.ts +++ b/packages/events/tests/contracts.type.test.ts @@ -38,7 +38,7 @@ describe('@holo-js/events typing', () => { handle(event) { return event.name }, - }) + } satisfies ListenerDefinition) type MultiEvent = Parameters[0] type MultiEventName = MultiEvent['name'] diff --git a/packages/events/tests/registry.test.ts b/packages/events/tests/registry.test.ts index 8d57e5a..e1e92a7 100644 --- a/packages/events/tests/registry.test.ts +++ b/packages/events/tests/registry.test.ts @@ -83,7 +83,7 @@ describe('@holo-js/events registry', () => { }) it('applies normalized names first and treats explicit and path-derived collisions as the same event identity', () => { - registerEvent(defineEvent<{ userId: string }, ' user.registered '>({ + registerEvent(defineEvent<{ userId: string }>({ name: ' user.registered ', })) diff --git a/packages/flux-react/tests/package.test.ts b/packages/flux-react/tests/package.test.ts index a90ce2c..b65e467 100644 --- a/packages/flux-react/tests/package.test.ts +++ b/packages/flux-react/tests/package.test.ts @@ -1,5 +1,5 @@ -import { createElement, useEffect } from 'react' -import { act, create } from 'react-test-renderer' +import { useEffect } from 'react' +import { jsx } from 'react/jsx-runtime' import { describe, expect, it, vi } from 'vitest' import { configureFluxClient, createFluxClient, fluxInternals, getFluxClient, resetFluxClient } from '@holo-js/flux' import { @@ -19,6 +19,24 @@ type DebugConnector = { getJoinedChannels(): readonly string[] } +type Renderer = { + update(element: ReturnType): void + unmount(): void +} + +type RendererModule = { + act(callback: () => void | Promise): Promise + create(element: ReturnType): Renderer +} + +async function loadRenderer(): Promise { + return await import('react-test-renderer') as unknown as RendererModule +} + +function renderElement(Component: () => null): ReturnType { + return jsx(Component, {}) +} + describe('@holo-js/flux-react package surface', () => { it('uses the default flux client when no client is provided', async () => { resetFluxClient() @@ -34,9 +52,10 @@ describe('@holo-js/flux-react package surface', () => { return null } - let renderer: ReturnType | undefined + const { act, create } = await loadRenderer() + let renderer: Renderer | undefined await act(async () => { - renderer = create(createElement(Probe)) + renderer = create(renderElement(Probe)) }) expect(debug.getJoinedChannels()).toContain('public:feed.default') @@ -75,9 +94,10 @@ describe('@holo-js/flux-react package surface', () => { return null } - let renderer: ReturnType | undefined + const { act, create } = await loadRenderer() + let renderer: Renderer | undefined await act(async () => { - renderer = create(createElement(Probe)) + renderer = create(renderElement(Probe)) }) expect(controls).toEqual({ @@ -134,14 +154,15 @@ describe('@holo-js/flux-react package surface', () => { return null } - let renderer: ReturnType | undefined + const { act, create } = await loadRenderer() + let renderer: Renderer | undefined await act(async () => { - renderer = create(createElement(Probe)) + renderer = create(renderElement(Probe)) }) expect(subscribeCalls).toBe(1) await act(async () => { - renderer!.update(createElement(Probe)) + renderer!.update(renderElement(Probe)) }) expect(subscribeCalls).toBe(1) @@ -160,14 +181,14 @@ describe('@holo-js/flux-react package surface', () => { const statusHandler = vi.fn((status: string) => { statusChanges.push(status) }) - const presenceSnapshots: readonly unknown[][] = [] + const presenceSnapshots: Array = [] const statusSnapshots: string[] = [] const bareStatusSnapshots: string[] = [] let emptyPresence: ReturnType | undefined let presenceControls: ReturnType> | undefined function Probe() { - const presence = useFluxPresence('chat.1', { + const presence = useFluxPresence<{ id: string }>('chat.1', { onHere(members) { here.push(members) }, @@ -195,9 +216,10 @@ describe('@holo-js/flux-react package surface', () => { return null } - let renderer: ReturnType | undefined + const { act, create } = await loadRenderer() + let renderer: Renderer | undefined await act(async () => { - renderer = create(createElement(Probe)) + renderer = create(renderElement(Probe)) }) expect(here).toEqual([[]]) @@ -259,9 +281,10 @@ describe('@holo-js/flux-react package surface', () => { return null } - let renderer: ReturnType | undefined + const { act, create } = await loadRenderer() + let renderer: Renderer | undefined await act(async () => { - renderer = create(createElement(Probe)) + renderer = create(renderElement(Probe)) }) expect(unmounts.length).toBeGreaterThanOrEqual(4) diff --git a/packages/flux-react/tests/package.type.test.ts b/packages/flux-react/tests/package.type.test.ts index dafa13c..220ec63 100644 --- a/packages/flux-react/tests/package.type.test.ts +++ b/packages/flux-react/tests/package.type.test.ts @@ -1,8 +1,7 @@ -import { createElement } from 'react' -import { act, create } from 'react-test-renderer' import { describe, it, expectTypeOf } from 'vitest' import type { FluxConnectionStatus } from '@holo-js/flux' import { createFluxClient } from '@holo-js/flux' +import type { GeneratedBroadcastManifest } from '@holo-js/broadcast' import { useFlux, useFluxConnectionStatus, @@ -12,45 +11,47 @@ import { } from '../src' describe('@holo-js/flux-react typing', () => { - it('supports single and multi-event typed helper usage', async () => { - const client = createFluxClient({ - manifest: { - version: 1, - generatedAt: '2026-01-01T00:00:00.000Z', - events: [{ - name: 'orders.updated', - channels: [{ - type: 'private', - pattern: 'orders.{orderId}', - }], - }, { - name: 'orders.shipped', - channels: [{ - type: 'private', - pattern: 'orders.{orderId}', - }], - }], + it('supports single and multi-event typed helper usage', () => { + const manifest: GeneratedBroadcastManifest = { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + events: [{ + name: 'orders.updated', channels: [{ - name: 'orders.{orderId}', + type: 'private', pattern: 'orders.{orderId}', + }], + }, { + name: 'orders.shipped', + channels: [{ type: 'private', - params: ['orderId'], - whispers: ['typing.start'], + pattern: 'orders.{orderId}', }], - }, + }], + channels: [{ + name: 'orders.{orderId}', + pattern: 'orders.{orderId}', + type: 'private', + params: ['orderId'], + whispers: ['typing.start'], + }], + } + + const client = createFluxClient({ + manifest, }) - function Probe() { + if (false) { const generic = useFlux('orders.1', 'orders.updated', payload => { - expectTypeOf(payload).toEqualTypeOf>() + expectTypeOf(payload).toExtend>() }, { client }) const genericMany = useFlux('orders.1', ['orders.updated', 'orders.shipped'], payload => { - expectTypeOf(payload).toEqualTypeOf>() + expectTypeOf(payload).toExtend>() }, { client }) const pub = useFluxPublic('feed.1', 'orders.updated', payload => { - expectTypeOf(payload).toEqualTypeOf>() + expectTypeOf(payload).toExtend>() }, { client }) const priv = useFluxPrivate('orders.1', 'orders.shipped', payload => { - expectTypeOf(payload).toEqualTypeOf>() + expectTypeOf(payload).toExtend>() }, { client }) const presence = useFluxPresence<{ id: string }>('chat.1', {}, { client }) const status = useFluxConnectionStatus({ client }) @@ -61,12 +62,6 @@ describe('@holo-js/flux-react typing', () => { void genericMany void pub void priv - return null } - - await act(async () => { - const renderer = create(createElement(Probe)) - renderer.unmount() - }) }) }) diff --git a/packages/flux-svelte/tests/package.type.test.ts b/packages/flux-svelte/tests/package.type.test.ts index 0cfacbf..0e3df78 100644 --- a/packages/flux-svelte/tests/package.type.test.ts +++ b/packages/flux-svelte/tests/package.type.test.ts @@ -2,6 +2,7 @@ import { describe, expectTypeOf, it } from 'vitest' import type { Readable } from 'svelte/store' import type { FluxConnectionStatus } from '@holo-js/flux' import { createFluxClient, fluxInternals } from '@holo-js/flux' +import type { GeneratedBroadcastManifest } from '@holo-js/broadcast' import { useFlux, useFluxConnectionStatus, @@ -12,45 +13,47 @@ import { describe('@holo-js/flux-svelte typing', () => { it('supports single and multi-event typed helper usage', () => { - const client = createFluxClient({ - manifest: { - version: 1, - generatedAt: '2026-01-01T00:00:00.000Z', - events: [{ - name: 'orders.updated', - channels: [{ - type: 'private', - pattern: 'orders.{orderId}', - }], - }, { - name: 'orders.shipped', - channels: [{ - type: 'private', - pattern: 'orders.{orderId}', - }], - }], + const manifest: GeneratedBroadcastManifest = { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + events: [{ + name: 'orders.updated', channels: [{ - name: 'orders.{orderId}', + type: 'private', pattern: 'orders.{orderId}', + }], + }, { + name: 'orders.shipped', + channels: [{ type: 'private', - params: ['orderId'], - whispers: ['typing.start'], + pattern: 'orders.{orderId}', }], - }, + }], + channels: [{ + name: 'orders.{orderId}', + pattern: 'orders.{orderId}', + type: 'private', + params: ['orderId'], + whispers: ['typing.start'], + }], + } + + const client = createFluxClient({ + manifest, connector: fluxInternals.createPusherConnector({ transport: 'mock' }), }) const generic = useFlux('orders.1', 'orders.updated', payload => { - expectTypeOf(payload).toEqualTypeOf>() + expectTypeOf(payload).toMatchTypeOf>() }, { client }) const genericMany = useFlux('orders.1', ['orders.updated', 'orders.shipped'], payload => { - expectTypeOf(payload).toEqualTypeOf>() + expectTypeOf(payload).toMatchTypeOf>() }, { client }) const pub = useFluxPublic('feed.1', 'orders.updated', payload => { - expectTypeOf(payload).toEqualTypeOf>() + expectTypeOf(payload).toMatchTypeOf>() }, { client }) const priv = useFluxPrivate('orders.1', 'orders.shipped', payload => { - expectTypeOf(payload).toEqualTypeOf>() + expectTypeOf(payload).toMatchTypeOf>() }, { client }) const presence = useFluxPresence<{ id: string }>('chat.1', {}, { client }) const status = useFluxConnectionStatus({ client }) diff --git a/packages/flux-vue/tests/package.type.test.ts b/packages/flux-vue/tests/package.type.test.ts index c5ed056..a9445ad 100644 --- a/packages/flux-vue/tests/package.type.test.ts +++ b/packages/flux-vue/tests/package.type.test.ts @@ -1,7 +1,7 @@ import { describe, expectTypeOf, it } from 'vitest' -import type { Ref } from 'vue' import type { FluxConnectionStatus } from '@holo-js/flux' import { createFluxClient, fluxInternals } from '@holo-js/flux' +import type { GeneratedBroadcastManifest } from '@holo-js/broadcast' import { useFlux, useFluxConnectionStatus, @@ -12,50 +12,52 @@ import { describe('@holo-js/flux-vue typing', () => { it('supports single and multi-event typed helper usage', () => { - const client = createFluxClient({ - manifest: { - version: 1, - generatedAt: '2026-01-01T00:00:00.000Z', - events: [{ - name: 'orders.updated', - channels: [{ - type: 'private', - pattern: 'orders.{orderId}', - }], - }, { - name: 'orders.shipped', - channels: [{ - type: 'private', - pattern: 'orders.{orderId}', - }], - }], + const manifest: GeneratedBroadcastManifest = { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + events: [{ + name: 'orders.updated', channels: [{ - name: 'orders.{orderId}', + type: 'private', pattern: 'orders.{orderId}', + }], + }, { + name: 'orders.shipped', + channels: [{ type: 'private', - params: ['orderId'], - whispers: ['typing.start'], + pattern: 'orders.{orderId}', }], - }, + }], + channels: [{ + name: 'orders.{orderId}', + pattern: 'orders.{orderId}', + type: 'private', + params: ['orderId'], + whispers: ['typing.start'], + }], + } + + const client = createFluxClient({ + manifest, connector: fluxInternals.createPusherConnector({ transport: 'mock' }), }) const generic = useFlux('orders.1', 'orders.updated', payload => { - expectTypeOf(payload).toEqualTypeOf>() + expectTypeOf(payload).toExtend>() }, { client }) const genericMany = useFlux('orders.1', ['orders.updated', 'orders.shipped'], payload => { - expectTypeOf(payload).toEqualTypeOf>() + expectTypeOf(payload).toExtend>() }, { client }) const pub = useFluxPublic('feed.1', 'orders.updated', payload => { - expectTypeOf(payload).toEqualTypeOf>() + expectTypeOf(payload).toExtend>() }, { client }) const priv = useFluxPrivate('orders.1', 'orders.shipped', payload => { - expectTypeOf(payload).toEqualTypeOf>() + expectTypeOf(payload).toExtend>() }, { client }) const presence = useFluxPresence<{ id: string }>('chat.1', {}, { client }) const status = useFluxConnectionStatus({ client }) expectTypeOf(presence.members).toEqualTypeOf() - expectTypeOf(status).toEqualTypeOf>>() + expectTypeOf(status).toExtend<{ readonly value: FluxConnectionStatus }>() void generic void genericMany diff --git a/packages/flux/tests/package.test.ts b/packages/flux/tests/package.test.ts index afa50ad..a02e93c 100644 --- a/packages/flux/tests/package.test.ts +++ b/packages/flux/tests/package.test.ts @@ -78,10 +78,10 @@ describe('@holo-js/flux package surface', () => { publicSubscription.notification((payload) => { receivedNotifications.push(payload) }) - publicSubscription.listenForWhisper('typing.start', (payload) => { + publicSubscription.listenForWhisper('typing.start' as never, (payload) => { receivedWhispers.push(payload) }) - publicSubscription.listenForWhisper('typing.start', (payload) => { + publicSubscription.listenForWhisper('typing.start' as never, (payload) => { receivedWhispers.push({ duplicate: payload }) }) expect(() => publicSubscription.listen(' ' as never, () => undefined)).toThrow('must be a non-empty string') @@ -89,7 +89,7 @@ describe('@holo-js/flux package surface', () => { debug!.emitEvent('orders.1', 'orders.updated', { id: 'ord_1' }) debug!.emitNotification('orders.1', { type: 'OrderUpdated' }) - await publicSubscription.whisper('typing.start', { editing: true }) + await publicSubscription.whisper('typing.start' as never, { editing: true }) expect(receivedEvents).toEqual([{ id: 'ord_1' }]) expect(receivedNotifications).toEqual([{ type: 'OrderUpdated' }]) expect(receivedWhispers).toEqual([ @@ -108,7 +108,7 @@ describe('@holo-js/flux package surface', () => { debug!.updatePresenceMembers('chat.1', [{ id: 'user_1' }, { id: 'user_2' }]) expect(presenceSubscription.members).toEqual([{ id: 'user_1' }, { id: 'user_2' }]) - await privateSubscription.whisper('typing.start', { editing: false }) + await privateSubscription.whisper('typing.start' as never, { editing: false }) privateSubscription.leave() publicSubscription.leaveChannel() presenceSubscription.leaveChannel() @@ -164,13 +164,13 @@ describe('@holo-js/flux package surface', () => { connector: fluxInternals.createPusherConnector({ transport: 'mock' }), }) const debug = (client as unknown as { __debug?: ReturnType['__debug'] }).__debug - const presenceSubscription = client.presence('chat.2') as typeof client extends never + const presenceSubscription = client.presence('chat.2') as unknown as typeof client extends never ? never : { __onPresenceChange(callback: (members: readonly BroadcastJsonObject[]) => void): () => void leaveChannel(): void } - const seen: readonly BroadcastJsonObject[][] = [] + const seen: Array = [] const stop = presenceSubscription.__onPresenceChange((members) => { seen.push(members) diff --git a/packages/mail/tests/contracts.test.ts b/packages/mail/tests/contracts.test.ts index 5c5af0a..f0eab9b 100644 --- a/packages/mail/tests/contracts.test.ts +++ b/packages/mail/tests/contracts.test.ts @@ -390,7 +390,7 @@ describe('@holo-js/mail contracts', () => { expect(() => mailInternals.normalizeAttachment({ resolve: 'bad' as never, }, 0)).toThrow('resolve attachments must define resolve()') - expect(() => mailInternals.normalizeAttachment({}, 0)).toThrow('must define path, storage, content, or resolve') + expect(() => mailInternals.normalizeAttachment({} as never, 0)).toThrow('must define path, storage, content, or resolve') expect(() => mailInternals.resolveNormalizedAttachment({ resolve: async () => ({ content: 'hello', diff --git a/packages/mail/tests/index.type.test.ts b/packages/mail/tests/index.type.test.ts index 83c662f..2e1b390 100644 --- a/packages/mail/tests/index.type.test.ts +++ b/packages/mail/tests/index.type.test.ts @@ -45,7 +45,7 @@ describe('@holo-js/mail root export typing', () => { const preview: Promise = 0 as unknown as Promise const renderedPreview: Promise = 0 as unknown as Promise const fromDefault: typeof mail.sendMail = mail.sendMail - const defaultMailer: 'preview' = config.default + const defaultMailer: string = config.default if (false) { void previewMail(definition) diff --git a/packages/mail/tests/runtime.test.ts b/packages/mail/tests/runtime.test.ts index 0fabfe9..46d5776 100644 --- a/packages/mail/tests/runtime.test.ts +++ b/packages/mail/tests/runtime.test.ts @@ -19,11 +19,41 @@ import { resetPreviewMailArtifacts, resetMailRuntime, sendMail, + type MailDriver, + type MailPreviewInput, + type MailPreviewResult, + type MailSendResult, + type NormalizedHoloMailConfig, } from '../src' const previousAppEnv = process.env.APP_ENV const previousNodeEnv = process.env.NODE_ENV +type NormalizedMailMailerConfig = NormalizedHoloMailConfig['mailers'][string] +type BuiltInDriverName = 'preview' | 'fake' | 'log' | 'smtp' + +function getMailerConfig( + config: NormalizedHoloMailConfig, + mailer: string, + driver: TDriver, +): Extract { + const mailerConfig = config.mailers[mailer] + if (!mailerConfig || mailerConfig.driver !== driver) { + throw new Error(`Expected mailer "${mailer}" to use driver "${driver}".`) + } + + return mailerConfig as Extract +} + +function getBuiltInDriver(name: BuiltInDriverName): MailDriver { + const driver = mailRuntimeInternals.builtInDrivers[name] + if (!driver) { + throw new Error(`Expected built-in mail driver "${name}" to be registered.`) + } + + return driver +} + function createQueueModuleStub(options: { readonly autoRun?: boolean } = {}) { const jobs = new Map | unknown }>() const dispatches: Array<{ @@ -172,13 +202,13 @@ afterEach(() => { resetMailDriverRegistry() resetMailRuntime() if (typeof previousAppEnv === 'string') { - process.env.APP_ENV = previousAppEnv + Reflect.set(process.env, 'APP_ENV', previousAppEnv) } else { Reflect.deleteProperty(process.env, 'APP_ENV') } if (typeof previousNodeEnv === 'string') { - process.env.NODE_ENV = previousNodeEnv + Reflect.set(process.env, 'NODE_ENV', previousNodeEnv) } else { Reflect.deleteProperty(process.env, 'NODE_ENV') } @@ -637,18 +667,20 @@ describe('@holo-js/mail runtime', () => { }) it('sends through the built-in preview and fake drivers and stores runtime records', async () => { + const resolvedConfig = mailRuntimeInternals.getResolvedConfig() + configureMailRuntime({ config: { - ...mailRuntimeInternals.getResolvedConfig(), + ...resolvedConfig, default: 'preview', from: { email: 'config@example.com' }, mailers: { - ...mailRuntimeInternals.getResolvedConfig().mailers, + ...resolvedConfig.mailers, preview: { - ...mailRuntimeInternals.getResolvedConfig().mailers.preview, + ...getMailerConfig(resolvedConfig, 'preview', 'preview'), }, fake: { - ...mailRuntimeInternals.getResolvedConfig().mailers.fake, + ...getMailerConfig(resolvedConfig, 'fake', 'fake'), }, }, }, @@ -713,15 +745,17 @@ describe('@holo-js/mail runtime', () => { const previewRoot = await mkdtemp(join(tmpdir(), 'holo-mail-preview-')) try { + const resolvedConfig = mailRuntimeInternals.getResolvedConfig() + configureMailRuntime({ config: { - ...mailRuntimeInternals.getResolvedConfig(), + ...resolvedConfig, default: 'preview', from: { email: 'config@example.com' }, mailers: { - ...mailRuntimeInternals.getResolvedConfig().mailers, + ...resolvedConfig.mailers, preview: { - ...mailRuntimeInternals.getResolvedConfig().mailers.preview, + ...getMailerConfig(resolvedConfig, 'preview', 'preview'), path: previewRoot, }, }, @@ -769,17 +803,19 @@ describe('@holo-js/mail runtime', () => { const previewRoot = await mkdtemp(join(tmpdir(), 'holo-mail-preview-')) try { + const resolvedConfig = mailRuntimeInternals.getResolvedConfig() + configureMailRuntime({ config: { - ...mailRuntimeInternals.getResolvedConfig(), + ...resolvedConfig, from: { email: 'config@example.com' }, mailers: { - ...mailRuntimeInternals.getResolvedConfig().mailers, + ...resolvedConfig.mailers, fake: { - ...mailRuntimeInternals.getResolvedConfig().mailers.fake, + ...getMailerConfig(resolvedConfig, 'fake', 'fake'), }, preview: { - ...mailRuntimeInternals.getResolvedConfig().mailers.preview, + ...getMailerConfig(resolvedConfig, 'preview', 'preview'), path: previewRoot, }, }, @@ -823,23 +859,24 @@ describe('@holo-js/mail runtime', () => { it('queues built-in preview, fake, and log drivers and only captures on execution', async () => { const queue = createQueueModuleStub({ autoRun: false }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const resolvedConfig = mailRuntimeInternals.getResolvedConfig() mailRuntimeInternals.setQueueModuleLoader(async () => queue.module) configureMailRuntime({ config: { - ...mailRuntimeInternals.getResolvedConfig(), + ...resolvedConfig, from: { email: 'config@example.com' }, default: 'preview', mailers: { - ...mailRuntimeInternals.getResolvedConfig().mailers, + ...resolvedConfig.mailers, preview: { - ...mailRuntimeInternals.getResolvedConfig().mailers.preview, + ...getMailerConfig(resolvedConfig, 'preview', 'preview'), }, fake: { - ...mailRuntimeInternals.getResolvedConfig().mailers.fake, + ...getMailerConfig(resolvedConfig, 'fake', 'fake'), }, log: { - ...mailRuntimeInternals.getResolvedConfig().mailers.log, + ...getMailerConfig(resolvedConfig, 'log', 'log'), logBodies: false, }, }, @@ -892,19 +929,20 @@ describe('@holo-js/mail runtime', () => { }, }) const storage = createStorageModuleStub() + const resolvedConfig = mailRuntimeInternals.getResolvedConfig() mailRuntimeInternals.setNodemailerModuleLoader(async () => nodemailer.module) mailRuntimeInternals.setStorageModuleLoader(async () => storage.module) configureMailRuntime({ config: { - ...mailRuntimeInternals.getResolvedConfig(), + ...resolvedConfig, default: 'smtp', from: { email: 'config@example.com', name: 'Config Sender' }, replyTo: { email: 'reply@example.com', name: 'Reply Team' }, mailers: { - ...mailRuntimeInternals.getResolvedConfig().mailers, + ...resolvedConfig.mailers, smtp: { - ...mailRuntimeInternals.getResolvedConfig().mailers.smtp, + ...getMailerConfig(resolvedConfig, 'smtp', 'smtp'), from: { email: 'mailer@example.com', name: 'Mailer Sender' }, replyTo: { email: 'mailer-reply@example.com', name: 'Mailer Reply' }, host: 'smtp.internal', @@ -1098,15 +1136,16 @@ describe('@holo-js/mail runtime', () => { }, }, })) + const resolvedConfig = mailRuntimeInternals.getResolvedConfig() configureMailRuntime({ config: { - ...mailRuntimeInternals.getResolvedConfig(), + ...resolvedConfig, default: 'fake', from: { email: 'config@example.com' }, mailers: { - ...mailRuntimeInternals.getResolvedConfig().mailers, + ...resolvedConfig.mailers, fake: { - ...mailRuntimeInternals.getResolvedConfig().mailers.fake, + ...getMailerConfig(resolvedConfig, 'fake', 'fake'), }, }, }, @@ -1398,11 +1437,12 @@ describe('@holo-js/mail runtime', () => { queue: 'mail-only', }, }) + const resolvedConfig = mailRuntimeInternals.getResolvedConfig() const queueConfig = { - ...mailRuntimeInternals.getResolvedConfig(), + ...resolvedConfig, mailers: { preview: { - ...mailRuntimeInternals.getResolvedConfig().mailers.preview, + ...getMailerConfig(resolvedConfig, 'preview', 'preview'), queue: { queued: true, connection: 'redis', @@ -1467,13 +1507,17 @@ describe('@holo-js/mail runtime', () => { const context = mailRuntimeInternals.createSendContext('mail-1', { mailer: 'preview', driver: 'preview', - implementation: mailRuntimeInternals.builtInDrivers.preview, + implementation: getBuiltInDriver('preview'), }, false) expect(mailRuntimeInternals.normalizeDriverResult(undefined, context)).toMatchObject({ messageId: 'mail-1', queued: false, }) expect(mailRuntimeInternals.normalizeDriverResult({ + messageId: 'mail-1', + mailer: 'preview', + driver: 'preview', + queued: false, providerMessageId: 'provider-1', provider: { response: '250 accepted', @@ -1568,7 +1612,7 @@ describe('@holo-js/mail runtime', () => { mailRuntimeInternals.createSendContext('mail-2', { mailer: 'preview', driver: 'smtp', - implementation: mailRuntimeInternals.builtInDrivers.smtp, + implementation: getBuiltInDriver('smtp'), }, false), )).rejects.toMatchObject({ code: 'MAIL_SMTP_MAILER_INVALID', @@ -1579,15 +1623,16 @@ describe('@holo-js/mail runtime', () => { }, }) mailRuntimeInternals.setNodemailerModuleLoader(async () => nodemailerWithoutResponse.module) + const smtpConfigWithoutResponse = mailRuntimeInternals.getResolvedConfig() configureMailRuntime({ config: { - ...mailRuntimeInternals.getResolvedConfig(), + ...smtpConfigWithoutResponse, default: 'smtp', from: { email: 'config@example.com' }, mailers: { - ...mailRuntimeInternals.getResolvedConfig().mailers, + ...smtpConfigWithoutResponse.mailers, smtp: { - ...mailRuntimeInternals.getResolvedConfig().mailers.smtp, + ...getMailerConfig(smtpConfigWithoutResponse, 'smtp', 'smtp'), host: 'smtp.example.com', port: 587, secure: false, @@ -1604,7 +1649,7 @@ describe('@holo-js/mail runtime', () => { } as never, mailRuntimeInternals.createSendContext('mail-3a', { mailer: 'smtp', driver: 'smtp', - implementation: mailRuntimeInternals.builtInDrivers.smtp, + implementation: getBuiltInDriver('smtp'), }, false))).toMatchObject({ html: '

Hello

', }) @@ -1618,7 +1663,7 @@ describe('@holo-js/mail runtime', () => { mailRuntimeInternals.createSendContext('mail-3', { mailer: 'smtp', driver: 'smtp', - implementation: mailRuntimeInternals.builtInDrivers.smtp, + implementation: getBuiltInDriver('smtp'), }, false), )).resolves.toMatchObject({ providerMessageId: 'provider-only-id', @@ -1629,15 +1674,16 @@ describe('@holo-js/mail runtime', () => { }, }) mailRuntimeInternals.setNodemailerModuleLoader(async () => nodemailerWithoutMessageId.module) + const smtpConfigWithoutMessageId = mailRuntimeInternals.getResolvedConfig() configureMailRuntime({ config: { - ...mailRuntimeInternals.getResolvedConfig(), + ...smtpConfigWithoutMessageId, default: 'smtp', from: { email: 'config@example.com' }, mailers: { - ...mailRuntimeInternals.getResolvedConfig().mailers, + ...smtpConfigWithoutMessageId.mailers, smtp: { - ...mailRuntimeInternals.getResolvedConfig().mailers.smtp, + ...getMailerConfig(smtpConfigWithoutMessageId, 'smtp', 'smtp'), host: 'smtp.example.com', port: 587, secure: false, @@ -1656,7 +1702,7 @@ describe('@holo-js/mail runtime', () => { mailRuntimeInternals.createSendContext('mail-3ab', { mailer: 'smtp', driver: 'smtp', - implementation: mailRuntimeInternals.builtInDrivers.smtp, + implementation: getBuiltInDriver('smtp'), }, false), )).resolves.toMatchObject({ provider: { @@ -1670,15 +1716,16 @@ describe('@holo-js/mail runtime', () => { }, }) mailRuntimeInternals.setNodemailerModuleLoader(async () => nodemailerWithResponse.module) + const smtpConfigWithResponse = mailRuntimeInternals.getResolvedConfig() configureMailRuntime({ config: { - ...mailRuntimeInternals.getResolvedConfig(), + ...smtpConfigWithResponse, default: 'smtp', from: { email: 'config@example.com' }, mailers: { - ...mailRuntimeInternals.getResolvedConfig().mailers, + ...smtpConfigWithResponse.mailers, smtp: { - ...mailRuntimeInternals.getResolvedConfig().mailers.smtp, + ...getMailerConfig(smtpConfigWithResponse, 'smtp', 'smtp'), host: 'smtp.example.com', port: 587, secure: false, @@ -1698,7 +1745,7 @@ describe('@holo-js/mail runtime', () => { mailRuntimeInternals.createSendContext('mail-3b', { mailer: 'smtp', driver: 'smtp', - implementation: mailRuntimeInternals.builtInDrivers.smtp, + implementation: getBuiltInDriver('smtp'), }, false), )).resolves.toMatchObject({ providerMessageId: 'provider-response-id', @@ -1709,7 +1756,7 @@ describe('@holo-js/mail runtime', () => { mailRuntimeInternals.setNodemailerModuleLoader(undefined) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - expect(mailRuntimeInternals.builtInDrivers.log.send({ + expect(getBuiltInDriver('log').send({ ...smtpResolvedMail, tags: ['transactional'], priority: 'high', @@ -1724,7 +1771,7 @@ describe('@holo-js/mail runtime', () => { } as never, mailRuntimeInternals.createSendContext('mail-log', { mailer: 'preview', driver: 'log', - implementation: mailRuntimeInternals.builtInDrivers.log, + implementation: getBuiltInDriver('log'), }, false))).toMatchObject({ driver: 'log', }) @@ -1769,7 +1816,12 @@ describe('@holo-js/mail runtime', () => { }) registerMailDriver('resolved-driver', { async send() { - return undefined + return { + messageId: 'resolved-driver', + mailer: 'custom-mailer', + driver: 'resolved-driver', + queued: false, + } }, }, { replaceExisting: true, @@ -1970,14 +2022,15 @@ describe('@holo-js/mail runtime', () => { }, }) + const missingFromConfig = mailRuntimeInternals.getResolvedConfig() configureMailRuntime({ config: { - ...mailRuntimeInternals.getResolvedConfig(), + ...missingFromConfig, from: undefined, mailers: { - ...mailRuntimeInternals.getResolvedConfig().mailers, + ...missingFromConfig.mailers, preview: { - ...mailRuntimeInternals.getResolvedConfig().mailers.preview, + ...getMailerConfig(missingFromConfig, 'preview', 'preview'), from: undefined, replyTo: undefined, }, @@ -1998,12 +2051,10 @@ describe('@holo-js/mail runtime', () => { allowedEnvironments: ['test'], }, }, - preview: vi.fn(async ({ mail }) => ({ - messageId: 'preview-override', + preview: vi.fn(async ({ mail }: MailPreviewInput): Promise => ({ source: { kind: 'text', }, - mailer: 'preview', from: { email: 'config@example.com' }, replyTo: { email: 'config@example.com' }, to: mail.to, @@ -2012,6 +2063,8 @@ describe('@holo-js/mail runtime', () => { subject: mail.subject, text: 'override preview', attachments: [], + headers: {}, + tags: [], metadata: {}, })), renderPreview: vi.fn(async () => new Response('override render')), @@ -2104,16 +2157,17 @@ describe('@holo-js/mail runtime', () => { it('logs summary output by default and includes bodies only when verbose log mode is enabled', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const resolvedConfig = mailRuntimeInternals.getResolvedConfig() configureMailRuntime({ config: { - ...mailRuntimeInternals.getResolvedConfig(), + ...resolvedConfig, default: 'log', from: { email: 'config@example.com' }, mailers: { - ...mailRuntimeInternals.getResolvedConfig().mailers, + ...resolvedConfig.mailers, log: { - ...mailRuntimeInternals.getResolvedConfig().mailers.log, + ...getMailerConfig(resolvedConfig, 'log', 'log'), logBodies: false, }, verbose: { @@ -2158,7 +2212,11 @@ describe('@holo-js/mail runtime', () => { }) it('uses registered custom drivers and wraps thrown driver errors', async () => { - const customSend = vi.fn(async () => ({ + const customSend = vi.fn(async () => ({ + messageId: 'custom-send', + mailer: 'transactional', + driver: 'resend', + queued: false, providerMessageId: 'provider-1', provider: { region: 'us', diff --git a/packages/media/tests/media.test.ts b/packages/media/tests/media.test.ts index dc79498..f17818e 100644 --- a/packages/media/tests/media.test.ts +++ b/packages/media/tests/media.test.ts @@ -1575,14 +1575,15 @@ describe('@holo-js/media', () => { it('supports remote uploads and selective regeneration', async () => { vi.stubGlobal('fetch', vi.fn(async (input?: string | URL | Request) => { const url = String(input) + const imageBytes = new Uint8Array(await createImageBuffer()) if (url.includes('no-header')) { - return new Response(await createImageBuffer(), { + return new Response(imageBytes, { status: 200, }) } - return new Response(await createImageBuffer(), { + return new Response(imageBytes, { status: 200, headers: { 'content-type': 'image/jpeg', diff --git a/packages/notifications/tests/contracts.test.ts b/packages/notifications/tests/contracts.test.ts index 03b06a4..deb503b 100644 --- a/packages/notifications/tests/contracts.test.ts +++ b/packages/notifications/tests/contracts.test.ts @@ -56,7 +56,7 @@ describe('@holo-js/notifications contracts', () => { it('rejects malformed definitions, delays, and queue options', () => { expect(() => defineNotification({ via() { - return ['email'] as const + return ['email'] as never }, build: {}, })).toThrow('must define at least one channel payload builder') diff --git a/packages/notifications/tests/contracts.type.test.ts b/packages/notifications/tests/contracts.type.test.ts index fccadd7..a4864c3 100644 --- a/packages/notifications/tests/contracts.type.test.ts +++ b/packages/notifications/tests/contracts.type.test.ts @@ -67,15 +67,10 @@ describe('@holo-js/notifications typing', () => { }) type RegisteredNotifiable = InferNotificationNotifiable - type RegisteredChannels = InferNotificationChannels type NotifiableAssertion = Expect> - type ChannelsAssertion = Expect> const pending = notify({ id: 'user-1', @@ -87,13 +82,14 @@ describe('@holo-js/notifications typing', () => { .channel('slack', { webhook: 'https://hooks.slack.test' }) type RoutedTarget = typeof routed.target - type RoutedAssertion = Expect + readonly slack: SlackRoute }> - >> + ? true + : false + > const channelName: ChannelNames = 'slack' const route: SlackRoute = { webhook: 'https://hooks.slack.test' } @@ -115,7 +111,6 @@ describe('@holo-js/notifications typing', () => { void (0 as unknown as RouteAssertion) void (0 as unknown as PayloadAssertion) void (0 as unknown as NotifiableAssertion) - void (0 as unknown as ChannelsAssertion) void (0 as unknown as RoutedAssertion) }) }) diff --git a/packages/notifications/tests/index.type.test.ts b/packages/notifications/tests/index.type.test.ts index b5644b0..5da4eb1 100644 --- a/packages/notifications/tests/index.type.test.ts +++ b/packages/notifications/tests/index.type.test.ts @@ -61,20 +61,21 @@ describe('@holo-js/notifications root export typing', () => { typeof pending, PendingNotificationDispatch >> - type AnonymousAssertion = Expect - >> + ? true + : false + > type ResultAssertion = Expect, void >> const fromDefault: typeof notifications.notify = notifications.notify - const table: 'notifications' = config.table + const table: string = config.table void pending void anonymous diff --git a/packages/notifications/tests/runtime.test.ts b/packages/notifications/tests/runtime.test.ts index 9601f3c..9feae51 100644 --- a/packages/notifications/tests/runtime.test.ts +++ b/packages/notifications/tests/runtime.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { configureNotificationsRuntime, + defineNotification, getNotificationsRuntimeBindings, getNotificationsRuntime, getRegisteredNotificationChannel, @@ -17,9 +18,35 @@ import { registerNotificationChannel, resetNotificationChannelRegistry, resetNotificationsRuntime, + type NotificationChannel, + type NotificationBuildFactories, + type NotificationDefinition, } from '../src' -const invoicePaid = { +type InvoicePaidNotifiable = { + readonly id?: string + readonly type?: string + readonly email: string + readonly name?: string + readonly routeNotificationForBroadcast?: () => readonly string[] +} + +declare module '../src/contracts' { + interface HoloNotificationChannelRegistry { + readonly slack: NotificationChannel<{ readonly webhook: string }, { readonly text: string }, void> + } +} + +function asRuntimeNotification>( + notification: NotificationDefinition, +): NotificationDefinition> { + return notification as unknown as NotificationDefinition> +} + +const invoicePaidDefinition: NotificationDefinition< + InvoicePaidNotifiable, + NotificationBuildFactories +> = { type: 'invoice-paid', via() { return ['email', 'database', 'broadcast'] as const @@ -46,7 +73,9 @@ const invoicePaid = { } }, }, -} as const +} + +const invoicePaid = defineNotification(invoicePaidDefinition) function createQueueModuleStub() { const jobs = new Map | unknown }>() @@ -245,7 +274,7 @@ describe('@holo-js/notifications runtime', () => { .channel('email', { email: 'ava@example.com', name: 'Ava' }) .channel('database', { id: 'user-1', type: 'users' }) .channel('broadcast', { channels: ['private-users.user-1'] }) - .notify(invoicePaid) + .notify(asRuntimeNotification(invoicePaid)) expect(result.channels).toHaveLength(3) expect(mailer.send).toHaveBeenCalledWith({ @@ -343,7 +372,7 @@ describe('@holo-js/notifications runtime', () => { await expect(notifyUsing() .channel('email', { email: 'ava@example.com' }) - .notify(invoicePaid)).resolves.toMatchObject({ + .notify(asRuntimeNotification(invoicePaid))).resolves.toMatchObject({ totalTargets: 1, }) @@ -384,7 +413,7 @@ describe('@holo-js/notifications runtime', () => { const invalidDatabaseRoute = await notifyUsing() .channel('email', { email: 'ava@example.com' }) .channel('database', { id: 'user-1', type: ' ' } as never) - .notify(invoicePaid) + .notify(asRuntimeNotification(invoicePaid)) expect((invalidDatabaseRoute.channels[1] as { error: Error }).error.message) .toContain('Database routes must include a string or numeric id and a non-empty type') @@ -479,7 +508,7 @@ describe('@holo-js/notifications runtime', () => { await expect(notify({ email: 'ava@example.com', - }, { + } as never, { via() { return ['sms'] as const }, @@ -525,12 +554,15 @@ describe('@holo-js/notifications runtime', () => { }, }) - const queuedInvoicePaid = { + const queuedInvoicePaid: NotificationDefinition< + InvoicePaidNotifiable, + typeof invoicePaid.build + > = defineNotification({ type: 'invoice-paid', via() { return ['email', 'database', 'broadcast'] as const }, - queue(_notifiable: unknown, channel: string) { + queue(_notifiable: InvoicePaidNotifiable, channel: string) { if (channel === 'broadcast') { return { connection: 'notification-connection', @@ -540,7 +572,7 @@ describe('@holo-js/notifications runtime', () => { return true }, - delay(_notifiable: unknown, channel: string) { + delay(_notifiable: InvoicePaidNotifiable, channel: string) { if (channel === 'database') { return 10 } @@ -552,7 +584,7 @@ describe('@holo-js/notifications runtime', () => { return undefined }, build: invoicePaid.build, - } as const + }) const result = await notify({ id: 'user-1', @@ -864,7 +896,7 @@ describe('@holo-js/notifications runtime', () => { return undefined }, - }, { + } as never, { via() { return ['slack'] as const }, @@ -990,7 +1022,7 @@ describe('@holo-js/notifications runtime', () => { return undefined }, - }, { + } as never, { via() { return ['email', 'slack'] as const }, diff --git a/packages/queue-db/tests/database-driver.test.ts b/packages/queue-db/tests/database-driver.test.ts index 5e1790b..368b431 100644 --- a/packages/queue-db/tests/database-driver.test.ts +++ b/packages/queue-db/tests/database-driver.test.ts @@ -38,13 +38,19 @@ function createDialect(name: string, placeholderPrefix: '$' | '?'): Dialect { return { name, capabilities: { + returning: false, + lockForUpdate: false, + sharedLock: false, concurrentQueries: false, - jsonOperations: true, - lateralJoins: false, workerThreadExecution: false, - pessimisticLocking: false, savepoints: true, - vectorColumns: false, + jsonValueQuery: true, + jsonContains: true, + jsonLength: true, + schemaQualifiedIdentifiers: true, + nativeUpsert: false, + ddlAlterSupport: false, + introspection: false, }, quoteIdentifier(identifier: string) { return `"${identifier}"` @@ -444,7 +450,7 @@ describe('@holo-js/queue-db database driver', () => { }) it('reuses the active async-context connection when it matches the configured database connection', async () => { - const executeCompiled = vi.fn(async () => ({})) + const executeCompiled = vi.fn(async (_statement: unknown) => ({})) const initialize = vi.fn(async () => {}) const activeConnection = { async initialize() { diff --git a/packages/queue-db/tests/failed.test.ts b/packages/queue-db/tests/failed.test.ts index 05db946..840430f 100644 --- a/packages/queue-db/tests/failed.test.ts +++ b/packages/queue-db/tests/failed.test.ts @@ -34,13 +34,19 @@ function createDialect(name: string, placeholderPrefix: '$' | '?'): Dialect { return { name, capabilities: { + returning: false, + lockForUpdate: false, + sharedLock: false, concurrentQueries: false, - jsonOperations: true, - lateralJoins: false, workerThreadExecution: false, - pessimisticLocking: false, savepoints: true, - vectorColumns: false, + jsonValueQuery: true, + jsonContains: true, + jsonLength: true, + schemaQualifiedIdentifiers: true, + nativeUpsert: false, + ddlAlterSupport: false, + introspection: false, }, quoteIdentifier(identifier: string) { return `"${identifier}"` @@ -320,7 +326,7 @@ describe('@holo-js/queue-db failed job store', () => { }) it('reuses the active async-context connection for the failed-job store when names match', async () => { - const executeCompiled = vi.fn(async () => ({})) + const executeCompiled = vi.fn(async (_statement: unknown) => ({})) const activeConnection = { async initialize() {}, getConnectionName() { diff --git a/packages/storage-s3/src/index.ts b/packages/storage-s3/src/index.ts index 52980a6..8549b94 100644 --- a/packages/storage-s3/src/index.ts +++ b/packages/storage-s3/src/index.ts @@ -218,7 +218,7 @@ function createSignedRequest( return new Request(url.toString(), { method, headers, - body: payloadBytes, + body: payloadBytes ? new Blob([new Uint8Array(payloadBytes)]) : undefined, }) } diff --git a/packages/storage/tests/facade.test.ts b/packages/storage/tests/facade.test.ts index 08af93e..a9e6bdd 100644 --- a/packages/storage/tests/facade.test.ts +++ b/packages/storage/tests/facade.test.ts @@ -1,6 +1,7 @@ import { createHash, createHmac } from 'node:crypto' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { HoloStorageRuntimeConfig } from '../src' +import type * as StorageRuntimeModule from '../src/runtime/composables/index' type StoredValue = string | Uint8Array | ArrayBuffer | Buffer @@ -74,6 +75,18 @@ function encodeCanonicalUri(pathname: string): string { }) } +function restoreGlobalProperty( + property: 'useRuntimeConfig' | 'useStorage', + descriptor: PropertyDescriptor | undefined, +): void { + if (descriptor) { + Object.defineProperty(globalThis, property, descriptor) + return + } + + Reflect.deleteProperty(globalThis, property) +} + describe('Storage facade', () => { beforeEach(() => { resetStorageRuntime() @@ -282,7 +295,9 @@ describe('Storage facade', () => { }) it('shares explicit runtime bindings across isolated module instances', async () => { - const isolatedRuntime = await import('../src/runtime/composables/index.ts?isolated-storage-runtime') + const isolatedRuntime = await import( + '../src/runtime/composables/index.ts' + '?isolated-storage-runtime' + ) as typeof StorageRuntimeModule try { await expect(isolatedRuntime.useStorage('public').exists('avatars/user-1.txt')).resolves.toBe(false) @@ -316,43 +331,43 @@ describe('Storage facade', () => { it('falls back to runtime globals when explicit bindings are absent', async () => { const backend = createBackend('holo:public') - const runtimeGlobals = globalThis as typeof globalThis & { - useRuntimeConfig?: () => typeof runtimeConfig - useStorage?: () => MockStorageBackend - } - const previousUseRuntimeConfig = runtimeGlobals.useRuntimeConfig - const previousUseStorage = runtimeGlobals.useStorage + const previousUseRuntimeConfig = Object.getOwnPropertyDescriptor(globalThis, 'useRuntimeConfig') + const previousUseStorage = Object.getOwnPropertyDescriptor(globalThis, 'useStorage') - runtimeGlobals.useRuntimeConfig = () => runtimeConfig - runtimeGlobals.useStorage = () => backend + Object.defineProperty(globalThis, 'useRuntimeConfig', { + configurable: true, + writable: true, + value: () => runtimeConfig, + }) + Object.defineProperty(globalThis, 'useStorage', { + configurable: true, + writable: true, + value: () => backend, + }) resetStorageRuntime() try { await expect(useStorage('public').exists('avatars/user-1.txt')).resolves.toBe(false) expect(useStorage('public').url('avatars/user-1.txt')).toBe('https://app.test/storage/avatars/user-1.txt') } finally { - runtimeGlobals.useRuntimeConfig = previousUseRuntimeConfig - runtimeGlobals.useStorage = previousUseStorage + restoreGlobalProperty('useRuntimeConfig', previousUseRuntimeConfig) + restoreGlobalProperty('useStorage', previousUseStorage) } }) it('throws when neither explicit bindings nor runtime globals are configured', () => { - const runtimeGlobals = globalThis as typeof globalThis & { - useRuntimeConfig?: unknown - useStorage?: unknown - } - const previousUseRuntimeConfig = runtimeGlobals.useRuntimeConfig - const previousUseStorage = runtimeGlobals.useStorage + const previousUseRuntimeConfig = Object.getOwnPropertyDescriptor(globalThis, 'useRuntimeConfig') + const previousUseStorage = Object.getOwnPropertyDescriptor(globalThis, 'useStorage') - delete runtimeGlobals.useRuntimeConfig - delete runtimeGlobals.useStorage + Reflect.deleteProperty(globalThis, 'useRuntimeConfig') + Reflect.deleteProperty(globalThis, 'useStorage') resetStorageRuntime() try { expect(() => useStorage('local')).toThrow('Storage runtime is not configured') } finally { - runtimeGlobals.useRuntimeConfig = previousUseRuntimeConfig - runtimeGlobals.useStorage = previousUseStorage + restoreGlobalProperty('useRuntimeConfig', previousUseRuntimeConfig) + restoreGlobalProperty('useStorage', previousUseStorage) } }) diff --git a/packages/storage/tests/s3Driver.test.ts b/packages/storage/tests/s3Driver.test.ts index e7caa0b..732e936 100644 --- a/packages/storage/tests/s3Driver.test.ts +++ b/packages/storage/tests/s3Driver.test.ts @@ -1,8 +1,14 @@ import { createHash, createHmac } from 'node:crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type createS3Driver from '../../storage-s3/src' const fetchMock = vi.fn() +async function loadDriver(): Promise { + const module = await import('../src/runtime/drivers/s3') as { default: typeof createS3Driver } + return module.default +} + async function readRequestBody(request: Request): Promise { return request.clone().text() } @@ -45,7 +51,7 @@ describe('custom s3 storage driver', () => { it('signs requests with the session token when temporary credentials are configured', async () => { fetchMock.mockResolvedValue(new Response(null, { status: 200 })) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -66,7 +72,7 @@ describe('custom s3 storage driver', () => { it('uses the configured addressing mode for backend requests', async () => { fetchMock.mockImplementation(async () => new Response('stored', { status: 200 })) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const virtualHostDriver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -99,7 +105,7 @@ describe('custom s3 storage driver', () => { const accessKeyId = 'AKIAEXAMPLE' const secretAccessKey = 'supersecretkey' const region = 'us-east-1' - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region, @@ -142,7 +148,7 @@ describe('custom s3 storage driver', () => { }) it('validates required driver options', async () => { - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() expect(() => createDriver({ region: 'us-east-1', @@ -196,7 +202,7 @@ describe('custom s3 storage driver', () => { })) .mockResolvedValueOnce(new Response(null, { status: 404 })) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -228,7 +234,7 @@ describe('custom s3 storage driver', () => { .mockResolvedValueOnce(new Response(null, { status: 404 })) .mockResolvedValueOnce(new Response(null, { status: 200 })) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -248,7 +254,7 @@ describe('custom s3 storage driver', () => { .mockResolvedValueOnce(new Response(null, { status: 404 })) .mockResolvedValueOnce(new Response(null, { status: 200 })) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -266,7 +272,7 @@ describe('custom s3 storage driver', () => { it('returns null metadata for missing objects', async () => { fetchMock.mockResolvedValueOnce(new Response(null, { status: 404 })) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -283,7 +289,7 @@ describe('custom s3 storage driver', () => { .mockResolvedValueOnce(new Response(null, { status: 200 })) .mockResolvedValueOnce(new Response(null, { status: 200 })) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -302,7 +308,7 @@ describe('custom s3 storage driver', () => { it('parses structured JSON payloads back through getItem', async () => { fetchMock.mockResolvedValueOnce(new Response('{"ok":true,"count":2}', { status: 200 })) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -333,7 +339,7 @@ describe('custom s3 storage driver', () => { { status: 200 }, )) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -363,7 +369,7 @@ describe('custom s3 storage driver', () => { { status: 200 }, )) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -387,7 +393,7 @@ describe('custom s3 storage driver', () => { { status: 200 }, )) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -421,7 +427,7 @@ describe('custom s3 storage driver', () => { .mockResolvedValueOnce(new Response(null, { status: 200 })) .mockResolvedValueOnce(new Response(null, { status: 200 })) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -449,7 +455,7 @@ describe('custom s3 storage driver', () => { { status: 200 }, )) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -472,7 +478,7 @@ describe('custom s3 storage driver', () => { { status: 200 }, )) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -496,7 +502,7 @@ describe('custom s3 storage driver', () => { { status: 200 }, )) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -520,7 +526,7 @@ describe('custom s3 storage driver', () => { { status: 200 }, )) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -546,7 +552,7 @@ describe('custom s3 storage driver', () => { .mockResolvedValueOnce(new Response(null, { status: 200 })) .mockResolvedValueOnce(new Response(null, { status: 200 })) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -575,7 +581,7 @@ describe('custom s3 storage driver', () => { }), }) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -596,7 +602,7 @@ describe('custom s3 storage driver', () => { { status: 200 }, )) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -611,7 +617,7 @@ describe('custom s3 storage driver', () => { it('handles root-object requests and duplicate query keys when signing', async () => { fetchMock.mockResolvedValueOnce(new Response('root', { status: 200 })) - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region: 'us-east-1', @@ -634,7 +640,7 @@ describe('custom s3 storage driver', () => { const accessKeyId = 'AKIAEXAMPLE' const secretAccessKey = 'supersecretkey' const region = 'us-east-1' - const { default: createDriver } = await import('../src/runtime/drivers/s3') + const createDriver = await loadDriver() const driver = createDriver({ bucket: 'media-bucket', region, From 2af5f74ef47c36bfa3cd71d3daff4de9b4aad8b1 Mon Sep 17 00:00:00 2001 From: Mohamed Melouk <42706279+cobraprojects@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:49:07 +0200 Subject: [PATCH 3/4] typecheck --- packages/notifications/tests/contracts.test.ts | 3 ++- packages/notifications/tests/index.type.test.ts | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/notifications/tests/contracts.test.ts b/packages/notifications/tests/contracts.test.ts index deb503b..6932a90 100644 --- a/packages/notifications/tests/contracts.test.ts +++ b/packages/notifications/tests/contracts.test.ts @@ -55,8 +55,9 @@ describe('@holo-js/notifications contracts', () => { it('rejects malformed definitions, delays, and queue options', () => { expect(() => defineNotification({ + // @ts-expect-error - intentionally returning wrong channel type to test validation via() { - return ['email'] as never + return ['email'] }, build: {}, })).toThrow('must define at least one channel payload builder') diff --git a/packages/notifications/tests/index.type.test.ts b/packages/notifications/tests/index.type.test.ts index 5da4eb1..6816fdf 100644 --- a/packages/notifications/tests/index.type.test.ts +++ b/packages/notifications/tests/index.type.test.ts @@ -62,12 +62,13 @@ describe('@holo-js/notifications root export typing', () => { PendingNotificationDispatch >> type AnonymousAssertion = Expect< - typeof anonymous extends PendingAnonymousNotification<{ + Equal< + typeof anonymous, + PendingAnonymousNotification<{ readonly email: string | { readonly email: string, readonly name?: string } readonly database: { readonly id: string | number, readonly type: string } }> - ? true - : false + > > type ResultAssertion = Expect, @@ -75,7 +76,7 @@ describe('@holo-js/notifications root export typing', () => { >> const fromDefault: typeof notifications.notify = notifications.notify - const table: string = config.table + const table: 'notifications' = config.table void pending void anonymous From f0f92d36a01e9e451653ebf910c6eebb3175c470 Mon Sep 17 00:00:00 2001 From: Mohamed Melouk <42706279+cobraprojects@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:15:54 +0200 Subject: [PATCH 4/4] fix findings --- packages/auth-social/src/index.ts | 53 ++++++++++++++++--- packages/auth/src/runtime.ts | 7 ++- packages/authorization/src/contracts.ts | 4 +- .../cli/tests/authorization-registry.test.ts | 5 +- packages/config/src/loader.ts | 2 +- packages/events/src/contracts.ts | 19 ++++--- packages/events/src/index.ts | 1 + packages/events/tests/contracts.test.ts | 16 ++++++ packages/events/tests/contracts.type.test.ts | 14 +++++ packages/flux-react/src/index.ts | 42 ++++++++------- .../flux-react/tests/package.type.test.ts | 19 +++---- packages/flux-svelte/src/index.ts | 47 ++++++++-------- .../flux-svelte/tests/package.type.test.ts | 20 ++++--- packages/flux-vue/src/index.ts | 47 ++++++++-------- packages/flux-vue/tests/package.type.test.ts | 12 +++-- packages/mail/tests/runtime.test.ts | 2 +- packages/notifications/src/contracts.ts | 9 +++- packages/notifications/src/index.ts | 9 +++- packages/notifications/src/runtime.ts | 4 +- packages/storage-s3/src/index.ts | 15 +++++- packages/storage-s3/tests/storage-s3.test.ts | 23 ++++++++ packages/storage/tests/facade.test.ts | 6 +++ packages/storage/tests/s3Driver.test.ts | 37 +++++++++++++ scripts/run-test-typecheck.mjs | 15 +++--- 24 files changed, 316 insertions(+), 112 deletions(-) diff --git a/packages/auth-social/src/index.ts b/packages/auth-social/src/index.ts index fff9521..f73383f 100644 --- a/packages/auth-social/src/index.ts +++ b/packages/auth-social/src/index.ts @@ -93,13 +93,49 @@ const AUTH_PROVIDER_MARKER = Symbol.for('holo-js.auth.provider') type RuntimeAuthProviderAdapter = ReturnType['providers'][string] function requireUserRecord(user: unknown, message: string): Record { - if (!user || typeof user !== 'object') { + if (user == null) { + throw new Error(message) + } + + if (typeof user !== 'object') { throw new Error(message) } return user as Record } +function resolveUserRecord(user: unknown, message: string): Record | null { + if (user == null) { + return null + } + + return requireUserRecord(user, message) +} + +function requireUserId( + adapter: RuntimeAuthProviderAdapter, + user: unknown, + message: string, +): string | number { + const userRecord = requireUserRecord(user, message) + const userId = adapter.getId(userRecord) + + if (typeof userId === 'string') { + const normalized = userId.trim() + if (!normalized) { + throw new Error(message) + } + + return normalized + } + + if (typeof userId === 'number' && Number.isFinite(userId)) { + return userId + } + + throw new Error(message) +} + function throwUnconfigured(): never { throw new Error('[@holo-js/auth-social] Social auth runtime is not configured yet.') } @@ -236,7 +272,11 @@ function serializeLocalUser( user: Record, providerName: string, ): AuthUserLike { - const id = adapter.getId(user) + const id = requireUserId( + adapter, + user, + '[@holo-js/auth-social] Auth provider users must resolve to a non-empty string or numeric id.', + ) const serialized = adapter.serialize ? adapter.serialize(user) : { ...(user as Record) } @@ -263,9 +303,7 @@ async function findUserByEmail( } const user = await adapter.findByCredentials({ email }) - return user - ? requireUserRecord(user, '[@holo-js/auth-social] Auth provider lookups must return object users.') - : null + return resolveUserRecord(user, '[@holo-js/auth-social] Auth provider lookups must return object users.') } function resolveEmailForCreation( @@ -299,10 +337,13 @@ async function resolveLinkedUser( const verificationRequired = authBindings.config.emailVerification.required === true if (existingIdentity) { - const linkedUser = requireUserRecord( + const linkedUser = resolveUserRecord( await adapter.findById(existingIdentity.userId), `[@holo-js/auth-social] Linked social identity "${provider}:${profile.id}" references a missing local user.`, ) + if (!linkedUser) { + throw new Error(`[@holo-js/auth-social] Linked social identity "${provider}:${profile.id}" references a missing local user.`) + } const serialized = serializeLocalUser(adapter, linkedUser, authProvider) await bindings.identityStore.save({ diff --git a/packages/auth/src/runtime.ts b/packages/auth/src/runtime.ts index 347b022..f42bc18 100644 --- a/packages/auth/src/runtime.ts +++ b/packages/auth/src/runtime.ts @@ -2085,7 +2085,12 @@ function createPasswordResetFacade(): AuthPasswordResetFacade { } const password = await getRuntimeBindings().passwordHasher.hash(input.password) - const updated = await updateUserRecord(record.provider, adapter.getId(user), { + const userId = requireUserId( + adapter, + user, + '[@holo-js/auth] Password reset token user is invalid.', + ) + const updated = await updateUserRecord(record.provider, userId, { password, }) await store.delete(record.id, { diff --git a/packages/authorization/src/contracts.ts b/packages/authorization/src/contracts.ts index d9de6ce..83337dc 100644 --- a/packages/authorization/src/contracts.ts +++ b/packages/authorization/src/contracts.ts @@ -173,13 +173,13 @@ type RegisteredAuthorizationAbilityEntry = Authoriz ] export type PolicyActorForName = RegisteredAuthorizationPolicyEntry extends { - actor: infer TActor + actor?: infer TActor } ? FallbackRegistryActor : object export type AbilityActorForName = RegisteredAuthorizationAbilityEntry extends { - actor: infer TActor + actor?: infer TActor } ? FallbackRegistryActor : object diff --git a/packages/cli/tests/authorization-registry.test.ts b/packages/cli/tests/authorization-registry.test.ts index 705b8d1..cb8731a 100644 --- a/packages/cli/tests/authorization-registry.test.ts +++ b/packages/cli/tests/authorization-registry.test.ts @@ -156,7 +156,10 @@ describe('@holo-js/cli authorization registry discovery', () => { expect(types).toContain('AuthorizationPolicyRegistry') expect(types).toContain('AuthorizationAbilityRegistry') expect(types).toContain('AuthorizationGuardRegistry') - expect(types).toContain('actor: object') + const policyEntry = /"posts": \{[\s\S]*?recordActions:/.exec(types)?.[0] + const abilityEntry = /"reports\.export": \{[\s\S]*?input:/.exec(types)?.[0] + expect(policyEntry).toContain('actor: object') + expect(abilityEntry).toContain('actor: object') expect(types).toContain('"web": {') expect(types).toContain('user: import(\'@holo-js/auth\').AuthUser') expect(types).toContain('"admin": {') diff --git a/packages/config/src/loader.ts b/packages/config/src/loader.ts index 3c37926..8b37fbf 100644 --- a/packages/config/src/loader.ts +++ b/packages/config/src/loader.ts @@ -545,7 +545,7 @@ export function defineMailConfig(config: TConfig return defineConfig(config) } -export function defineNotificationsConfig(config: TConfig): DefineConfigValue { +export function defineNotificationsConfig(config: TConfig): DefineConfigValue { return defineConfig(config) } diff --git a/packages/events/src/contracts.ts b/packages/events/src/contracts.ts index 6acc726..1e49fea 100644 --- a/packages/events/src/contracts.ts +++ b/packages/events/src/contracts.ts @@ -180,6 +180,13 @@ export interface ListenerDefinition< handle(event: ListenerHandledEvent): TResult | Promise } +export type ListenerDefinitionInput< + TInput extends EventReferenceInput = EventReferenceInput, + TResult = unknown, +> = Omit, 'listensTo'> & { + readonly listensTo: TInput +} + export interface RegisteredListener< TInput extends EventReferenceInput = EventReferenceInput, TResult = unknown, @@ -330,8 +337,8 @@ export function isListenerDefinition(value: unknown): value is ListenerDefinitio export function normalizeListenerDefinition< TInput extends EventReferenceInput, - TListener extends ListenerDefinition, ->(listener: TListener): TListener { + TResult, +>(listener: ListenerDefinitionInput): ListenerDefinition { if (!isListenerDefinition(listener)) { throw new Error('[Holo Events] Listeners must define "listensTo" and a "handle" function.') } @@ -355,13 +362,13 @@ export function normalizeListenerDefinition< ...(typeof queueName === 'undefined' ? {} : { queueName }), ...(typeof delay === 'undefined' ? {} : { delay }), ...(typeof afterCommit === 'undefined' ? {} : { afterCommit }), - } as TListener + } as ListenerDefinition } export function defineListener< TInput extends EventReferenceInput, - TListener extends ListenerDefinition, ->(listener: TListener): TListener { + TResult, +>(listener: ListenerDefinitionInput): ListenerDefinition { const normalized = normalizeListenerDefinition(listener) if (isReadonlyArray(normalized.listensTo)) { Object.freeze(normalized.listensTo) @@ -373,7 +380,7 @@ export function defineListener< value: true, enumerable: false, }) - return Object.freeze(tagged) + return Object.freeze(tagged) as ListenerDefinition } export const eventInternals = { diff --git a/packages/events/src/index.ts b/packages/events/src/index.ts index 5ea8006..b36fb4a 100644 --- a/packages/events/src/index.ts +++ b/packages/events/src/index.ts @@ -24,6 +24,7 @@ export type { EventReferenceInput, ExportedEventDefinition, ListenerDefinition, + ListenerDefinitionInput, ListenerHandledEvent, RegisteredEvent, RegisteredListener, diff --git a/packages/events/tests/contracts.test.ts b/packages/events/tests/contracts.test.ts index ab4e189..d43cd76 100644 --- a/packages/events/tests/contracts.test.ts +++ b/packages/events/tests/contracts.test.ts @@ -100,6 +100,22 @@ describe('@holo-js/events contracts', () => { expect(Object.isFrozen(multiple.listensTo)).toBe(true) }) + it('normalizes a single listener event reference into a frozen list', () => { + const event = defineEvent<{ userId: string }, 'user.registered'>({ + name: 'user.registered', + }) + + const listener = defineListener({ + listensTo: event, + async handle() { + return 'ok' + }, + }) + + expect(listener.listensTo).toEqual([event]) + expect(Object.isFrozen(listener.listensTo)).toBe(true) + }) + it('rejects invalid listener definitions and metadata combinations', () => { const validEvent = defineEvent<{ id: string }, 'entity.created'>({ name: 'entity.created', diff --git a/packages/events/tests/contracts.type.test.ts b/packages/events/tests/contracts.type.test.ts index f51a1af..c3849e6 100644 --- a/packages/events/tests/contracts.type.test.ts +++ b/packages/events/tests/contracts.type.test.ts @@ -7,6 +7,7 @@ import { type EventPendingDispatch, type EventPayloadFor, type ListenerDefinition, + type ListenerDefinitionInput, defineEvent, defineListener, } from '../src' @@ -40,9 +41,17 @@ describe('@holo-js/events typing', () => { }, } satisfies ListenerDefinition) + const singleListener = defineListener({ + listensTo: userRegistered, + handle(event) { + return event.payload.userId + }, + } satisfies ListenerDefinitionInput) + type MultiEvent = Parameters[0] type MultiEventName = MultiEvent['name'] type MultiEventPayload = MultiEvent['payload'] + type SingleListensTo = typeof singleListener.listensTo type PayloadFromRegistry = EventPayloadFor<'user.registered'> type ListenerContract = ListenerDefinition @@ -76,6 +85,10 @@ describe('@holo-js/events typing', () => { PayloadFromRegistry, { userId: string; email: string } >> + type SingleListensToAssertion = Expect> const explicitEnvelope: ExplicitEnvelope = { name: 'user.registered', @@ -95,5 +108,6 @@ describe('@holo-js/events typing', () => { void (0 as unknown as MultiNameAssertion) void (0 as unknown as MultiPayloadAssertion) void (0 as unknown as RegistryPayloadAssertion) + void (0 as unknown as SingleListensToAssertion) }) }) diff --git a/packages/flux-react/src/index.ts b/packages/flux-react/src/index.ts index 7a8e9ef..c8a51e3 100644 --- a/packages/flux-react/src/index.ts +++ b/packages/flux-react/src/index.ts @@ -1,13 +1,13 @@ import { useEffect, useMemo, useReducer, useRef, useSyncExternalStore } from 'react' import { getFluxClient, type FluxClient, type FluxConnectionStatus, type FluxListenerControls } from '@holo-js/flux' -import type { BroadcastJsonObject, BroadcastPayloadFor } from '@holo-js/broadcast' +import type { BroadcastJsonObject, BroadcastPayloadFor, GeneratedBroadcastManifest } from '@holo-js/broadcast' -export interface FluxHookOptions { - readonly client?: TClient +export interface FluxHookOptions { + readonly client?: FluxClient readonly onUnmount?: (cleanup: () => void) => void } -export interface FluxConnectionStatusHookOptions extends FluxHookOptions { +export interface FluxConnectionStatusHookOptions extends FluxHookOptions { readonly onChange?: (status: FluxConnectionStatus) => void } @@ -24,8 +24,10 @@ type AnyFluxPresenceSubscription = ReturnType & { __onPresenceChange?(callback: (members: readonly BroadcastJsonObject[]) => void): () => void } -function resolveClient(options: FluxHookOptions): TClient { - return (options.client ?? getFluxClient()) as TClient +function resolveClient( + options: FluxHookOptions, +): FluxClient { + return (options.client ?? getFluxClient()) as FluxClient } const noop = Function.prototype as () => void @@ -117,11 +119,11 @@ function useEventSubscription( }, onUnmount, dependencies) } -export function useFlux( +export function useFlux( channel: string, events: TEvent | readonly TEvent[], callback: (payload: BroadcastPayloadFor) => void, - options: FluxHookOptions = {}, + options: FluxHookOptions = {}, ): FluxListenerControls { const client = resolveClient(options) return useEventSubscription( @@ -133,11 +135,11 @@ export function useFlux( ) } -export function useFluxPublic( +export function useFluxPublic( channel: string, events: TEvent | readonly TEvent[], callback: (payload: BroadcastPayloadFor) => void, - options: FluxHookOptions = {}, + options: FluxHookOptions = {}, ): FluxListenerControls { const client = resolveClient(options) return useEventSubscription( @@ -149,19 +151,19 @@ export function useFluxPublic( ) } -export function useFluxPrivate( +export function useFluxPrivate( channel: string, events: TEvent | readonly TEvent[], callback: (payload: BroadcastPayloadFor) => void, - options: FluxHookOptions = {}, + options: FluxHookOptions = {}, ): FluxListenerControls { return useFlux(channel, events, callback, options) } -export function useFluxPresence( +export function useFluxPresence( channel: string, callbacks: FluxPresenceHookCallbacks = {}, - options: FluxHookOptions = {}, + options: FluxHookOptions = {}, ): FluxPresenceHookState { const client = resolveClient(options) const membersRef = useRef([]) @@ -227,10 +229,10 @@ export function useFluxPresence( }) } -export function useFluxNotification( +export function useFluxNotification( channel: string, callback: (payload: unknown) => void, - options: FluxHookOptions = {}, + options: FluxHookOptions = {}, ): FluxListenerControls { const client = resolveClient(options) const callbackRef = useLatestRef(callback) @@ -241,17 +243,17 @@ export function useFluxNotification( }, options.onUnmount, [client, channel]) } -export function useFluxModel( +export function useFluxModel( channel: string, events: TEvent | readonly TEvent[], callback: (payload: BroadcastPayloadFor) => void, - options: FluxHookOptions = {}, + options: FluxHookOptions = {}, ): FluxListenerControls { return useFluxPrivate(channel, events, callback, options) } -export function useFluxConnectionStatus( - options: FluxConnectionStatusHookOptions = {}, +export function useFluxConnectionStatus( + options: FluxConnectionStatusHookOptions = {}, ): FluxConnectionStatus { const client = resolveClient(options) const onChangeRef = useLatestRef(options.onChange) diff --git a/packages/flux-react/tests/package.type.test.ts b/packages/flux-react/tests/package.type.test.ts index 220ec63..26cf8ba 100644 --- a/packages/flux-react/tests/package.type.test.ts +++ b/packages/flux-react/tests/package.type.test.ts @@ -12,9 +12,9 @@ import { describe('@holo-js/flux-react typing', () => { it('supports single and multi-event typed helper usage', () => { - const manifest: GeneratedBroadcastManifest = { + const manifest = { version: 1, - generatedAt: '2026-01-01T00:00:00.000Z', + generatedAt: '2026-01-01T00:00:00.000Z' as string, events: [{ name: 'orders.updated', channels: [{ @@ -35,7 +35,7 @@ describe('@holo-js/flux-react typing', () => { params: ['orderId'], whispers: ['typing.start'], }], - } + } as const satisfies GeneratedBroadcastManifest const client = createFluxClient({ manifest, @@ -43,21 +43,22 @@ describe('@holo-js/flux-react typing', () => { if (false) { const generic = useFlux('orders.1', 'orders.updated', payload => { expectTypeOf(payload).toExtend>() - }, { client }) + }) const genericMany = useFlux('orders.1', ['orders.updated', 'orders.shipped'], payload => { expectTypeOf(payload).toExtend>() - }, { client }) + }) const pub = useFluxPublic('feed.1', 'orders.updated', payload => { expectTypeOf(payload).toExtend>() - }, { client }) + }) const priv = useFluxPrivate('orders.1', 'orders.shipped', payload => { expectTypeOf(payload).toExtend>() - }, { client }) - const presence = useFluxPresence<{ id: string }>('chat.1', {}, { client }) - const status = useFluxConnectionStatus({ client }) + }) + const presence = useFluxPresence<{ id: string }>('chat.1', {}) + const status = useFluxConnectionStatus() expectTypeOf(presence.members).toEqualTypeOf() expectTypeOf(status).toEqualTypeOf() + void client void generic void genericMany void pub diff --git a/packages/flux-svelte/src/index.ts b/packages/flux-svelte/src/index.ts index f4a7f74..2ce51be 100644 --- a/packages/flux-svelte/src/index.ts +++ b/packages/flux-svelte/src/index.ts @@ -1,14 +1,14 @@ import { onDestroy } from 'svelte' import { readable, writable, type Readable } from 'svelte/store' import { getFluxClient, type FluxClient, type FluxConnectionStatus, type FluxListenerControls } from '@holo-js/flux' -import type { BroadcastJsonObject, BroadcastPayloadFor } from '@holo-js/broadcast' +import type { BroadcastJsonObject, BroadcastPayloadFor, GeneratedBroadcastManifest } from '@holo-js/broadcast' -export interface FluxHelperOptions { - readonly client?: TClient +export interface FluxHelperOptions { + readonly client?: FluxClient readonly onUnmount?: (cleanup: () => void) => void } -export interface FluxConnectionStatusHelperOptions extends FluxHelperOptions { +export interface FluxConnectionStatusHelperOptions extends FluxHelperOptions { readonly onChange?: (status: FluxConnectionStatus) => void } @@ -25,11 +25,16 @@ type AnyFluxPresenceSubscription = ReturnType & { __onPresenceChange?(callback: (members: readonly BroadcastJsonObject[]) => void): () => void } -function resolveClient(options: FluxHelperOptions): TClient { - return (options.client ?? getFluxClient()) as TClient +function resolveClient( + options: FluxHelperOptions, +): FluxClient { + return (options.client ?? getFluxClient()) as FluxClient } -function registerCleanup(options: FluxHelperOptions, cleanup: () => void): void { +function registerCleanup( + options: FluxHelperOptions, + cleanup: () => void, +): void { const runCleanup = () => { cleanup() } @@ -77,11 +82,11 @@ function subscribeWithEvents( ) as AnyFluxSubscription } -export function useFlux( +export function useFlux( channel: string, events: TEvent | readonly TEvent[], callback: (payload: BroadcastPayloadFor) => void, - options: FluxHelperOptions = {}, + options: FluxHelperOptions = {}, ): FluxListenerControls { const subscription = subscribeWithEvents( resolveClient(options).private(channel), @@ -94,11 +99,11 @@ export function useFlux( return controlsFromSubscription(subscription) } -export function useFluxPublic( +export function useFluxPublic( channel: string, events: TEvent | readonly TEvent[], callback: (payload: BroadcastPayloadFor) => void, - options: FluxHelperOptions = {}, + options: FluxHelperOptions = {}, ): FluxListenerControls { const subscription = subscribeWithEvents( resolveClient(options).channel(channel), @@ -111,19 +116,19 @@ export function useFluxPublic( return controlsFromSubscription(subscription) } -export function useFluxPrivate( +export function useFluxPrivate( channel: string, events: TEvent | readonly TEvent[], callback: (payload: BroadcastPayloadFor) => void, - options: FluxHelperOptions = {}, + options: FluxHelperOptions = {}, ): FluxListenerControls { return useFlux(channel, events, callback, options) } -export function useFluxPresence( +export function useFluxPresence( channel: string, callbacks: FluxPresenceHelperCallbacks = {}, - options: FluxHelperOptions = {}, + options: FluxHelperOptions = {}, ): FluxPresenceHelperState { const subscription = resolveClient(options).presence(channel) as AnyFluxPresenceSubscription callbacks.onHere?.(subscription.members as readonly TMember[]) @@ -145,10 +150,10 @@ export function useFluxPresence( }) } -export function useFluxNotification( +export function useFluxNotification( channel: string, callback: (payload: unknown) => void, - options: FluxHelperOptions = {}, + options: FluxHelperOptions = {}, ): FluxListenerControls { const subscription = resolveClient(options).private(channel).notification(callback as (payload: { readonly [key: string]: unknown }) => void) as AnyFluxSubscription registerCleanup(options, () => { @@ -157,17 +162,17 @@ export function useFluxNotification( return controlsFromSubscription(subscription) } -export function useFluxModel( +export function useFluxModel( channel: string, events: TEvent | readonly TEvent[], callback: (payload: BroadcastPayloadFor) => void, - options: FluxHelperOptions = {}, + options: FluxHelperOptions = {}, ): FluxListenerControls { return useFluxPrivate(channel, events, callback, options) } -export function useFluxConnectionStatus( - options: FluxConnectionStatusHelperOptions = {}, +export function useFluxConnectionStatus( + options: FluxConnectionStatusHelperOptions = {}, ): Readable { const client = resolveClient(options) const status = writable(client.getStatus()) diff --git a/packages/flux-svelte/tests/package.type.test.ts b/packages/flux-svelte/tests/package.type.test.ts index 0e3df78..7387b5c 100644 --- a/packages/flux-svelte/tests/package.type.test.ts +++ b/packages/flux-svelte/tests/package.type.test.ts @@ -13,9 +13,9 @@ import { describe('@holo-js/flux-svelte typing', () => { it('supports single and multi-event typed helper usage', () => { - const manifest: GeneratedBroadcastManifest = { + const manifest = { version: 1, - generatedAt: '2026-01-01T00:00:00.000Z', + generatedAt: '2026-01-01T00:00:00.000Z' as string, events: [{ name: 'orders.updated', channels: [{ @@ -36,7 +36,7 @@ describe('@holo-js/flux-svelte typing', () => { params: ['orderId'], whispers: ['typing.start'], }], - } + } as const satisfies GeneratedBroadcastManifest const client = createFluxClient({ manifest, @@ -44,18 +44,22 @@ describe('@holo-js/flux-svelte typing', () => { }) const generic = useFlux('orders.1', 'orders.updated', payload => { - expectTypeOf(payload).toMatchTypeOf>() + expectTypeOf(payload).toExtend>() }, { client }) const genericMany = useFlux('orders.1', ['orders.updated', 'orders.shipped'], payload => { - expectTypeOf(payload).toMatchTypeOf>() + expectTypeOf(payload).toExtend>() }, { client }) const pub = useFluxPublic('feed.1', 'orders.updated', payload => { - expectTypeOf(payload).toMatchTypeOf>() + expectTypeOf(payload).toExtend>() }, { client }) const priv = useFluxPrivate('orders.1', 'orders.shipped', payload => { - expectTypeOf(payload).toMatchTypeOf>() + expectTypeOf(payload).toExtend>() + }, { client }) + const presence = useFluxPresence('chat.1', { + onHere(members: readonly { id: string }[]) { + void members + }, }, { client }) - const presence = useFluxPresence<{ id: string }>('chat.1', {}, { client }) const status = useFluxConnectionStatus({ client }) expectTypeOf(presence.members).toEqualTypeOf>() expectTypeOf(status).toEqualTypeOf>() diff --git a/packages/flux-vue/src/index.ts b/packages/flux-vue/src/index.ts index bef9c2f..a94e002 100644 --- a/packages/flux-vue/src/index.ts +++ b/packages/flux-vue/src/index.ts @@ -1,13 +1,13 @@ import { getCurrentScope, onScopeDispose, readonly, shallowRef, type Ref, type ShallowRef } from 'vue' import { getFluxClient, type FluxClient, type FluxConnectionStatus, type FluxListenerControls } from '@holo-js/flux' -import type { BroadcastJsonObject, BroadcastPayloadFor } from '@holo-js/broadcast' +import type { BroadcastJsonObject, BroadcastPayloadFor, GeneratedBroadcastManifest } from '@holo-js/broadcast' -export interface FluxComposableOptions { - readonly client?: TClient +export interface FluxComposableOptions { + readonly client?: FluxClient readonly onUnmount?: (cleanup: () => void) => void } -export interface FluxConnectionStatusComposableOptions extends FluxComposableOptions { +export interface FluxConnectionStatusComposableOptions extends FluxComposableOptions { readonly onChange?: (status: FluxConnectionStatus) => void } @@ -26,11 +26,16 @@ type AnyFluxPresenceSubscription = ReturnType & { __onPresenceChange?(callback: (members: readonly BroadcastJsonObject[]) => void): () => void } -function resolveClient(options: FluxComposableOptions): TClient { - return (options.client ?? getFluxClient()) as TClient +function resolveClient( + options: FluxComposableOptions, +): FluxClient { + return (options.client ?? getFluxClient()) as FluxClient } -function registerCleanup(options: FluxComposableOptions, cleanup: () => void): void { +function registerCleanup( + options: FluxComposableOptions, + cleanup: () => void, +): void { if (getCurrentScope()) { onScopeDispose(cleanup) return @@ -69,11 +74,11 @@ function subscribeWithEvents( ) as AnyFluxSubscription } -export function useFlux( +export function useFlux( channel: string, events: TEvent | readonly TEvent[], callback: (payload: BroadcastPayloadFor) => void, - options: FluxComposableOptions = {}, + options: FluxComposableOptions = {}, ): FluxListenerControls { const subscription = subscribeWithEvents( resolveClient(options).private(channel), @@ -86,11 +91,11 @@ export function useFlux( return createControls(subscription) } -export function useFluxPublic( +export function useFluxPublic( channel: string, events: TEvent | readonly TEvent[], callback: (payload: BroadcastPayloadFor) => void, - options: FluxComposableOptions = {}, + options: FluxComposableOptions = {}, ): FluxListenerControls { const subscription = subscribeWithEvents( resolveClient(options).channel(channel), @@ -103,19 +108,19 @@ export function useFluxPublic( return createControls(subscription) } -export function useFluxPrivate( +export function useFluxPrivate( channel: string, events: TEvent | readonly TEvent[], callback: (payload: BroadcastPayloadFor) => void, - options: FluxComposableOptions = {}, + options: FluxComposableOptions = {}, ): FluxListenerControls { return useFlux(channel, events, callback, options) } -export function useFluxPresence( +export function useFluxPresence( channel: string, callbacks: FluxPresenceComposableCallbacks = {}, - options: FluxComposableOptions = {}, + options: FluxComposableOptions = {}, ): FluxPresenceComposableState { const subscription = resolveClient(options).presence(channel) as AnyFluxPresenceSubscription const members = shallowRef(subscription.members as readonly TMember[]) @@ -139,10 +144,10 @@ export function useFluxPresence( }) } -export function useFluxNotification( +export function useFluxNotification( channel: string, callback: (payload: unknown) => void, - options: FluxComposableOptions = {}, + options: FluxComposableOptions = {}, ): FluxListenerControls { const subscription = resolveClient(options).private(channel).notification(callback as (payload: { readonly [key: string]: unknown }) => void) as AnyFluxSubscription registerCleanup(options, () => { @@ -151,17 +156,17 @@ export function useFluxNotification( return createControls(subscription) } -export function useFluxModel( +export function useFluxModel( channel: string, events: TEvent | readonly TEvent[], callback: (payload: BroadcastPayloadFor) => void, - options: FluxComposableOptions = {}, + options: FluxComposableOptions = {}, ): FluxListenerControls { return useFluxPrivate(channel, events, callback, options) } -export function useFluxConnectionStatus( - options: FluxConnectionStatusComposableOptions = {}, +export function useFluxConnectionStatus( + options: FluxConnectionStatusComposableOptions = {}, ): Readonly> { const client = resolveClient(options) const status = shallowRef(client.getStatus()) diff --git a/packages/flux-vue/tests/package.type.test.ts b/packages/flux-vue/tests/package.type.test.ts index a9445ad..7650f84 100644 --- a/packages/flux-vue/tests/package.type.test.ts +++ b/packages/flux-vue/tests/package.type.test.ts @@ -12,9 +12,9 @@ import { describe('@holo-js/flux-vue typing', () => { it('supports single and multi-event typed helper usage', () => { - const manifest: GeneratedBroadcastManifest = { + const manifest = { version: 1, - generatedAt: '2026-01-01T00:00:00.000Z', + generatedAt: '2026-01-01T00:00:00.000Z' as string, events: [{ name: 'orders.updated', channels: [{ @@ -35,7 +35,7 @@ describe('@holo-js/flux-vue typing', () => { params: ['orderId'], whispers: ['typing.start'], }], - } + } as const satisfies GeneratedBroadcastManifest const client = createFluxClient({ manifest, @@ -54,7 +54,11 @@ describe('@holo-js/flux-vue typing', () => { const priv = useFluxPrivate('orders.1', 'orders.shipped', payload => { expectTypeOf(payload).toExtend>() }, { client }) - const presence = useFluxPresence<{ id: string }>('chat.1', {}, { client }) + const presence = useFluxPresence('chat.1', { + onHere(members: readonly { id: string }[]) { + void members + }, + }, { client }) const status = useFluxConnectionStatus({ client }) expectTypeOf(presence.members).toEqualTypeOf() expectTypeOf(status).toExtend<{ readonly value: FluxConnectionStatus }>() diff --git a/packages/mail/tests/runtime.test.ts b/packages/mail/tests/runtime.test.ts index 46d5776..7d98987 100644 --- a/packages/mail/tests/runtime.test.ts +++ b/packages/mail/tests/runtime.test.ts @@ -30,7 +30,7 @@ const previousAppEnv = process.env.APP_ENV const previousNodeEnv = process.env.NODE_ENV type NormalizedMailMailerConfig = NormalizedHoloMailConfig['mailers'][string] -type BuiltInDriverName = 'preview' | 'fake' | 'log' | 'smtp' +type BuiltInDriverName = keyof typeof mailRuntimeInternals.builtInDrivers function getMailerConfig( config: NormalizedHoloMailConfig, diff --git a/packages/notifications/src/contracts.ts b/packages/notifications/src/contracts.ts index ebcb71f..574b267 100644 --- a/packages/notifications/src/contracts.ts +++ b/packages/notifications/src/contracts.ts @@ -220,6 +220,13 @@ export interface AnonymousNotificationTarget< readonly routes: TRoutes } +type AnonymousRoutesWithChannel< + TRoutes extends Partial<{ readonly [TChannel in NotificationChannelName]: NotificationRouteFor }>, + TChannel extends NotificationChannelName, +> = Readonly & { + readonly [TKey in TChannel]: NotificationRouteFor +}> + export interface NotificationChannelDispatchResult { readonly channel: TChannel readonly targetIndex: number @@ -263,7 +270,7 @@ export interface PendingAnonymousNotification< channel( channel: TChannel, route: NotificationRouteFor, - ): PendingAnonymousNotification }> + ): PendingAnonymousNotification> notify>>( notification: TNotification, ): PendingNotificationDispatch diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts index dcb46ed..11bbd69 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -9,7 +9,10 @@ import { unreadNotifications, } from './runtime' -export { defineNotificationsConfig } from '@holo-js/config' +import { + defineNotificationsConfig as defineBaseNotificationsConfig, + type HoloNotificationsConfig, +} from '@holo-js/config' export type { HoloNotificationsConfig, NormalizedHoloNotificationsConfig } from '@holo-js/config' export { @@ -83,6 +86,10 @@ export { unreadNotifications, } from './runtime' +export function defineNotificationsConfig(config: TConfig) { + return defineBaseNotificationsConfig(config) +} + const notifications = Object.freeze({ deleteNotifications, listNotifications, diff --git a/packages/notifications/src/runtime.ts b/packages/notifications/src/runtime.ts index b46d420..53b27f6 100644 --- a/packages/notifications/src/runtime.ts +++ b/packages/notifications/src/runtime.ts @@ -1008,12 +1008,12 @@ class AnonymousNotificationBuilder< channel( channel: TChannel, route: NotificationRouteFor, - ): PendingAnonymousNotification }> { + ): PendingAnonymousNotification & { readonly [TKey in TChannel]: NotificationRouteFor }>> { const normalizedChannel = normalizeOptionalString(channel, 'Notification channel') return new AnonymousNotificationBuilder({ ...this.target.routes, [normalizedChannel]: route, - } as TRoutes & { readonly [TKey in TChannel]: NotificationRouteFor }) + } as Readonly & { readonly [TKey in TChannel]: NotificationRouteFor }>) } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- must accept any NotificationDefinition variant diff --git a/packages/storage-s3/src/index.ts b/packages/storage-s3/src/index.ts index 8549b94..4881d51 100644 --- a/packages/storage-s3/src/index.ts +++ b/packages/storage-s3/src/index.ts @@ -215,10 +215,23 @@ function createSignedRequest( `AWS4-HMAC-SHA256 Credential=${options.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`, ) + const requestBody = payloadBytes + ? (() => { + const { buffer, byteOffset, byteLength } = payloadBytes + const arrayBuffer = buffer instanceof ArrayBuffer + ? (byteOffset === 0 && byteLength === buffer.byteLength + ? buffer + : buffer.slice(byteOffset, byteOffset + byteLength)) + : payloadBytes.slice().buffer + + return new Blob([arrayBuffer]) + })() + : undefined + return new Request(url.toString(), { method, headers, - body: payloadBytes ? new Blob([new Uint8Array(payloadBytes)]) : undefined, + body: requestBody, }) } diff --git a/packages/storage-s3/tests/storage-s3.test.ts b/packages/storage-s3/tests/storage-s3.test.ts index 8829993..bfd902f 100644 --- a/packages/storage-s3/tests/storage-s3.test.ts +++ b/packages/storage-s3/tests/storage-s3.test.ts @@ -31,4 +31,27 @@ describe('@holo-js/storage-s3', () => { expect(request.headers.get('authorization')).toContain('AWS4-HMAC-SHA256') expect(request.url).toContain('/reports/daily.txt') }) + + it('supports buffer-backed payload uploads', async () => { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const request = input instanceof Request ? input : new Request(input) + expect(await request.text()).toBe('buffer-ok') + return new Response(null, { status: 200 }) + }) + vi.stubGlobal('fetch', fetchMock) + + const driver = createS3Driver({ + bucket: 'media-bucket', + region: 'us-east-1', + endpoint: 'https://s3.us-east-1.amazonaws.com', + accessKeyId: 'AKIAEXAMPLE', + secretAccessKey: 'supersecretkey', + }) + + await driver.setItemRaw('reports:buffer.txt', Buffer.from('buffer-ok')) + + const firstCall = fetchMock.mock.calls[0] as unknown[] | undefined + const request = firstCall?.[0] as unknown as Request + expect(request.url).toContain('/reports/buffer.txt') + }) }) diff --git a/packages/storage/tests/facade.test.ts b/packages/storage/tests/facade.test.ts index a9e6bdd..f32ee69 100644 --- a/packages/storage/tests/facade.test.ts +++ b/packages/storage/tests/facade.test.ts @@ -445,6 +445,12 @@ describe('Storage facade', () => { expect(new TextDecoder().decode((await local.getBytes('blob/data.bin')) ?? new Uint8Array())).toBe('blob-data') }) + it('returns null for malformed backend values that cannot be decoded into bytes', async () => { + storedValues['holo:local']?.set('raw:invalid.txt', 0 as never) + + await expect(Storage.disk('local').get('raw/invalid.txt')).resolves.toBeNull() + }) + it('preserves malformed encoded keys when listing backend files', async () => { storedValues['holo:local']?.set('reports:bad%ZZname.txt', new TextEncoder().encode('bad')) diff --git a/packages/storage/tests/s3Driver.test.ts b/packages/storage/tests/s3Driver.test.ts index 732e936..c1eddb0 100644 --- a/packages/storage/tests/s3Driver.test.ts +++ b/packages/storage/tests/s3Driver.test.ts @@ -249,6 +249,43 @@ describe('custom s3 storage driver', () => { expect(await readRequestBody(fetchMock.mock.calls[1]?.[0] as Request)).toBe('u8') }) + it('preserves buffer-backed payloads on writes', async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 })) + + const createDriver = await loadDriver() + const driver = createDriver({ + bucket: 'media-bucket', + region: 'us-east-1', + endpoint: 'https://s3.us-east-1.amazonaws.com', + accessKeyId: 'AKIAEXAMPLE', + secretAccessKey: 'supersecretkey', + }) + + await driver.setItemRaw('reports:buffer.bin', Buffer.from('buffer-ok')) + + expect(await readRequestBody(fetchMock.mock.calls[0]?.[0] as Request)).toBe('buffer-ok') + }) + + it('preserves shared-array-buffer-backed payloads on writes', async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 })) + + const createDriver = await loadDriver() + const driver = createDriver({ + bucket: 'media-bucket', + region: 'us-east-1', + endpoint: 'https://s3.us-east-1.amazonaws.com', + accessKeyId: 'AKIAEXAMPLE', + secretAccessKey: 'supersecretkey', + }) + + const payload = new Uint8Array(new SharedArrayBuffer(9)) + payload.set(new TextEncoder().encode('shared-ok')) + + await driver.setItemRaw('reports:shared.bin', payload) + + expect(await readRequestBody(fetchMock.mock.calls[0]?.[0] as Request)).toBe('shared-ok') + }) + it('returns null for missing raw objects and preserves plain string writes', async () => { fetchMock .mockResolvedValueOnce(new Response(null, { status: 404 })) diff --git a/scripts/run-test-typecheck.mjs b/scripts/run-test-typecheck.mjs index adf28d3..7522282 100644 --- a/scripts/run-test-typecheck.mjs +++ b/scripts/run-test-typecheck.mjs @@ -1,6 +1,6 @@ import { mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises' import { spawn } from 'node:child_process' -import { join, relative, resolve } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' import { tmpdir } from 'node:os' const packagesRoot = resolve('packages') @@ -55,8 +55,8 @@ async function resolveTestTsconfigs(packageDir, generatedConfigDirs) { } const typeTestFiles = await collectTypeTestFiles(packageDir) - for (const typeTestFile of typeTestFiles) { - configPaths.push(await createGeneratedTypeTestConfig(packageDir, typeTestFile, generatedConfigDirs)) + if (typeTestFiles.length > 0) { + configPaths.push(await createGeneratedTypeTestsBatchConfig(packageDir, typeTestFiles, generatedConfigDirs)) } return configPaths @@ -88,7 +88,7 @@ async function createGeneratedMainTestConfig(packageDir, generatedConfigDirs) { return generatedConfigPath } -async function createGeneratedTypeTestConfig(packageDir, typeTestFile, generatedConfigDirs) { +async function createGeneratedTypeTestsBatchConfig(packageDir, typeTestFiles, generatedConfigDirs) { const generatedConfigDir = await mkdtemp(join(tmpdir(), 'holo-type-test-typecheck-')) generatedConfigDirs.push(generatedConfigDir) @@ -102,7 +102,7 @@ async function createGeneratedTypeTestConfig(packageDir, typeTestFile, generated }, include: [ join(packageDir, 'src/**/*').replaceAll('\\', '/'), - typeTestFile.replaceAll('\\', '/'), + ...typeTestFiles.map(typeTestFile => typeTestFile.replaceAll('\\', '/')), ], exclude: [ join(packageDir, 'node_modules').replaceAll('\\', '/'), @@ -122,7 +122,10 @@ async function collectTypeTestFiles(packageDir) { return entries .filter(entry => entry.isFile() && entry.name.endsWith('.type.test.ts')) - .map(entry => join(entry.parentPath, entry.name)) + .map(entry => { + const parentPath = entry.parentPath ?? dirname(entry.path) ?? testsDir + return join(parentPath, entry.name) + }) .sort() }