{{ gap.title }}
+{{ gap.body }}
+Holo-JS
++ Modular backend pieces for Nuxt, Next.js, and SvelteKit. Install what you need. + Skip the rest. +
+What you pick is what you ship.
+Why Holo-JS
++ Routing and rendering come in the box. The rest of a production backend doesn't. +
+{{ gap.body }}
+Features
++ No bundled bloat. No defaults you didn't choose. Install one module, install all, + or swap drivers. The runtime stays the same. +
+{{ m.pkg }}
+ {{ m.summary }}
+Beside, not instead of
++ Keep the routing, rendering, and conventions you already use. Holo-JS adds typed + backend services with first-class adapters for each host. +
+{ it('escapes configured queue table names and derived index names', () => { - const queueTable = 'jobs\'); throw new Error(\'owned\');//.queue' - const failedJobsTable = 'failed"jobs.queue' + const queueTable = 'jobs\'); throw new Error(\'owned\');//\n.queue' + const failedJobsTable = 'failed"jobs\r.queue' const queueIndexPrefix = queueTable.replaceAll('.', '_') const failedJobsIndexPrefix = failedJobsTable.replaceAll('.', '_') const queueMigration = renderQueueTableMigration(queueTable) diff --git a/packages/core/src/portable/holo.ts b/packages/core/src/portable/holo.ts index 0297a806..031f0dfe 100644 --- a/packages/core/src/portable/holo.ts +++ b/packages/core/src/portable/holo.ts @@ -3543,7 +3543,7 @@ async function registerProjectQueueJobs( throw new Error(`Discovered job "${entry.sourcePath}" does not export a Holo job.`) } - queueModule.registerQueueJob(queueModule.normalizeQueueJobDefinition(job), { + queueModule.registerQueueJob(job, { name: entry.name, sourcePath: entry.sourcePath, replaceExisting: !!existing?.sourcePath, @@ -4406,7 +4406,7 @@ export async function createHolo( runtimeOwnedAuthorizationPolicyNames.splice(0, runtimeOwnedAuthorizationPolicyNames.length, ...authorizationRegistration.policyNames) runtimeOwnedAuthorizationAbilityNames.splice(0, runtimeOwnedAuthorizationAbilityNames.length, ...authorizationRegistration.abilityNames) - if (options.registerProjectQueueJobs === true && registryHasJobs(registry)) { + if (options.registerProjectQueueJobs !== false && registryHasJobs(registry)) { /* v8 ignore start -- exercised only when the optional package is absent outside the monorepo test graph */ if (!activeQueueModule) { throw new Error('[@holo-js/core] Project jobs require @holo-js/queue to be installed.') diff --git a/packages/core/tests/adapter.test.ts b/packages/core/tests/adapter.test.ts index 10baed02..69fb04f1 100644 --- a/packages/core/tests/adapter.test.ts +++ b/packages/core/tests/adapter.test.ts @@ -353,7 +353,7 @@ describe('@holo-js/core adapter helpers', () => { }) }) - it('does not import discovered queue jobs when initializing an adapter project runtime directly by default', async () => { + it('does not import discovered queue jobs when adapter project queue registration is disabled', async () => { const root = await createProjectRoot() await mkdir(join(root, '.holo-js/generated'), { recursive: true }) @@ -383,7 +383,9 @@ describe('@holo-js/core adapter helpers', () => { }, null, 2)}\n`, 'utf8') const loadEsbuildSpy = vi.spyOn(runtimeModuleInternals, 'loadEsbuild') - const project = await createHoloAdapterProject(root) + const project = await createHoloAdapterProject(root, { + registerProjectQueueJobs: false, + }) await expect(project.runtime.initialize()).resolves.toBeUndefined() expect(listRegisteredQueueJobs().map(job => job.name)).toEqual(['holo.events.invoke-listener']) @@ -503,7 +505,7 @@ export default defineAbility('manage-posts', () => true) await expect(Queue.dispatchSync('report', {})).resolves.toBe('ok') }) - it('does not import discovered queue jobs during framework adapter initialization by default', async () => { + it('does not import discovered queue jobs during framework adapter initialization when queue registration is disabled', async () => { const adapter = createHoloFrameworkAdapter({ stateKey: '__holoTestAdapter__', displayName: 'Test', @@ -540,6 +542,7 @@ export default defineAbility('manage-posts', () => true) const project = await adapter.initializeProject({ projectRoot: root, + registerProjectQueueJobs: false, }) expect(project.runtime.initialized).toBe(true) diff --git a/packages/core/tests/runtime.test.ts b/packages/core/tests/runtime.test.ts index 421bfe5f..c6883247 100644 --- a/packages/core/tests/runtime.test.ts +++ b/packages/core/tests/runtime.test.ts @@ -576,7 +576,7 @@ export default {} ).rejects.toThrow(/missing-transitive-dependency/) }) - it('does not import discovered queue jobs during default runtime initialization', async () => { + it('does not import discovered queue jobs when runtime queue registration is disabled', async () => { const root = await createProject() await writeBaseConfig(root) await mkdir(join(root, 'server/jobs/bad'), { recursive: true }) @@ -590,7 +590,9 @@ export default {} }], }) - const runtime = await initializeHolo(root) + const runtime = await initializeHolo(root, { + registerProjectQueueJobs: false, + }) expect(listRegisteredQueueJobs().map(job => job.name)).toEqual(['holo.events.invoke-listener']) diff --git a/packages/events/src/contracts.ts b/packages/events/src/contracts.ts index 27bf4350..ec241d19 100644 --- a/packages/events/src/contracts.ts +++ b/packages/events/src/contracts.ts @@ -3,6 +3,37 @@ export type EventDelayValue = number | Date const HOLO_EVENT_DEFINITION_MARKER = Symbol.for('holo-js.events.definition') const HOLO_LISTENER_DEFINITION_MARKER = Symbol.for('holo-js.events.listener') +type EventDefinitionDispatcher = ( + event: EventDefinition , + payload: TPayload, +) => EventPendingDispatch + +function getEventDefinitionDispatcher(): EventDefinitionDispatcher | undefined { + return (globalThis as typeof globalThis & { + __holoEventDefinitionDispatcher__?: EventDefinitionDispatcher + }).__holoEventDefinitionDispatcher__ +} + +function setEventDefinitionDispatcher(dispatcher: EventDefinitionDispatcher): void { + const runtime = globalThis as typeof globalThis & { + __holoEventDefinitionDispatcher__?: EventDefinitionDispatcher + } + + runtime.__holoEventDefinitionDispatcher__ = dispatcher +} + +function dispatchDefinedEvent ( + event: EventDefinition , + payload: TPayload, +): EventPendingDispatch { + const dispatcher = getEventDefinitionDispatcher() + if (!dispatcher) { + throw new Error('[Holo Events] Event definitions cannot dispatch before the events runtime is loaded.') + } + + return dispatcher(event, payload) +} + function normalizeOptionalString( value: unknown, label: string, @@ -113,6 +144,13 @@ export interface EventDefinition< readonly __payloadType?: TPayload } +export interface DefinedEventDefinition< + TPayload = unknown, + TName extends string | undefined = string | undefined, +> extends EventDefinition { + dispatch(payload: TPayload): EventPendingDispatch +} + export interface RegisteredEvent< TPayload = unknown, TName extends string = string, @@ -300,12 +338,18 @@ export function normalizeEventDefinition (event: export function defineEvent ( event: EventDefinition , -): EventDefinition { - const normalized = { ...normalizeEventDefinition(event) } +): DefinedEventDefinition { + const normalized = { ...normalizeEventDefinition(event) } as DefinedEventDefinition Object.defineProperty(normalized, HOLO_EVENT_DEFINITION_MARKER, { value: true, enumerable: false, }) + Object.defineProperty(normalized, 'dispatch', { + value(payload: TPayload): EventPendingDispatch { + return dispatchDefinedEvent(normalized, payload) + }, + enumerable: false, + }) return Object.freeze(normalized) } @@ -406,6 +450,7 @@ export const eventInternals = { normalizeOptionalBoolean, normalizeOptionalDelay, normalizeOptionalString, + setEventDefinitionDispatcher, normalizeListensTo, normalizeListenerDefinition, toPosixPath, diff --git a/packages/events/src/index.ts b/packages/events/src/index.ts index b36fb4a3..e500a573 100644 --- a/packages/events/src/index.ts +++ b/packages/events/src/index.ts @@ -20,6 +20,7 @@ export type { EventQueuedListenerDispatch, EventRuntimeBinding, EventRuntimeHooks, + DefinedEventDefinition, EventReference, EventReferenceInput, ExportedEventDefinition, diff --git a/packages/events/src/runtime.ts b/packages/events/src/runtime.ts index 61133047..57cbc99e 100644 --- a/packages/events/src/runtime.ts +++ b/packages/events/src/runtime.ts @@ -495,6 +495,8 @@ export function dispatchEvent ( return new PendingEventDispatch(event, payload) } +eventInternals.setEventDefinitionDispatcher(dispatchEvent) + export const Event: EventFacade = { dispatch: dispatchEvent, } diff --git a/packages/events/tests/contracts.test.ts b/packages/events/tests/contracts.test.ts index 93e461f8..3022d8f2 100644 --- a/packages/events/tests/contracts.test.ts +++ b/packages/events/tests/contracts.test.ts @@ -21,8 +21,12 @@ describe('@holo-js/events contracts', () => { expect(event).toEqual({ name: 'user.registered', }) + expect(typeof event.dispatch).toBe('function') + expect(Object.keys(event)).toEqual(['name']) expect(Object.isFrozen(event)).toBe(true) expect(unnamed).toEqual({}) + expect(typeof unnamed.dispatch).toBe('function') + expect(Object.keys(unnamed)).toEqual([]) expect(Object.isFrozen(unnamed)).toBe(true) }) @@ -43,6 +47,26 @@ describe('@holo-js/events contracts', () => { expect(eventInternals.hasListenerDefinitionMarker({ listensTo: ['audit.entry'], async handle() {} })).toBe(false) }) + it('rejects event definition dispatch before the runtime dispatcher is loaded', () => { + const runtime = globalThis as typeof globalThis & { + __holoEventDefinitionDispatcher__?: unknown + } + const existingDispatcher = runtime.__holoEventDefinitionDispatcher__ + const event = defineEvent