Files
makelore/electron/coding-plugins/adapters/data-service.ts

301 lines
10 KiB
TypeScript

import type {
DataServiceCollectionRemoval,
DataServiceDocument,
DataServiceDocumentList,
DataServiceErrorContext,
DataServiceHostResult,
DataServiceInstanceList,
DataServiceInstanceRemoval,
DataServiceInstanceState,
} from '../../../shared/data-service';
import {
DATA_SERVICE_PLUGIN_ID,
type CodingPluginToolDefinition,
} from '../../../shared/coding-plugins';
import type { DataServiceOperations } from '../../services/data-service-client';
import type {
AdapterInvocationResult,
CodingPluginAdapter,
PluginBackendProjection,
TrustedCodingCapabilityContext,
} from '../registry';
const MAX_REQUEST_BYTES = 98_304;
const CONTEXT_KEYS = new Set([
'resource', 'limit', 'current', 'attempted', 'actual', 'current_revision',
]);
const CONTEXT_RESOURCES = new Set(['instances', 'collections', 'documents', 'bytes']);
type InputRecord = Record<string, unknown>;
function isRecord(value: unknown): value is InputRecord {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function boundedContext(value: DataServiceErrorContext | undefined): DataServiceErrorContext | undefined {
if (!value) return undefined;
const projected: DataServiceErrorContext = {};
for (const [key, item] of Object.entries(value)) {
if (!CONTEXT_KEYS.has(key)) continue;
if (key === 'resource') {
if (typeof item !== 'string' || !CONTEXT_RESOURCES.has(item) || item.length > 32) return undefined;
projected[key] = item;
} else if (Number.isSafeInteger(item) && (item as number) >= 0) {
projected[key] = item as number;
}
}
return Object.keys(projected).length > 0 ? projected : undefined;
}
function validRetryAfter(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= 86_400;
}
function boundedError(error: unknown, fallback: string): string {
return typeof error === 'string' && error.trim().length > 0 && error.length <= 2_000
? error
: fallback;
}
function failure(
code: string,
error: string,
status = 422,
retryable = false,
): AdapterInvocationResult {
return {
success: false,
status,
code,
error,
retryable,
payload_schema: 'data-service.v1',
data: null,
};
}
function projectResult<T>(result: DataServiceHostResult<T>): AdapterInvocationResult<T> {
if (result.success) {
return {
success: true,
status: result.status,
code: null,
error: null,
retryable: false,
payload_schema: 'data-service.v1',
data: result.data,
};
}
const context = boundedContext(result.context);
return {
success: false,
status: result.status,
code: result.code ?? 'plugin_backend_unavailable',
error: boundedError(result.error, 'Data Service request was rejected'),
retryable: result.retryable,
...(validRetryAfter(result.retry_after_seconds)
? { retry_after_seconds: result.retry_after_seconds }
: {}),
...(context ? { context } : {}),
payload_schema: 'data-service.v1',
data: null,
};
}
function schemaValid(value: unknown, schema: Readonly<Record<string, unknown>>): boolean {
if (schema.type === 'object') {
if (!isRecord(value)) return false;
const properties = isRecord(schema.properties) ? schema.properties : {};
if (schema.additionalProperties === false
&& Object.keys(value).some((key) => !Object.prototype.hasOwnProperty.call(properties, key))) {
return false;
}
const required = Array.isArray(schema.required) ? schema.required : [];
if (required.some((key) => typeof key !== 'string' || !Object.prototype.hasOwnProperty.call(value, key))) {
return false;
}
return Object.entries(value).every(([key, item]) => {
const child = properties[key];
return !child || (isRecord(child) && schemaValid(item, child));
});
}
if (schema.type === 'array') {
if (!Array.isArray(value)) return false;
if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) return false;
const items = isRecord(schema.items) ? schema.items : null;
return !items || value.every((item) => schemaValid(item, items));
}
if (schema.type === 'string') {
if (typeof value !== 'string') return false;
if (typeof schema.minLength === 'number' && value.length < schema.minLength) return false;
if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) return false;
if (typeof schema.pattern === 'string' && !(new RegExp(schema.pattern, 'u')).test(value)) return false;
return true;
}
if (schema.type === 'integer') {
if (!Number.isSafeInteger(value)) return false;
if (typeof schema.minimum === 'number' && (value as number) < schema.minimum) return false;
if (typeof schema.maximum === 'number' && (value as number) > schema.maximum) return false;
return true;
}
if (schema.type === 'boolean') return typeof value === 'boolean'
&& (schema.const === undefined || value === schema.const);
return true;
}
function boundedDocumentData(value: unknown): boolean {
if (!isRecord(value)) return false;
try {
const encoded = JSON.stringify({ data: value });
return typeof encoded === 'string' && Buffer.byteLength(encoded, 'utf8') <= MAX_REQUEST_BYTES;
} catch {
return false;
}
}
function validateInput(tool: CodingPluginToolDefinition, input: unknown): boolean {
if (!schemaValid(input, tool.inputSchema)) return false;
if (!isRecord(input)) return false;
if (tool.name === 'data_service_put_document' && !boundedDocumentData(input.data)) return false;
return true;
}
type DataServiceResult =
| DataServiceInstanceState
| DataServiceInstanceList
| DataServiceDocument
| DataServiceDocumentList
| DataServiceCollectionRemoval
| DataServiceInstanceRemoval
| null;
async function callDataService(
operations: DataServiceOperations,
toolName: string,
input: InputRecord,
projectPath: string,
): Promise<DataServiceHostResult<DataServiceResult>> {
switch (toolName) {
case 'data_service_configure':
return await operations.configure({ collections: input.collections as string[] }, projectPath);
case 'data_service_inspect':
return await operations.inspect(projectPath);
case 'data_service_list_projects':
return await operations.listProjects();
case 'data_service_get_document':
return await operations.getDocument({
collection: input.collection as string,
document_id: input.document_id as string,
}, projectPath);
case 'data_service_list_documents':
return await operations.listDocuments({
collection: input.collection as string,
...(input.limit === undefined ? {} : { limit: input.limit as number }),
...(input.cursor === undefined ? {} : { cursor: input.cursor as string }),
}, projectPath);
case 'data_service_put_document':
return await operations.putDocument({
collection: input.collection as string,
document_id: input.document_id as string,
data: input.data as Record<string, unknown>,
...(input.if_revision === undefined ? {} : { if_revision: input.if_revision as number }),
}, projectPath);
case 'data_service_delete_document':
return await operations.deleteDocument({
collection: input.collection as string,
document_id: input.document_id as string,
...(input.if_revision === undefined ? {} : { if_revision: input.if_revision as number }),
confirmed: true,
}, projectPath);
case 'data_service_remove_collection':
return await operations.removeCollection({
collection: input.collection as string,
confirmed: true,
}, projectPath);
case 'data_service_reset':
return await operations.reset({ confirmed: true }, projectPath);
case 'data_service_remove_project':
return await operations.removeProject({ confirmed: true }, projectPath);
default:
throw new Error('Data Service tool is unavailable');
}
}
function projection(result: DataServiceHostResult<unknown>): PluginBackendProjection {
if (result.success) return { status: 'ready' };
switch (result.code) {
case 'authentication_required': return { status: 'authentication_required' };
case 'project_identity_required': return { status: 'identity_required' };
case 'active_project_required':
case 'instance_not_found': return { status: 'unconfigured' };
case 'active_project_path_mismatch':
case 'active_project_invalid': return { status: 'degraded', code: result.code, message: 'Data Service project is unavailable', retryable: false };
default:
return {
status: 'degraded',
code: result.code ?? 'plugin_backend_unavailable',
message: 'Data Service is temporarily unavailable',
retryable: result.retryable,
...(validRetryAfter(result.retry_after_seconds)
? { retry_after_seconds: result.retry_after_seconds }
: {}),
};
}
}
export class DataServicePluginAdapter implements CodingPluginAdapter {
readonly pluginId = DATA_SERVICE_PLUGIN_ID;
constructor(private readonly operations: DataServiceOperations) {}
async inspect(projectPath: string): Promise<PluginBackendProjection> {
try {
return projection(await this.operations.inspect(projectPath));
} catch {
return {
status: 'degraded',
code: 'plugin_backend_unavailable',
message: 'Data Service is temporarily unavailable',
retryable: true,
};
}
}
async invoke(
_context: TrustedCodingCapabilityContext,
tool: CodingPluginToolDefinition,
input: unknown,
): Promise<AdapterInvocationResult<DataServiceResult>> {
if (!validateInput(tool, input)) {
return failure('plugin_input_invalid', 'Data Service tool input is invalid');
}
try {
return projectResult(await callDataService(
this.operations,
tool.name,
input as InputRecord,
_context.projectPath,
));
} catch {
return {
success: false,
status: 503,
code: 'plugin_backend_unavailable',
error: 'Data Service is temporarily unavailable',
retryable: true,
payload_schema: 'data-service.v1',
data: null,
};
}
}
async deactivate(_projectPath: string): Promise<void> {
// Data Service deactivation invalidates preview sessions in the dedicated
// lifecycle owner; this adapter owns no independent process or token.
}
}
export const createDataServicePluginAdapter = (
operations: DataServiceOperations,
): DataServicePluginAdapter => new DataServicePluginAdapter(operations);