feat(coding): add Main Data Service Host adapter

This commit is contained in:
2026-08-26 19:10:37 +08:00
parent 003fe210f4
commit c19227a4d4
10 changed files with 1771 additions and 0 deletions

View File

@@ -0,0 +1,304 @@
import { describe, expect, it, vi } from 'vitest';
import {
createDataServiceOperations,
DataServiceCloudClient,
} from '@electron/services/data-service-client';
const projectId = '11111111-1111-4111-8111-111111111111';
function jsonResponse(value: unknown, init: ResponseInit = {}): Response {
const headers = new Headers(init.headers);
headers.set('content-type', 'application/json');
return new Response(JSON.stringify(value), { ...init, headers });
}
function instance(project = projectId) {
return {
instance_id: '22222222-2222-4222-8222-222222222222',
project_id: project,
collections: [],
usage: { document_count: 0, total_bytes: 0 },
limits: {
max_collections: 20,
max_documents: 5000,
max_total_bytes: 20971520,
max_document_bytes: 65536,
list_default_limit: 50,
list_max_limit: 100,
list_max_data_bytes: 1048576,
mutations_per_minute: 120,
},
created_at: '2026-08-26T08:00:00Z',
updated_at: '2026-08-26T08:00:00Z',
};
}
function document() {
return {
id: 'todo-1',
data: { title: 'Ship P0', done: false },
revision: 7,
created_at: '2026-08-26T08:00:00Z',
updated_at: '2026-08-26T08:00:00Z',
};
}
describe('DataServiceCloudClient', () => {
it('projects the control-plane route and direct DTO', async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
jsonResponse(instance(), { status: 200 }),
);
const client = new DataServiceCloudClient({
fetchImpl,
getAccessToken: vi.fn().mockResolvedValue('access-token'),
apiBaseUrl: 'https://square.example/',
});
const result = await client.configure(projectId, ['todos']);
expect(result).toMatchObject({ success: true, status: 200, data: instance() });
expect(fetchImpl).toHaveBeenCalledWith(
`https://square.example/api/data-service/v1/projects/${projectId}`,
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ collections: ['todos'] }),
headers: expect.objectContaining({
Accept: 'application/json',
Authorization: 'Bearer access-token',
'Content-Type': 'application/json',
}),
}),
);
});
it('refreshes and replays exactly once after an authoritative 401', async () => {
const fetchImpl = vi.fn<typeof fetch>()
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(jsonResponse(instance(), { status: 200 }));
const getAccessToken = vi.fn(async (options?: { forceRefresh?: boolean }) => (
options?.forceRefresh ? 'refreshed-token' : 'stale-token'
));
const client = new DataServiceCloudClient({ fetchImpl, getAccessToken });
const result = await client.inspect(projectId);
expect(result.success).toBe(true);
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(getAccessToken).toHaveBeenNthCalledWith(1, { fetchImpl });
expect(getAccessToken).toHaveBeenNthCalledWith(2, { fetchImpl, forceRefresh: true });
expect((fetchImpl.mock.calls[1][1] as RequestInit).headers).toEqual(
expect.objectContaining({ Authorization: 'Bearer refreshed-token' }),
);
});
it('does not replay an ambiguous transport failure', async () => {
const fetchImpl = vi.fn<typeof fetch>().mockRejectedValue(new Error('socket closed'));
const getAccessToken = vi.fn().mockResolvedValue('access-token');
const client = new DataServiceCloudClient({ fetchImpl, getAccessToken });
const result = await client.inspect(projectId);
expect(result).toMatchObject({
success: false,
status: 503,
code: 'data_service_unavailable',
retryable: true,
data: null,
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(getAccessToken).toHaveBeenCalledTimes(1);
});
it('keeps error fields safe while preserving accepted code and context', async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
jsonResponse({
detail: {
code: 'quota_exceeded',
message: 'owner secret must not cross the Host boundary',
retryable: false,
context: {
resource: 'bytes',
limit: 20971520,
current: 20900000,
attempted: 80000,
owner_user_id: 'private',
},
},
}, { status: 409 }),
);
const client = new DataServiceCloudClient({
fetchImpl,
getAccessToken: vi.fn().mockResolvedValue('access-token'),
});
const result = await client.putDocument(projectId, {
collection: 'todos',
document_id: 'todo-1',
data: { done: false },
});
expect(result).toEqual({
success: false,
status: 409,
code: 'quota_exceeded',
error: 'Data Service quota exceeded',
retryable: false,
context: { resource: 'bytes', limit: 20971520, current: 20900000, attempted: 80000 },
data: null,
});
});
it('normalizes an invalid success DTO', async () => {
const client = new DataServiceCloudClient({
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ items: [] })),
getAccessToken: vi.fn().mockResolvedValue('access-token'),
});
const result = await client.listProjects();
expect(result).toEqual({
success: false,
status: 502,
code: 'upstream_invalid_response',
error: 'Data Service returned an invalid response',
retryable: false,
data: null,
});
});
it('rejects a control-plane response for a different project', async () => {
const client = new DataServiceCloudClient({
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(
jsonResponse(instance('33333333-3333-4333-8333-333333333333'), { status: 200 }),
),
getAccessToken: vi.fn().mockResolvedValue('access-token'),
});
const result = await client.inspect(projectId);
expect(result).toMatchObject({
success: false,
status: 502,
code: 'upstream_invalid_response',
data: null,
});
});
it('rejects malformed known error fields', async () => {
const client = new DataServiceCloudClient({
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(
jsonResponse({ detail: { code: 'quota_exceeded', retryable: 'no' } }, { status: 409 }),
),
getAccessToken: vi.fn().mockResolvedValue('access-token'),
});
const result = await client.inspect(projectId);
expect(result).toMatchObject({
success: false,
status: 502,
code: 'upstream_invalid_response',
data: null,
});
});
it('normalizes malformed client errors and all upstream 5xx responses safely', async () => {
const fetchImpl = vi.fn<typeof fetch>()
.mockResolvedValueOnce(new Response(null, { status: 404 }))
.mockResolvedValueOnce(new Response('private upstream exception', { status: 500 }));
const client = new DataServiceCloudClient({
fetchImpl,
getAccessToken: vi.fn().mockResolvedValue('access-token'),
});
const malformed = await client.inspect(projectId);
const unavailable = await client.inspect(projectId);
expect(malformed).toMatchObject({ success: false, status: 502, code: 'upstream_invalid_response', data: null });
expect(unavailable).toMatchObject({
success: false,
status: 503,
code: 'data_service_unavailable',
retryable: true,
data: null,
});
});
it('projects data-plane paths, query parameters, and strong revision headers exactly', async () => {
const fetchImpl = vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse(document(), { status: 200 }))
.mockResolvedValueOnce(jsonResponse({ items: [document()], next_cursor: null, limit: 50 }, { status: 200 }))
.mockResolvedValueOnce(jsonResponse(document(), { status: 200 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
const client = new DataServiceCloudClient({
fetchImpl,
getAccessToken: vi.fn().mockResolvedValue('access-token'),
apiBaseUrl: 'https://square.example',
});
await client.getDocument(projectId, 'todos', 'todo-1');
await client.listDocuments(projectId, 'todos', 50, 'cursor/next');
await client.putDocument(projectId, {
collection: 'todos',
document_id: 'todo-1',
data: document().data,
if_revision: 7,
});
await client.deleteDocument(projectId, { collection: 'todos', document_id: 'todo-1', if_revision: 7 });
expect(fetchImpl.mock.calls.map(([url]) => url)).toEqual([
`https://square.example/api/data/v1/projects/${projectId}/collections/todos/documents/todo-1`,
`https://square.example/api/data/v1/projects/${projectId}/collections/todos/documents?limit=50&cursor=cursor%2Fnext`,
`https://square.example/api/data/v1/projects/${projectId}/collections/todos/documents/todo-1`,
`https://square.example/api/data/v1/projects/${projectId}/collections/todos/documents/todo-1`,
]);
expect((fetchImpl.mock.calls[2][1] as RequestInit).headers).toEqual(
expect.objectContaining({ 'If-Match': '"7"' }),
);
expect((fetchImpl.mock.calls[3][1] as RequestInit).headers).toEqual(
expect.objectContaining({ 'If-Match': '"7"' }),
);
});
it('preserves opaque cursor text when projecting the query', async () => {
const listDocuments = vi.fn<typeof fetch>().mockResolvedValue(
jsonResponse({ items: [], next_cursor: null, limit: 50 }, { status: 200 }),
);
const client = new DataServiceCloudClient({
fetchImpl: listDocuments,
getAccessToken: vi.fn().mockResolvedValue('access-token'),
});
const result = await client.listDocuments(projectId, 'todos', 50, ' cursor ');
expect(result).toMatchObject({ success: true, status: 200 });
expect(listDocuments.mock.calls[0][0]).toContain('cursor=+cursor+');
});
});
describe('DataServiceOperations', () => {
it('derives active project identity for every operation except owner-wide listing', async () => {
const client = {
listProjects: vi.fn().mockResolvedValue({ success: true, status: 200, code: null, error: null, retryable: false, data: { items: [], total: 0, instance_limit: 20 } }),
inspect: vi.fn().mockResolvedValue({ success: true, status: 200, code: null, error: null, retryable: false, data: instance() }),
} as unknown as DataServiceCloudClient;
const requireActiveRealProjectWithIdentity = vi.fn().mockResolvedValue({
project: {},
path: 'C:\\projects\\active',
projectId,
});
const operations = createDataServiceOperations({
projects: { requireActiveRealProjectWithIdentity },
client,
});
await operations.listProjects();
expect(requireActiveRealProjectWithIdentity).not.toHaveBeenCalled();
const unconfirmed = await operations.removeProject({ confirmed: false as true });
expect(unconfirmed).toMatchObject({ success: false, status: 400, code: 'confirmation_required', data: null });
expect(requireActiveRealProjectWithIdentity).not.toHaveBeenCalled();
await operations.inspect();
expect(requireActiveRealProjectWithIdentity).toHaveBeenCalledTimes(1);
expect(client.inspect).toHaveBeenCalledWith(projectId);
});
});

View File

@@ -0,0 +1,159 @@
import { EventEmitter } from 'node:events';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { describe, expect, it, vi } from 'vitest';
import { handleDataServiceRoutes } from '@electron/api/routes/data-service';
import type { HostApiContext } from '@electron/api/context';
import type { DataServiceOperations } from '@electron/services/data-service-client';
function request(
method: string,
body?: unknown,
headers: Record<string, string> = {},
): IncomingMessage {
const req = new EventEmitter();
const raw = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
Object.assign(req, {
method,
headers: {
...(raw === undefined ? {} : { 'content-length': String(Buffer.byteLength(raw, 'utf8')) }),
...headers,
},
[Symbol.asyncIterator]: async function* () {
if (raw !== undefined) yield Buffer.from(raw, 'utf8');
},
});
return req as IncomingMessage;
}
function response() {
const chunks: string[] = [];
const headers = new Map<string, string>();
const res = new EventEmitter();
Object.assign(res, {
statusCode: 0,
setHeader: vi.fn((name: string, value: string) => headers.set(name.toLowerCase(), value)),
end: vi.fn((chunk?: string) => { if (chunk) chunks.push(chunk); }),
});
return {
res: res as unknown as ServerResponse,
get status() { return (res as { statusCode: number }).statusCode; },
header: (name: string) => headers.get(name.toLowerCase()),
json: () => JSON.parse(chunks.join('')) as Record<string, unknown>,
};
}
function success<T>(data: T): { success: true; status: 200; code: null; error: null; retryable: false; data: T } {
return { success: true, status: 200, code: null, error: null, retryable: false, data };
}
function setup(overrides: Partial<Record<keyof DataServiceOperations, ReturnType<typeof vi.fn>>> = {}) {
const operations = {
configure: vi.fn().mockResolvedValue(success({})),
inspect: vi.fn().mockResolvedValue(success({})),
listProjects: vi.fn().mockResolvedValue(success({ items: [], total: 0, instance_limit: 20 })),
getDocument: vi.fn().mockResolvedValue(success({})),
listDocuments: vi.fn().mockResolvedValue(success({ items: [], next_cursor: null, limit: 50 })),
putDocument: vi.fn().mockResolvedValue(success({})),
deleteDocument: vi.fn().mockResolvedValue(success(null)),
removeCollection: vi.fn().mockResolvedValue(success({ removed: true, usage: { document_count: 0, total_bytes: 0 } })),
reset: vi.fn().mockResolvedValue(success({})),
removeProject: vi.fn().mockResolvedValue(success({ removed: true })),
...overrides,
} as unknown as DataServiceOperations;
const ctx = { codingProducts: { dataService: operations } } as unknown as HostApiContext;
return { operations, ctx };
}
async function invoke(
ctx: HostApiContext,
method: string,
path: string,
body?: unknown,
headers: Record<string, string> = {},
) {
const target = response();
const handled = await handleDataServiceRoutes(
request(method, body, headers),
target.res,
new URL(`http://localhost${path}`),
ctx,
);
return { handled, ...target, payload: target.json() };
}
describe('Data Service Host routes', () => {
it('uses the owner-wide listing without an active-project argument', async () => {
const { operations, ctx } = setup();
const result = await invoke(ctx, 'GET', '/api/works/data-service/projects');
expect(result.handled).toBe(true);
expect(result.status).toBe(200);
expect(result.header('cache-control')).toBe('private, no-store');
expect(operations.listProjects).toHaveBeenCalledOnce();
expect(result.payload).toMatchObject({ success: true, data: { total: 0 } });
});
it('passes only strict collection input to active-project configure', async () => {
const { operations, ctx } = setup();
await invoke(ctx, 'PUT', '/api/works/data-service/project', { collections: ['todos', 'settings'] }, {
'content-type': 'application/json',
});
expect(operations.configure).toHaveBeenCalledWith({ collections: ['todos', 'settings'] });
});
it('requires the literal confirmation before removing the active project', async () => {
const { operations, ctx } = setup();
const rejected = await invoke(ctx, 'DELETE', '/api/works/data-service/project');
const accepted = await invoke(ctx, 'DELETE', '/api/works/data-service/project?confirmed=true');
expect(rejected.payload).toMatchObject({ success: false, status: 400, code: 'confirmation_required', data: null });
expect(operations.removeProject).toHaveBeenCalledOnce();
expect(operations.removeProject).toHaveBeenCalledWith({ confirmed: true });
expect(accepted.payload).toMatchObject({ success: true, status: 200 });
});
it('projects a document precondition and rejects extra request fields locally', async () => {
const { operations, ctx } = setup();
await invoke(
ctx,
'PUT',
'/api/works/data-service/project/collections/todos/documents/todo-1',
{ data: { done: false } },
{ 'content-type': 'application/json', 'if-match': '"7"' },
);
const rejected = await invoke(
ctx,
'PUT',
'/api/works/data-service/project/collections/todos/documents/todo-2',
{ data: {}, extra: true },
{ 'content-type': 'application/json' },
);
expect(operations.putDocument).toHaveBeenCalledWith({
collection: 'todos',
document_id: 'todo-1',
data: { done: false },
if_revision: 7,
});
expect(rejected.payload).toMatchObject({ success: false, status: 422, code: 'invalid_request', data: null });
expect(operations.putDocument).toHaveBeenCalledOnce();
});
it('keeps malformed paths and unsupported query keys out of the operations adapter', async () => {
const { operations, ctx } = setup();
const result = await invoke(
ctx,
'GET',
'/api/works/data-service/project/collections/todos/documents?limit=101&unexpected=x',
);
expect(result.payload).toMatchObject({ success: false, status: 422, code: 'invalid_request', data: null });
expect(operations.listDocuments).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,18 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('Data Service Host API registration', () => {
it('registers before the existing Works catch-all', async () => {
const source = await readFile(resolve('electron/api/route-handlers.ts'), 'utf8');
expect(source).toMatch(
/import\s+\{\s*handleDataServiceRoutes\s*\}\s+from\s+['"]\.\/routes\/data-service['"]/,
);
const routeList = source.match(/hostApiRouteHandlers[^=]*=\s*\[([\s\S]*?)\];/);
expect(routeList?.[1]).toBeDefined();
const dataServiceIndex = routeList?.[1].indexOf('handleDataServiceRoutes') ?? -1;
const worksIndex = routeList?.[1].indexOf('handleWorksRoutes') ?? -1;
expect(dataServiceIndex).toBeGreaterThanOrEqual(0);
expect(worksIndex).toBeGreaterThan(dataServiceIndex);
});
});