Skip to content

Commit 77086c2

Browse files
Copilothotlong
andcommitted
fix: resolve async protocol service and add comprehensive getServiceAsync tests
- Fixed 6 occurrences of this.kernel.context.getService('protocol') in handleMetadata and handleUi — these used the sync path that throws for async factory services, same root cause as auth/analytics. - Introduced resolveService() helper that all service lookups now use: getServiceAsync → getService → context.getService → services Map with null-check fallthrough at each step. - Simplified getObjectQLService() to use resolveService() internally. - Added 7 new dispatcher tests for getServiceAsync preferred path covering analytics, auth, automation, file-storage, protocol, null fallthrough, and error fallthrough. - Added getServiceAsync tests for Hono, Next.js, and SvelteKit adapters. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent eda02c8 commit 77086c2

5 files changed

Lines changed: 236 additions & 35 deletions

File tree

packages/adapters/hono/src/hono.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,43 @@ describe('createHonoApp', () => {
198198
expect(res.status).toBe(200);
199199
expect(mockDispatcher.handleAuth).toHaveBeenCalled();
200200
});
201+
202+
it('uses kernel.getServiceAsync("auth") when available (async factory)', async () => {
203+
const mockHandleRequest = vi.fn().mockResolvedValue(
204+
new Response(JSON.stringify({ user: { id: '2' } }), {
205+
status: 200,
206+
headers: { 'Content-Type': 'application/json' },
207+
}),
208+
);
209+
const kernelWithAsyncAuth = {
210+
...mockKernel,
211+
getServiceAsync: vi.fn().mockResolvedValue({ handleRequest: mockHandleRequest }),
212+
};
213+
const authApp = createHonoApp({ kernel: kernelWithAsyncAuth });
214+
const res = await authApp.request('/api/auth/sign-in/email', {
215+
method: 'POST',
216+
headers: { 'Content-Type': 'application/json' },
217+
body: JSON.stringify({ email: 'a@b.com', password: 'pass' }),
218+
});
219+
expect(res.status).toBe(200);
220+
expect(kernelWithAsyncAuth.getServiceAsync).toHaveBeenCalledWith('auth');
221+
expect(mockHandleRequest).toHaveBeenCalled();
222+
});
223+
224+
it('falls back to dispatcher when getServiceAsync throws (async factory error)', async () => {
225+
const kernelWithFailingAsync = {
226+
...mockKernel,
227+
getServiceAsync: vi.fn().mockRejectedValue(new Error("Service 'auth' not found")),
228+
};
229+
const authApp = createHonoApp({ kernel: kernelWithFailingAsync });
230+
const res = await authApp.request('/api/auth/login', {
231+
method: 'POST',
232+
headers: { 'Content-Type': 'application/json' },
233+
body: JSON.stringify({ email: 'a@b.com' }),
234+
});
235+
expect(res.status).toBe(200);
236+
expect(mockDispatcher.handleAuth).toHaveBeenCalled();
237+
});
201238
});
202239

203240
describe('GraphQL Endpoint', () => {

packages/adapters/nextjs/src/nextjs.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,37 @@ describe('createRouteHandler', () => {
213213
expect(res.status).toBe(500);
214214
expect(res.body.success).toBe(false);
215215
});
216+
217+
it('uses kernel.getServiceAsync("auth") when available (async factory)', async () => {
218+
const mockHandleRequest = vi.fn().mockResolvedValue(
219+
new Response(JSON.stringify({ user: { id: '2' } }), {
220+
status: 200,
221+
headers: { 'Content-Type': 'application/json' },
222+
}),
223+
);
224+
const kernelWithAsyncAuth = {
225+
...mockKernel,
226+
getServiceAsync: vi.fn().mockResolvedValue({ handleRequest: mockHandleRequest }),
227+
};
228+
const handler = createRouteHandler({ kernel: kernelWithAsyncAuth });
229+
const req = makeReq('http://localhost/api/auth/sign-in/email', 'POST', { email: 'a@b.com', password: 'pass' });
230+
const res = await handler(req, { params: { objectstack: ['auth', 'sign-in', 'email'] } });
231+
expect(res.status).toBe(200);
232+
expect(kernelWithAsyncAuth.getServiceAsync).toHaveBeenCalledWith('auth');
233+
expect(mockHandleRequest).toHaveBeenCalled();
234+
});
235+
236+
it('falls back to dispatcher when getServiceAsync throws', async () => {
237+
const kernelWithFailingAsync = {
238+
...mockKernel,
239+
getServiceAsync: vi.fn().mockRejectedValue(new Error("Service 'auth' not found")),
240+
};
241+
const handler = createRouteHandler({ kernel: kernelWithFailingAsync });
242+
const req = makeReq('http://localhost/api/auth/login', 'POST', { email: 'a@b.com' });
243+
const res = await handler(req, { params: { objectstack: ['auth', 'login'] } });
244+
expect(res.status).toBe(200);
245+
expect(mockDispatcher.handleAuth).toHaveBeenCalled();
246+
});
216247
});
217248

218249
describe('GraphQL Endpoint', () => {

packages/adapters/sveltekit/src/sveltekit.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,37 @@ describe('createRequestHandler', () => {
119119
expect(kernelWithAuth.getService).toHaveBeenCalledWith('auth');
120120
expect(mockHandleRequest).toHaveBeenCalled();
121121
});
122+
123+
it('uses kernel.getServiceAsync("auth") when available (async factory)', async () => {
124+
const mockHandleRequest = vi.fn().mockResolvedValue(
125+
new Response(JSON.stringify({ user: { id: '2' } }), {
126+
status: 200,
127+
headers: { 'Content-Type': 'application/json' },
128+
}),
129+
);
130+
const kernelWithAsyncAuth = {
131+
...mockKernel,
132+
getServiceAsync: vi.fn().mockResolvedValue({ handleRequest: mockHandleRequest }),
133+
};
134+
const authHandler = createRequestHandler({ kernel: kernelWithAsyncAuth });
135+
const event = makeEvent('http://localhost/api/auth/sign-in/email', 'POST', { email: 'a@b.com' });
136+
const res = await authHandler(event);
137+
expect(res.status).toBe(200);
138+
expect(kernelWithAsyncAuth.getServiceAsync).toHaveBeenCalledWith('auth');
139+
expect(mockHandleRequest).toHaveBeenCalled();
140+
});
141+
142+
it('falls back to dispatcher when getServiceAsync throws', async () => {
143+
const kernelWithFailingAsync = {
144+
...mockKernel,
145+
getServiceAsync: vi.fn().mockRejectedValue(new Error("Service 'auth' not found")),
146+
};
147+
const authHandler = createRequestHandler({ kernel: kernelWithFailingAsync });
148+
const event = makeEvent('http://localhost/api/auth/login', 'POST', { email: 'a@b.com' });
149+
const res = await authHandler(event);
150+
expect(res.status).toBe(200);
151+
expect(mockDispatcher.handleAuth).toHaveBeenCalled();
152+
});
122153
});
123154

124155
describe('GraphQL', () => {

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,108 @@ describe('HttpDispatcher', () => {
439439
});
440440
});
441441

442+
// ═══════════════════════════════════════════════════════════════
443+
// getServiceAsync preferred path
444+
// ═══════════════════════════════════════════════════════════════
445+
446+
describe('getServiceAsync preferred path', () => {
447+
it('should prefer getServiceAsync over getService for analytics', async () => {
448+
const asyncAnalytics = {
449+
query: vi.fn().mockResolvedValue({ rows: [1], total: 1 }),
450+
};
451+
(kernel as any).getServiceAsync = vi.fn().mockResolvedValue(asyncAnalytics);
452+
(kernel as any).getService = vi.fn().mockImplementation(() => {
453+
throw new Error("Service 'analytics' is async - use await");
454+
});
455+
456+
const result = await dispatcher.handleAnalytics('query', 'POST', {}, { request: {} });
457+
expect(result.handled).toBe(true);
458+
expect(asyncAnalytics.query).toHaveBeenCalled();
459+
expect((kernel as any).getServiceAsync).toHaveBeenCalledWith('analytics');
460+
});
461+
462+
it('should prefer getServiceAsync over getService for auth', async () => {
463+
const asyncAuth = {
464+
handler: vi.fn().mockResolvedValue({ user: { id: '1' } }),
465+
};
466+
(kernel as any).getServiceAsync = vi.fn().mockResolvedValue(asyncAuth);
467+
(kernel as any).getService = vi.fn().mockImplementation(() => {
468+
throw new Error("Service 'auth' is async - use await");
469+
});
470+
471+
const result = await dispatcher.handleAuth('', 'POST', {}, { request: {}, response: {} });
472+
expect(result.handled).toBe(true);
473+
expect(asyncAuth.handler).toHaveBeenCalled();
474+
expect((kernel as any).getServiceAsync).toHaveBeenCalledWith('auth');
475+
});
476+
477+
it('should prefer getServiceAsync over getService for automation', async () => {
478+
const asyncAuto = {
479+
listFlows: vi.fn().mockResolvedValue(['flow_async']),
480+
};
481+
(kernel as any).getServiceAsync = vi.fn().mockResolvedValue(asyncAuto);
482+
483+
const result = await dispatcher.handleAutomation('', 'GET', {}, { request: {} });
484+
expect(result.handled).toBe(true);
485+
expect(result.response?.body?.data?.flows).toEqual(['flow_async']);
486+
expect((kernel as any).getServiceAsync).toHaveBeenCalledWith('automation');
487+
});
488+
489+
it('should prefer getServiceAsync over getService for file-storage', async () => {
490+
const asyncStorage = {
491+
upload: vi.fn().mockResolvedValue({ id: 'file_1', url: '/files/1' }),
492+
};
493+
(kernel as any).getServiceAsync = vi.fn().mockResolvedValue(asyncStorage);
494+
495+
const result = await dispatcher.handleStorage('/upload', 'POST', { name: 'test.txt' }, { request: {} });
496+
expect(result.handled).toBe(true);
497+
expect(result.response?.status).toBe(200);
498+
expect((kernel as any).getServiceAsync).toHaveBeenCalledWith('file-storage');
499+
});
500+
501+
it('should resolve protocol service via getServiceAsync for handleMetadata', async () => {
502+
const asyncProtocol = {
503+
saveMetaItem: vi.fn().mockResolvedValue({ success: true }),
504+
};
505+
(kernel as any).getServiceAsync = vi.fn().mockImplementation((name: string) => {
506+
if (name === 'protocol') return Promise.resolve(asyncProtocol);
507+
return Promise.resolve(null);
508+
});
509+
// Remove context.getService to ensure getServiceAsync is used
510+
(kernel as any).context = {};
511+
512+
const result = await dispatcher.handleMetadata('/objects/my_obj', { request: {} }, 'PUT', { label: 'Test' });
513+
expect(result.handled).toBe(true);
514+
expect(result.response?.status).toBe(200);
515+
expect(asyncProtocol.saveMetaItem).toHaveBeenCalled();
516+
expect((kernel as any).getServiceAsync).toHaveBeenCalledWith('protocol');
517+
});
518+
519+
it('should fall through when getServiceAsync returns null', async () => {
520+
(kernel as any).getServiceAsync = vi.fn().mockResolvedValue(null);
521+
const syncAnalytics = {
522+
query: vi.fn().mockResolvedValue({ rows: [], total: 0 }),
523+
};
524+
(kernel as any).services = new Map([['analytics', syncAnalytics]]);
525+
526+
const result = await dispatcher.handleAnalytics('query', 'POST', {}, { request: {} });
527+
expect(result.handled).toBe(true);
528+
expect(syncAnalytics.query).toHaveBeenCalled();
529+
});
530+
531+
it('should fall through when getServiceAsync throws', async () => {
532+
(kernel as any).getServiceAsync = vi.fn().mockRejectedValue(new Error('not found'));
533+
const syncAnalytics = {
534+
query: vi.fn().mockResolvedValue({ rows: [], total: 0 }),
535+
};
536+
(kernel as any).services = new Map([['analytics', syncAnalytics]]);
537+
538+
const result = await dispatcher.handleAnalytics('query', 'POST', {}, { request: {} });
539+
expect(result.handled).toBe(true);
540+
expect(syncAnalytics.query).toHaveBeenCalled();
541+
});
542+
});
543+
442544
// ═══════════════════════════════════════════════════════════════
443545
// handleData — expand/populate parameter flow
444546
// ═══════════════════════════════════════════════════════════════

packages/runtime/src/http-dispatcher.ts

Lines changed: 35 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ export class HttpDispatcher {
202202
// GET /metadata/types
203203
if (parts[0] === 'types') {
204204
// Try protocol service for dynamic types
205-
const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
205+
const protocol = await this.resolveService('protocol');
206206
if (protocol && typeof protocol.getMetaTypes === 'function') {
207207
const result = await protocol.getMetaTypes({});
208208
return { handled: true, response: this.success(result) };
@@ -242,7 +242,7 @@ export class HttpDispatcher {
242242
// PUT /metadata/:type/:name (Save)
243243
if (method === 'PUT' && body) {
244244
// Try to get the protocol service directly
245-
const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
245+
const protocol = await this.resolveService('protocol');
246246

247247
if (protocol && typeof protocol.saveMetaItem === 'function') {
248248
try {
@@ -275,7 +275,7 @@ export class HttpDispatcher {
275275
const singularType = type.endsWith('s') ? type.slice(0, -1) : type;
276276

277277
// Try Protocol Service First (Preferred)
278-
const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
278+
const protocol = await this.resolveService('protocol');
279279
if (protocol && typeof protocol.getMetaItem === 'function') {
280280
try {
281281
const data = await protocol.getMetaItem({ type: singularType, name });
@@ -304,7 +304,7 @@ export class HttpDispatcher {
304304
const packageId = query?.package || undefined;
305305

306306
// Try protocol service first for any type
307-
const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
307+
const protocol = await this.resolveService('protocol');
308308
if (protocol && typeof protocol.getMetaItems === 'function') {
309309
try {
310310
const data = await protocol.getMetaItems({ type: typeOrName, packageId });
@@ -343,7 +343,7 @@ export class HttpDispatcher {
343343
// GET /metadata — return available metadata types
344344
if (parts.length === 0) {
345345
// Try protocol service for dynamic types
346-
const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
346+
const protocol = await this.resolveService('protocol');
347347
if (protocol && typeof protocol.getMetaTypes === 'function') {
348348
const result = await protocol.getMetaTypes({});
349349
return { handled: true, response: this.success(result) };
@@ -708,7 +708,7 @@ export class HttpDispatcher {
708708
// Support both path param /view/obj/list AND query param /view/obj?type=list
709709
const type = parts[2] || query?.type || 'list';
710710

711-
const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
711+
const protocol = await this.resolveService('protocol');
712712

713713
if (protocol && typeof protocol.getUiView === 'function') {
714714
try {
@@ -852,19 +852,38 @@ export class HttpDispatcher {
852852
}
853853

854854
private async getService(name: CoreServiceName) {
855-
// Prefer async resolution to support factory-based services (e.g. auth, analytics)
855+
return this.resolveService(name);
856+
}
857+
858+
/**
859+
* Resolve any service by name, supporting async factories.
860+
* Fallback chain: getServiceAsync → getService (sync) → context.getService → services map.
861+
* Only returns when a non-null service is found; otherwise falls through to the next step.
862+
*/
863+
private async resolveService(name: string) {
864+
// Prefer async resolution to support factory-based services (e.g. auth, analytics, protocol)
856865
if (typeof this.kernel.getServiceAsync === 'function') {
857866
try {
858-
return await this.kernel.getServiceAsync(name);
867+
const svc = await this.kernel.getServiceAsync(name);
868+
if (svc != null) return svc;
859869
} catch {
860-
// Service not registered or async resolution failed — fall through to sync/map lookup
870+
// Service not registered or async resolution failed — fall through
861871
}
862872
}
863873
if (typeof this.kernel.getService === 'function') {
864874
try {
865-
return await this.kernel.getService(name);
875+
const svc = await this.kernel.getService(name);
876+
if (svc != null) return svc;
877+
} catch {
878+
// Service not registered or sync resolution threw "is async" — fall through
879+
}
880+
}
881+
if (this.kernel?.context?.getService) {
882+
try {
883+
const svc = await this.kernel.context.getService(name);
884+
if (svc != null) return svc;
866885
} catch {
867-
// Service not registered or sync resolution threw "is async" — fall through to map lookup
886+
// Service not registered — fall through
868887
}
869888
}
870889
const services = this.getServicesMap();
@@ -876,30 +895,11 @@ export class HttpDispatcher {
876895
* Tries multiple access patterns since kernel structure varies.
877896
*/
878897
private async getObjectQLService(): Promise<any> {
879-
// 1. Try via kernel.getServiceAsync (supports factory-based services)
880-
if (typeof this.kernel.getServiceAsync === 'function') {
881-
try {
882-
const svc = await this.kernel.getServiceAsync('objectql');
883-
if (svc?.registry) return svc;
884-
} catch { /* service not registered or not yet available */ }
885-
}
886-
// 2. Try via kernel.getService (sync fallback)
887-
if (typeof this.kernel.getService === 'function') {
888-
try {
889-
const svc = await this.kernel.getService('objectql');
890-
if (svc?.registry) return svc;
891-
} catch { /* ignore */ }
892-
}
893-
// 3. Try via kernel context
894-
if (this.kernel?.context?.getService) {
895-
try {
896-
const svc = await this.kernel.context.getService('objectql');
897-
if (svc?.registry) return svc;
898-
} catch { /* ignore */ }
899-
}
900-
// 4. Try via services map
901-
const services = this.getServicesMap();
902-
if (services['objectql']?.registry) return services['objectql'];
898+
// 1. Try via resolveService (handles async factories, sync, context, and map)
899+
try {
900+
const svc = await this.resolveService('objectql');
901+
if (svc?.registry) return svc;
902+
} catch { /* service not available */ }
903903
return null;
904904
}
905905

0 commit comments

Comments
 (0)