feat(coding): add Main Data Service Host adapter
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
||||
refreshCodingProviderCredential,
|
||||
} from './coding-provider-auth';
|
||||
import { createCodingProductHost, type CodingProductComposition } from './coding-product-services';
|
||||
import { createDataServiceOperations } from '../services/data-service-client';
|
||||
import { archivePiConversationSession } from '../coding-runtime/pi/resource-loader';
|
||||
import { resolveLegacyProjectModel } from '../coding-projects/legacy-v1';
|
||||
|
||||
@@ -205,8 +206,10 @@ export function createCodingComposition(
|
||||
productTools,
|
||||
listPiCommands: (conversationId) => conversations.listLiveCommands(conversationId),
|
||||
});
|
||||
const dataService = createDataServiceOperations({ projects });
|
||||
return {
|
||||
attachments,
|
||||
dataService,
|
||||
productTools,
|
||||
projects,
|
||||
conversations,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import type { CodingConversationService } from '../coding-runtime/conversation-service';
|
||||
import type { CodingConversationRuntime } from '../coding-runtime/contracts';
|
||||
import type { PiProductTools } from '../coding-runtime/pi/product-tools';
|
||||
import type { DataServiceOperations } from '../services/data-service-client';
|
||||
|
||||
export interface ActiveCodingProject {
|
||||
id: string;
|
||||
@@ -35,6 +36,7 @@ export interface CodingProductHost {
|
||||
|
||||
export interface CodingProductComposition {
|
||||
attachments: CodingAttachmentStore;
|
||||
dataService: DataServiceOperations;
|
||||
productTools: PiProductTools;
|
||||
projects: CodingProjectService;
|
||||
conversations: CodingConversationService;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { handleAuthRoutes } from './routes/auth';
|
||||
import { handleImageWorkspaceRoutes } from './routes/image-workspace';
|
||||
import { handleImagePromptMuseumRoutes } from './routes/image-prompt-museum';
|
||||
import { handleLearningRoutes } from './routes/learning';
|
||||
import { handleDataServiceRoutes } from './routes/data-service';
|
||||
import { handleWorksRoutes } from './routes/works';
|
||||
import { handleUserSyncRoutes } from './routes/user-sync';
|
||||
import { handleSettingsRoutes } from './routes/settings';
|
||||
@@ -42,6 +43,7 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [
|
||||
handleImageWorkspaceRoutes,
|
||||
handleImagePromptMuseumRoutes,
|
||||
handleLearningRoutes,
|
||||
handleDataServiceRoutes,
|
||||
handleWorksRoutes,
|
||||
handleAgentBrowserRoutes,
|
||||
handleUserSyncRoutes,
|
||||
|
||||
335
electron/api/routes/data-service.ts
Normal file
335
electron/api/routes/data-service.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { sendJson } from '../route-utils';
|
||||
import {
|
||||
type DataServiceErrorContext,
|
||||
type DataServiceHostResult,
|
||||
} from '../../../shared/data-service';
|
||||
|
||||
const LOCAL_ROOT = '/api/works/data-service';
|
||||
const MAX_REQUEST_BYTES = 98_304;
|
||||
const MAX_CURSOR_LENGTH = 1_024;
|
||||
const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/;
|
||||
const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/;
|
||||
|
||||
class DataServiceRouteError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly retryable = false,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function boundedString(value: unknown, maximum: number): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
return value && value.length <= maximum ? value : null;
|
||||
}
|
||||
|
||||
function routeFailure<T>(
|
||||
status: number,
|
||||
code: string,
|
||||
error: string,
|
||||
retryable = false,
|
||||
extra: { retry_after_seconds?: number; context?: DataServiceErrorContext } = {},
|
||||
): DataServiceHostResult<T> {
|
||||
return {
|
||||
success: false,
|
||||
status,
|
||||
code,
|
||||
error,
|
||||
retryable,
|
||||
...(extra.retry_after_seconds === undefined ? {} : { retry_after_seconds: extra.retry_after_seconds }),
|
||||
...(extra.context === undefined ? {} : { context: extra.context }),
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
function unavailable<T>(): DataServiceHostResult<T> {
|
||||
return routeFailure(503, 'data_service_unavailable', 'Data Service is temporarily unavailable', true);
|
||||
}
|
||||
|
||||
function methodNotAllowed<T>(): DataServiceHostResult<T> {
|
||||
return routeFailure(405, 'method_not_allowed', 'Data Service method is not allowed');
|
||||
}
|
||||
|
||||
async function readBoundedJson(req: IncomingMessage): Promise<Record<string, unknown>> {
|
||||
const declared = req.headers['content-length'];
|
||||
const declaredValue = Array.isArray(declared) ? declared[0] : declared;
|
||||
if (declaredValue && /^\d+$/.test(declaredValue) && Number(declaredValue) > MAX_REQUEST_BYTES) {
|
||||
throw new DataServiceRouteError(413, 'request_too_large', 'Data Service request is too large');
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
size += buffer.byteLength;
|
||||
if (size > MAX_REQUEST_BYTES) {
|
||||
throw new DataServiceRouteError(413, 'request_too_large', 'Data Service request is too large');
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
|
||||
if (!isRecord(value)) throw new Error('object required');
|
||||
return value;
|
||||
} catch {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function requireExactKeys(value: Record<string, unknown>, keys: readonly string[]): void {
|
||||
const expected = new Set(keys);
|
||||
if (Object.keys(value).some((key) => !expected.has(key))
|
||||
|| keys.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function decodeSegment(value: string, maximum: number, pattern: RegExp): string {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(value);
|
||||
} catch {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
if (decoded.length > maximum || !pattern.test(decoded)) {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function parseCollection(value: string): string {
|
||||
return decodeSegment(value, 48, COLLECTION_PATTERN);
|
||||
}
|
||||
|
||||
function parseDocumentId(value: string): string {
|
||||
const documentId = decodeSegment(value, 128, DOCUMENT_ID_PATTERN);
|
||||
if (documentId === '.' || documentId === '..') {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
return documentId;
|
||||
}
|
||||
|
||||
function requireQueryKeys(url: URL, allowed: readonly string[]): void {
|
||||
const accepted = new Set(allowed);
|
||||
if ([...url.searchParams.keys()].some((key) => !accepted.has(key))) {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function requireConfirmed(url: URL): void {
|
||||
requireQueryKeys(url, ['confirmed']);
|
||||
if (url.searchParams.getAll('confirmed').length !== 1
|
||||
|| url.searchParams.get('confirmed') !== 'true') {
|
||||
throw new DataServiceRouteError(
|
||||
400,
|
||||
'confirmation_required',
|
||||
'Explicit confirmation is required for this Data Service operation',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseLimit(url: URL): number | undefined {
|
||||
const values = url.searchParams.getAll('limit');
|
||||
if (values.length === 0) return undefined;
|
||||
if (values.length !== 1 || !/^[1-9]\d*$/.test(values[0])) {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
const limit = Number(values[0]);
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
function parseCursor(url: URL): string | undefined {
|
||||
const values = url.searchParams.getAll('cursor');
|
||||
if (values.length === 0) return undefined;
|
||||
const cursor = values.length === 1 ? boundedString(values[0], MAX_CURSOR_LENGTH) : null;
|
||||
if (!cursor) throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function parseIfMatch(req: IncomingMessage): number | undefined {
|
||||
const value = req.headers['if-match'];
|
||||
if (value === undefined) return undefined;
|
||||
if (Array.isArray(value) || !/^"[1-9]\d*"$/.test(value)) {
|
||||
throw new DataServiceRouteError(422, 'invalid_revision', 'Data Service document revision is invalid');
|
||||
}
|
||||
const revision = Number(value.slice(1, -1));
|
||||
if (!Number.isSafeInteger(revision) || revision < 1) {
|
||||
throw new DataServiceRouteError(422, 'invalid_revision', 'Data Service document revision is invalid');
|
||||
}
|
||||
return revision;
|
||||
}
|
||||
|
||||
function parseCollections(value: unknown): string[] {
|
||||
if (!Array.isArray(value) || value.length > 20
|
||||
|| value.some((item) => typeof item !== 'string' || !COLLECTION_PATTERN.test(item))) {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
function parseData(value: unknown): Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sendResult<T>(res: ServerResponse, result: DataServiceHostResult<T>): void {
|
||||
res.setHeader('Cache-Control', 'private, no-store');
|
||||
// Host IPC and the browser fallback both consume the safe envelope. Keep the
|
||||
// transport status successful so a remote Data Service status cannot be
|
||||
// mistaken for a Host transport failure by the existing proxy.
|
||||
sendJson(res, 200, result);
|
||||
}
|
||||
|
||||
function isDataServicePath(pathname: string): boolean {
|
||||
return pathname === LOCAL_ROOT || pathname.startsWith(`${LOCAL_ROOT}/`);
|
||||
}
|
||||
|
||||
export async function handleDataServiceRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (!isDataServicePath(url.pathname)) return false;
|
||||
|
||||
const operations = ctx.codingProducts?.dataService;
|
||||
if (!operations) {
|
||||
sendResult(res, unavailable());
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const method = (req.method ?? 'GET').toUpperCase();
|
||||
const projectListPath = `${LOCAL_ROOT}/projects`;
|
||||
const projectPath = `${LOCAL_ROOT}/project`;
|
||||
if (url.pathname === projectListPath) {
|
||||
requireQueryKeys(url, []);
|
||||
if (method !== 'GET') {
|
||||
sendResult(res, methodNotAllowed());
|
||||
} else {
|
||||
sendResult(res, await operations.listProjects());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === projectPath) {
|
||||
if (method === 'PUT') {
|
||||
requireQueryKeys(url, []);
|
||||
const body = await readBoundedJson(req);
|
||||
requireExactKeys(body, ['collections']);
|
||||
sendResult(res, await operations.configure({ collections: parseCollections(body.collections) }));
|
||||
} else if (method === 'GET') {
|
||||
requireQueryKeys(url, []);
|
||||
sendResult(res, await operations.inspect());
|
||||
} else if (method === 'DELETE') {
|
||||
requireConfirmed(url);
|
||||
sendResult(res, await operations.removeProject({ confirmed: true }));
|
||||
} else {
|
||||
sendResult(res, methodNotAllowed());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === `${projectPath}/reset`) {
|
||||
if (method !== 'POST') {
|
||||
sendResult(res, methodNotAllowed());
|
||||
} else {
|
||||
requireConfirmed(url);
|
||||
sendResult(res, await operations.reset({ confirmed: true }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const collectionPath = url.pathname.match(
|
||||
new RegExp(`^${LOCAL_ROOT}/project/collections/([^/]+)$`),
|
||||
);
|
||||
if (collectionPath) {
|
||||
if (method !== 'DELETE') {
|
||||
sendResult(res, methodNotAllowed());
|
||||
} else {
|
||||
requireConfirmed(url);
|
||||
sendResult(res, await operations.removeCollection({
|
||||
collection: parseCollection(collectionPath[1]),
|
||||
confirmed: true,
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const documentCollectionPath = url.pathname.match(
|
||||
new RegExp(`^${LOCAL_ROOT}/project/collections/([^/]+)/documents$`),
|
||||
);
|
||||
if (documentCollectionPath) {
|
||||
const collection = parseCollection(documentCollectionPath[1]);
|
||||
if (method !== 'GET') {
|
||||
sendResult(res, methodNotAllowed());
|
||||
} else {
|
||||
requireQueryKeys(url, ['limit', 'cursor']);
|
||||
sendResult(res, await operations.listDocuments({
|
||||
collection,
|
||||
limit: parseLimit(url),
|
||||
cursor: parseCursor(url),
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const documentPath = url.pathname.match(
|
||||
new RegExp(`^${LOCAL_ROOT}/project/collections/([^/]+)/documents/([^/]+)$`),
|
||||
);
|
||||
if (documentPath) {
|
||||
const collection = parseCollection(documentPath[1]);
|
||||
const documentId = parseDocumentId(documentPath[2]);
|
||||
if (method === 'GET') {
|
||||
requireQueryKeys(url, []);
|
||||
sendResult(res, await operations.getDocument({ collection, document_id: documentId }));
|
||||
} else if (method === 'PUT') {
|
||||
requireQueryKeys(url, []);
|
||||
const body = await readBoundedJson(req);
|
||||
requireExactKeys(body, ['data']);
|
||||
const ifRevision = parseIfMatch(req);
|
||||
sendResult(res, await operations.putDocument({
|
||||
collection,
|
||||
document_id: documentId,
|
||||
data: parseData(body.data),
|
||||
...(ifRevision === undefined ? {} : { if_revision: ifRevision }),
|
||||
}));
|
||||
} else if (method === 'DELETE') {
|
||||
requireConfirmed(url);
|
||||
const ifRevision = parseIfMatch(req);
|
||||
sendResult(res, await operations.deleteDocument({
|
||||
collection,
|
||||
document_id: documentId,
|
||||
...(ifRevision === undefined ? {} : { if_revision: ifRevision }),
|
||||
confirmed: true,
|
||||
}));
|
||||
} else {
|
||||
sendResult(res, methodNotAllowed());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
sendResult(res, routeFailure(404, 'route_not_found', 'Data Service route was not found'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof DataServiceRouteError) {
|
||||
sendResult(res, routeFailure(error.status, error.code, error.message, error.retryable));
|
||||
return true;
|
||||
}
|
||||
sendResult(res, unavailable());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const handleDataServiceRoute = handleDataServiceRoutes;
|
||||
767
electron/services/data-service-client.ts
Normal file
767
electron/services/data-service-client.ts
Normal file
@@ -0,0 +1,767 @@
|
||||
import {
|
||||
CodingProjectServiceError,
|
||||
type CodingProjectService,
|
||||
} from '../coding-projects/project-service';
|
||||
import {
|
||||
getValidWorksSquareAccessToken,
|
||||
} from './works-square-session';
|
||||
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import {
|
||||
type DataServiceCollectionRemoval,
|
||||
type DataServiceCollectionTargetInput,
|
||||
type DataServiceConfirmationInput,
|
||||
type DataServiceDocument,
|
||||
type DataServiceDocumentList,
|
||||
type DataServiceDocumentTargetInput,
|
||||
type DataServiceErrorContext,
|
||||
type DataServiceHostResult,
|
||||
type DataServiceInstanceList,
|
||||
type DataServiceInstanceRemoval,
|
||||
type DataServiceInstanceState,
|
||||
type DataServicePutDocumentInput,
|
||||
} from '../../shared/data-service';
|
||||
|
||||
const MAX_REQUEST_BYTES = 98_304;
|
||||
const MAX_RESPONSE_BYTES = 1_310_720;
|
||||
const MAX_CURSOR_LENGTH = 1_024;
|
||||
const MAX_RETRY_AFTER_SECONDS = 86_400;
|
||||
const PROJECT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/;
|
||||
const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/;
|
||||
const KNOWN_ERROR_CODES = new Set([
|
||||
'invalid_project_id',
|
||||
'invalid_collection_name',
|
||||
'invalid_document_id',
|
||||
'invalid_document_data',
|
||||
'invalid_revision',
|
||||
'invalid_cursor',
|
||||
'cursor_expired',
|
||||
'instance_not_found',
|
||||
'collection_not_found',
|
||||
'document_not_found',
|
||||
'revision_conflict',
|
||||
'document_too_large',
|
||||
'quota_exceeded',
|
||||
'rate_limited',
|
||||
]);
|
||||
const CONTEXT_KEYS = new Set([
|
||||
'resource',
|
||||
'limit',
|
||||
'current',
|
||||
'attempted',
|
||||
'actual',
|
||||
'allowed',
|
||||
'current_revision',
|
||||
'retry_after_seconds',
|
||||
]);
|
||||
const CONTEXT_RESOURCES = new Set(['instances', 'collections', 'documents', 'bytes']);
|
||||
|
||||
type FetchImplementation = typeof fetch;
|
||||
type AccessTokenGetter = typeof getValidWorksSquareAccessToken;
|
||||
|
||||
export type DataServiceOperations = {
|
||||
configure(input: { collections: string[] }): Promise<DataServiceHostResult<DataServiceInstanceState>>;
|
||||
inspect(): Promise<DataServiceHostResult<DataServiceInstanceState>>;
|
||||
listProjects(): Promise<DataServiceHostResult<DataServiceInstanceList>>;
|
||||
getDocument(input: { collection: string; document_id: string }): Promise<DataServiceHostResult<DataServiceDocument>>;
|
||||
listDocuments(input: { collection: string; limit?: number; cursor?: string }): Promise<DataServiceHostResult<DataServiceDocumentList>>;
|
||||
putDocument(input: DataServicePutDocumentInput): Promise<DataServiceHostResult<DataServiceDocument>>;
|
||||
deleteDocument(input: DataServiceDocumentTargetInput & { confirmed: true }): Promise<DataServiceHostResult<null>>;
|
||||
removeCollection(input: DataServiceCollectionTargetInput): Promise<DataServiceHostResult<DataServiceCollectionRemoval>>;
|
||||
reset(input: DataServiceConfirmationInput): Promise<DataServiceHostResult<DataServiceInstanceState>>;
|
||||
removeProject(input: DataServiceConfirmationInput): Promise<DataServiceHostResult<DataServiceInstanceRemoval>>;
|
||||
};
|
||||
|
||||
export type DataServiceCloudClientDependencies = {
|
||||
fetchImpl?: FetchImplementation;
|
||||
getAccessToken?: AccessTokenGetter;
|
||||
apiBaseUrl?: string;
|
||||
};
|
||||
|
||||
type RequestSpec<T> = {
|
||||
method: 'GET' | 'PUT' | 'POST' | 'DELETE';
|
||||
path: string;
|
||||
body?: unknown;
|
||||
ifRevision?: number;
|
||||
expectedStatus: number | readonly number[];
|
||||
project(payload: unknown): T;
|
||||
};
|
||||
|
||||
class InvalidRemoteResponseError extends Error {}
|
||||
|
||||
class OversizedRemoteResponseError extends Error {}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function boundedString(value: unknown, maximum: number): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
return value && value.length <= maximum ? value : null;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown): number | null {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : null;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): number | null {
|
||||
return Number.isSafeInteger(value) && (value as number) > 0 ? value as number : null;
|
||||
}
|
||||
|
||||
function projectUuid(value: unknown): string {
|
||||
const uuid = boundedString(value, 36);
|
||||
if (!uuid || !PROJECT_ID_PATTERN.test(uuid)) throw new InvalidRemoteResponseError();
|
||||
return uuid;
|
||||
}
|
||||
|
||||
function projectTimestamp(value: unknown): string {
|
||||
const timestamp = boundedString(value, 64);
|
||||
if (!timestamp || !timestamp.endsWith('Z') || !Number.isFinite(Date.parse(timestamp))) {
|
||||
throw new InvalidRemoteResponseError();
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function projectCollectionName(value: unknown): string {
|
||||
const name = boundedString(value, 48);
|
||||
if (!name || !COLLECTION_PATTERN.test(name)) throw new InvalidRemoteResponseError();
|
||||
return name;
|
||||
}
|
||||
|
||||
function projectDocumentId(value: unknown): string {
|
||||
const id = boundedString(value, 128);
|
||||
if (!id || id === '.' || id === '..' || !DOCUMENT_ID_PATTERN.test(id)) {
|
||||
throw new InvalidRemoteResponseError();
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function projectData(value: unknown): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new InvalidRemoteResponseError();
|
||||
return value;
|
||||
}
|
||||
|
||||
function projectUsage(value: unknown): { document_count: number; total_bytes: number } {
|
||||
if (!isRecord(value)) throw new InvalidRemoteResponseError();
|
||||
const documentCount = nonNegativeInteger(value.document_count);
|
||||
const totalBytes = nonNegativeInteger(value.total_bytes);
|
||||
if (documentCount === null || totalBytes === null) throw new InvalidRemoteResponseError();
|
||||
return { document_count: documentCount, total_bytes: totalBytes };
|
||||
}
|
||||
|
||||
function projectLimits(value: unknown) {
|
||||
if (!isRecord(value)) throw new InvalidRemoteResponseError();
|
||||
const fields = [
|
||||
'max_collections',
|
||||
'max_documents',
|
||||
'max_total_bytes',
|
||||
'max_document_bytes',
|
||||
'list_default_limit',
|
||||
'list_max_limit',
|
||||
'list_max_data_bytes',
|
||||
'mutations_per_minute',
|
||||
] as const;
|
||||
const projected = {} as Record<(typeof fields)[number], number>;
|
||||
for (const field of fields) {
|
||||
const item = positiveInteger(value[field]);
|
||||
if (item === null) throw new InvalidRemoteResponseError();
|
||||
projected[field] = item;
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
function projectCollection(value: unknown) {
|
||||
if (!isRecord(value)) throw new InvalidRemoteResponseError();
|
||||
const documentCount = nonNegativeInteger(value.document_count);
|
||||
const totalBytes = nonNegativeInteger(value.total_bytes);
|
||||
if (documentCount === null || totalBytes === null) throw new InvalidRemoteResponseError();
|
||||
return {
|
||||
name: projectCollectionName(value.name),
|
||||
document_count: documentCount,
|
||||
total_bytes: totalBytes,
|
||||
created_at: projectTimestamp(value.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
function projectInstance(value: unknown): DataServiceInstanceState {
|
||||
if (!isRecord(value) || !Array.isArray(value.collections) || value.collections.length > 20) {
|
||||
throw new InvalidRemoteResponseError();
|
||||
}
|
||||
const projectId = projectUuid(value.project_id);
|
||||
const instanceId = projectUuid(value.instance_id);
|
||||
const collections = value.collections.map(projectCollection);
|
||||
if (new Set(collections.map(({ name }) => name)).size !== collections.length) {
|
||||
throw new InvalidRemoteResponseError();
|
||||
}
|
||||
const usage = projectUsage(value.usage);
|
||||
return {
|
||||
instance_id: instanceId,
|
||||
project_id: projectId,
|
||||
collections,
|
||||
usage,
|
||||
limits: projectLimits(value.limits),
|
||||
created_at: projectTimestamp(value.created_at),
|
||||
updated_at: projectTimestamp(value.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function projectInstanceForProject(projectId: string) {
|
||||
return (value: unknown): DataServiceInstanceState => {
|
||||
const instance = projectInstance(value);
|
||||
if (instance.project_id !== projectId) throw new InvalidRemoteResponseError();
|
||||
return instance;
|
||||
};
|
||||
}
|
||||
|
||||
function projectInstanceList(value: unknown): DataServiceInstanceList {
|
||||
if (!isRecord(value) || !Array.isArray(value.items) || value.items.length > 20) {
|
||||
throw new InvalidRemoteResponseError();
|
||||
}
|
||||
const total = nonNegativeInteger(value.total);
|
||||
const instanceLimit = positiveInteger(value.instance_limit);
|
||||
if (total === null || instanceLimit === null) throw new InvalidRemoteResponseError();
|
||||
const items = value.items.map(projectInstance);
|
||||
if (new Set(items.map(({ project_id }) => project_id)).size !== items.length || total !== items.length) {
|
||||
throw new InvalidRemoteResponseError();
|
||||
}
|
||||
return { items, total, instance_limit: instanceLimit };
|
||||
}
|
||||
|
||||
function projectCollectionRemoval(value: unknown): DataServiceCollectionRemoval {
|
||||
if (!isRecord(value) || typeof value.removed !== 'boolean') throw new InvalidRemoteResponseError();
|
||||
return { removed: value.removed, usage: projectUsage(value.usage) };
|
||||
}
|
||||
|
||||
function projectInstanceRemoval(value: unknown): DataServiceInstanceRemoval {
|
||||
if (!isRecord(value) || typeof value.removed !== 'boolean') throw new InvalidRemoteResponseError();
|
||||
return { removed: value.removed };
|
||||
}
|
||||
|
||||
function projectDocument(value: unknown): DataServiceDocument {
|
||||
if (!isRecord(value)) throw new InvalidRemoteResponseError();
|
||||
const revision = positiveInteger(value.revision);
|
||||
if (revision === null) throw new InvalidRemoteResponseError();
|
||||
return {
|
||||
id: projectDocumentId(value.id),
|
||||
data: projectData(value.data),
|
||||
revision,
|
||||
created_at: projectTimestamp(value.created_at),
|
||||
updated_at: projectTimestamp(value.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function projectDocumentList(value: unknown): DataServiceDocumentList {
|
||||
if (!isRecord(value) || !Array.isArray(value.items)) throw new InvalidRemoteResponseError();
|
||||
if (value.items.length > 100) throw new InvalidRemoteResponseError();
|
||||
const limit = positiveInteger(value.limit);
|
||||
if (limit === null || limit > 100) throw new InvalidRemoteResponseError();
|
||||
if (value.next_cursor !== null && boundedString(value.next_cursor, MAX_CURSOR_LENGTH) === null) {
|
||||
throw new InvalidRemoteResponseError();
|
||||
}
|
||||
const items = value.items.map(projectDocument);
|
||||
if (new Set(items.map(({ id }) => id)).size !== items.length) {
|
||||
throw new InvalidRemoteResponseError();
|
||||
}
|
||||
return {
|
||||
items,
|
||||
next_cursor: value.next_cursor === null ? null : value.next_cursor as string,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
function resultSuccess<T>(status: number, data: T | null): DataServiceHostResult<T> {
|
||||
return {
|
||||
success: true,
|
||||
status,
|
||||
code: null,
|
||||
error: null,
|
||||
retryable: false,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
function resultFailure<T>(
|
||||
status: number,
|
||||
code: string,
|
||||
error: string,
|
||||
retryable: boolean,
|
||||
extra: Pick<DataServiceHostResult<T>, 'retry_after_seconds' | 'context'> = {},
|
||||
): DataServiceHostResult<T> {
|
||||
return {
|
||||
success: false,
|
||||
status,
|
||||
code,
|
||||
error,
|
||||
retryable,
|
||||
...(extra.retry_after_seconds === undefined ? {} : { retry_after_seconds: extra.retry_after_seconds }),
|
||||
...(extra.context === undefined ? {} : { context: extra.context }),
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
function authRequired<T>(): DataServiceHostResult<T> {
|
||||
return resultFailure(401, 'authentication_required', 'Works Square sign-in is required', false);
|
||||
}
|
||||
|
||||
function unavailable<T>(): DataServiceHostResult<T> {
|
||||
return resultFailure(503, 'data_service_unavailable', 'Data Service is temporarily unavailable', true);
|
||||
}
|
||||
|
||||
function invalidResponse<T>(): DataServiceHostResult<T> {
|
||||
return resultFailure(502, 'upstream_invalid_response', 'Data Service returned an invalid response', false);
|
||||
}
|
||||
|
||||
function invalidRequest<T>(): DataServiceHostResult<T> {
|
||||
return resultFailure(422, 'invalid_request', 'Data Service request is invalid', false);
|
||||
}
|
||||
|
||||
function confirmationRequired<T>(): DataServiceHostResult<T> {
|
||||
return resultFailure(
|
||||
400,
|
||||
'confirmation_required',
|
||||
'Explicit confirmation is required for this Data Service operation',
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessageForCode(code: string): string {
|
||||
switch (code) {
|
||||
case 'invalid_cursor': return 'Data Service cursor is invalid';
|
||||
case 'cursor_expired': return 'Data Service cursor has expired';
|
||||
case 'instance_not_found': return 'Data Service project instance was not found';
|
||||
case 'collection_not_found': return 'Data Service collection was not found';
|
||||
case 'document_not_found': return 'Data Service document was not found';
|
||||
case 'revision_conflict': return 'Data Service document revision conflicts';
|
||||
case 'quota_exceeded': return 'Data Service quota exceeded';
|
||||
case 'document_too_large': return 'Data Service document is too large';
|
||||
case 'invalid_revision': return 'Data Service document revision is invalid';
|
||||
case 'invalid_project_id': return 'Data Service project ID is invalid';
|
||||
case 'invalid_collection_name': return 'Data Service collection is invalid';
|
||||
case 'invalid_document_id': return 'Data Service document ID is invalid';
|
||||
case 'invalid_document_data': return 'Data Service document data is invalid';
|
||||
case 'rate_limited': return 'Data Service request rate limit exceeded';
|
||||
case 'data_service_unavailable': return 'Data Service is temporarily unavailable';
|
||||
default: return 'Data Service request was rejected';
|
||||
}
|
||||
}
|
||||
|
||||
function projectContext(value: unknown): DataServiceErrorContext | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const context: DataServiceErrorContext = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (!CONTEXT_KEYS.has(key)) continue;
|
||||
if (key === 'resource') {
|
||||
const resource = boundedString(item, 64);
|
||||
if (!resource || !CONTEXT_RESOURCES.has(resource)) throw new InvalidRemoteResponseError();
|
||||
context[key] = resource;
|
||||
} else if (Number.isSafeInteger(item) && (item as number) >= 0) {
|
||||
context[key] = item as number;
|
||||
} else {
|
||||
throw new InvalidRemoteResponseError();
|
||||
}
|
||||
}
|
||||
return Object.keys(context).length > 0 ? context : undefined;
|
||||
}
|
||||
|
||||
function retryAfterSeconds(response: Response): number | undefined {
|
||||
const raw = response.headers.get('retry-after')?.trim();
|
||||
if (!raw || !/^\d+$/.test(raw)) return undefined;
|
||||
const seconds = Number(raw);
|
||||
return Number.isSafeInteger(seconds) && seconds <= MAX_RETRY_AFTER_SECONDS ? seconds : undefined;
|
||||
}
|
||||
|
||||
function projectError<T>(payload: unknown, response: Response): DataServiceHostResult<T> {
|
||||
if (!isRecord(payload) || !isRecord(payload.detail)) return invalidResponse();
|
||||
const detail = payload.detail;
|
||||
const rawCode = boundedString(detail.code, 64);
|
||||
if (!rawCode || !KNOWN_ERROR_CODES.has(rawCode)) return invalidResponse();
|
||||
if (detail.retryable !== undefined && typeof detail.retryable !== 'boolean') return invalidResponse();
|
||||
if (detail.context !== undefined && !isRecord(detail.context)) return invalidResponse();
|
||||
const code = rawCode;
|
||||
// The upstream message is intentionally not forwarded: even bounded error
|
||||
// text is not a safe boundary for exception details or account data.
|
||||
const message = errorMessageForCode(code);
|
||||
const retryable = typeof detail.retryable === 'boolean'
|
||||
? detail.retryable
|
||||
: code === 'rate_limited' || code === 'data_service_unavailable';
|
||||
const retryAfter = retryAfterSeconds(response);
|
||||
const context = projectContext(detail.context);
|
||||
return resultFailure(
|
||||
response.status >= 400 && response.status <= 599 ? response.status : 502,
|
||||
code,
|
||||
message,
|
||||
retryable,
|
||||
{
|
||||
...(retryAfter === undefined ? {} : { retry_after_seconds: retryAfter }),
|
||||
...(context === undefined ? {} : { context }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function readBoundedJson(response: Response): Promise<unknown> {
|
||||
const declaredLength = response.headers.get('content-length');
|
||||
if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > MAX_RESPONSE_BYTES) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw new OversizedRemoteResponseError();
|
||||
}
|
||||
if (!response.body) {
|
||||
const text = await response.text();
|
||||
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) throw new OversizedRemoteResponseError();
|
||||
if (!text.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new InvalidRemoteResponseError();
|
||||
}
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
size += value.byteLength;
|
||||
if (size > MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new OversizedRemoteResponseError();
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const text = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8');
|
||||
if (!text.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new InvalidRemoteResponseError();
|
||||
}
|
||||
}
|
||||
|
||||
function assertProjectId(projectId: string): boolean {
|
||||
return typeof projectId === 'string' && PROJECT_ID_PATTERN.test(projectId);
|
||||
}
|
||||
|
||||
function assertCollection(collection: string): boolean {
|
||||
return typeof collection === 'string' && COLLECTION_PATTERN.test(collection);
|
||||
}
|
||||
|
||||
function assertDocumentId(documentId: string): boolean {
|
||||
return typeof documentId === 'string'
|
||||
&& documentId !== '.'
|
||||
&& documentId !== '..'
|
||||
&& DOCUMENT_ID_PATTERN.test(documentId);
|
||||
}
|
||||
|
||||
function assertRevision(value: number | undefined): boolean {
|
||||
return value === undefined || (Number.isSafeInteger(value) && value > 0);
|
||||
}
|
||||
|
||||
function requestBytes(body: unknown): string | null {
|
||||
try {
|
||||
const encoded = JSON.stringify(body);
|
||||
if (typeof encoded !== 'string') return null;
|
||||
if (Buffer.byteLength(encoded, 'utf8') > MAX_REQUEST_BYTES) return null;
|
||||
return encoded;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class DataServiceCloudClient {
|
||||
private readonly fetchImpl: FetchImplementation;
|
||||
private readonly getAccessToken: AccessTokenGetter;
|
||||
private readonly apiBaseUrl: string;
|
||||
|
||||
constructor(dependencies: DataServiceCloudClientDependencies = {}) {
|
||||
this.fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch;
|
||||
this.getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken;
|
||||
this.apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
listProjects(): Promise<DataServiceHostResult<DataServiceInstanceList>> {
|
||||
return this.request({
|
||||
method: 'GET',
|
||||
path: '/api/data-service/v1/projects',
|
||||
expectedStatus: 200,
|
||||
project: projectInstanceList,
|
||||
});
|
||||
}
|
||||
|
||||
configure(projectId: string, collections: string[]): Promise<DataServiceHostResult<DataServiceInstanceState>> {
|
||||
if (!assertProjectId(projectId) || !Array.isArray(collections) || collections.length > 20
|
||||
|| collections.some((name) => !assertCollection(name))) return Promise.resolve(invalidRequest());
|
||||
return this.request({
|
||||
method: 'PUT',
|
||||
path: `/api/data-service/v1/projects/${encodeURIComponent(projectId)}`,
|
||||
body: { collections },
|
||||
expectedStatus: 200,
|
||||
project: projectInstanceForProject(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
inspect(projectId: string): Promise<DataServiceHostResult<DataServiceInstanceState>> {
|
||||
if (!assertProjectId(projectId)) return Promise.resolve(invalidRequest());
|
||||
return this.request({
|
||||
method: 'GET',
|
||||
path: `/api/data-service/v1/projects/${encodeURIComponent(projectId)}`,
|
||||
expectedStatus: 200,
|
||||
project: projectInstanceForProject(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
removeCollection(projectId: string, collection: string): Promise<DataServiceHostResult<DataServiceCollectionRemoval>> {
|
||||
if (!assertProjectId(projectId) || !assertCollection(collection)) return Promise.resolve(invalidRequest());
|
||||
return this.request({
|
||||
method: 'DELETE',
|
||||
path: `/api/data-service/v1/projects/${encodeURIComponent(projectId)}/collections/${encodeURIComponent(collection)}`,
|
||||
expectedStatus: 200,
|
||||
project: projectCollectionRemoval,
|
||||
});
|
||||
}
|
||||
|
||||
reset(projectId: string): Promise<DataServiceHostResult<DataServiceInstanceState>> {
|
||||
if (!assertProjectId(projectId)) return Promise.resolve(invalidRequest());
|
||||
return this.request({
|
||||
method: 'POST',
|
||||
path: `/api/data-service/v1/projects/${encodeURIComponent(projectId)}/reset`,
|
||||
expectedStatus: 200,
|
||||
project: projectInstanceForProject(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
removeProject(projectId: string): Promise<DataServiceHostResult<DataServiceInstanceRemoval>> {
|
||||
if (!assertProjectId(projectId)) return Promise.resolve(invalidRequest());
|
||||
return this.request({
|
||||
method: 'DELETE',
|
||||
path: `/api/data-service/v1/projects/${encodeURIComponent(projectId)}`,
|
||||
expectedStatus: 200,
|
||||
project: projectInstanceRemoval,
|
||||
});
|
||||
}
|
||||
|
||||
getDocument(projectId: string, collection: string, documentId: string): Promise<DataServiceHostResult<DataServiceDocument>> {
|
||||
if (!assertProjectId(projectId) || !assertCollection(collection) || !assertDocumentId(documentId)) {
|
||||
return Promise.resolve(invalidRequest());
|
||||
}
|
||||
return this.request({
|
||||
method: 'GET',
|
||||
path: `/api/data/v1/projects/${encodeURIComponent(projectId)}/collections/${encodeURIComponent(collection)}/documents/${encodeURIComponent(documentId)}`,
|
||||
expectedStatus: 200,
|
||||
project: projectDocument,
|
||||
});
|
||||
}
|
||||
|
||||
listDocuments(
|
||||
projectId: string,
|
||||
collection: string,
|
||||
limit?: number,
|
||||
cursor?: string,
|
||||
): Promise<DataServiceHostResult<DataServiceDocumentList>> {
|
||||
if (!assertProjectId(projectId) || !assertCollection(collection)
|
||||
|| (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > 100))
|
||||
|| (cursor !== undefined && (typeof cursor !== 'string' || !cursor || cursor.length > MAX_CURSOR_LENGTH))) {
|
||||
return Promise.resolve(invalidRequest());
|
||||
}
|
||||
const query = new URLSearchParams();
|
||||
if (limit !== undefined) query.set('limit', String(limit));
|
||||
if (cursor !== undefined) query.set('cursor', cursor);
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
return this.request({
|
||||
method: 'GET',
|
||||
path: `/api/data/v1/projects/${encodeURIComponent(projectId)}/collections/${encodeURIComponent(collection)}/documents${suffix}`,
|
||||
expectedStatus: 200,
|
||||
project: projectDocumentList,
|
||||
});
|
||||
}
|
||||
|
||||
putDocument(
|
||||
projectId: string,
|
||||
input: DataServicePutDocumentInput,
|
||||
): Promise<DataServiceHostResult<DataServiceDocument>> {
|
||||
if (!isRecord(input)) return Promise.resolve(invalidRequest());
|
||||
const encodedBody = requestBytes({ data: input.data });
|
||||
if (!assertProjectId(projectId) || !assertCollection(input.collection)
|
||||
|| !assertDocumentId(input.document_id) || !isRecord(input.data)
|
||||
|| encodedBody === null || !assertRevision(input.if_revision)) return Promise.resolve(invalidRequest());
|
||||
return this.request({
|
||||
method: 'PUT',
|
||||
path: `/api/data/v1/projects/${encodeURIComponent(projectId)}/collections/${encodeURIComponent(input.collection)}/documents/${encodeURIComponent(input.document_id)}`,
|
||||
body: { data: input.data },
|
||||
ifRevision: input.if_revision,
|
||||
expectedStatus: 200,
|
||||
project: projectDocument,
|
||||
});
|
||||
}
|
||||
|
||||
deleteDocument(
|
||||
projectId: string,
|
||||
input: DataServiceDocumentTargetInput,
|
||||
): Promise<DataServiceHostResult<null>> {
|
||||
if (!isRecord(input)) return Promise.resolve(invalidRequest());
|
||||
if (!assertProjectId(projectId) || !assertCollection(input.collection)
|
||||
|| !assertDocumentId(input.document_id) || !assertRevision(input.if_revision)) {
|
||||
return Promise.resolve(invalidRequest());
|
||||
}
|
||||
return this.request({
|
||||
method: 'DELETE',
|
||||
path: `/api/data/v1/projects/${encodeURIComponent(projectId)}/collections/${encodeURIComponent(input.collection)}/documents/${encodeURIComponent(input.document_id)}`,
|
||||
ifRevision: input.if_revision,
|
||||
expectedStatus: 204,
|
||||
project: () => null,
|
||||
});
|
||||
}
|
||||
|
||||
private async request<T>(spec: RequestSpec<T>): Promise<DataServiceHostResult<T>> {
|
||||
const body = spec.body === undefined ? undefined : requestBytes(spec.body);
|
||||
if (spec.body !== undefined && body === null) return invalidRequest();
|
||||
let token: string | null;
|
||||
try {
|
||||
token = await this.getAccessToken({ fetchImpl: this.fetchImpl });
|
||||
} catch {
|
||||
return authRequired();
|
||||
}
|
||||
if (!token) return authRequired();
|
||||
|
||||
const send = async (accessToken: string): Promise<Response> => await this.fetchImpl(
|
||||
`${this.apiBaseUrl}${spec.path}`,
|
||||
{
|
||||
method: spec.method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(spec.ifRevision === undefined ? {} : { 'If-Match': `"${spec.ifRevision}"` }),
|
||||
},
|
||||
...(body === undefined ? {} : { body }),
|
||||
redirect: 'manual',
|
||||
},
|
||||
);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await send(token);
|
||||
} catch {
|
||||
return unavailable();
|
||||
}
|
||||
if (response.status === 401) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
let refreshed: string | null;
|
||||
try {
|
||||
refreshed = await this.getAccessToken({ fetchImpl: this.fetchImpl, forceRefresh: true });
|
||||
} catch {
|
||||
refreshed = null;
|
||||
}
|
||||
if (!refreshed) return authRequired();
|
||||
try {
|
||||
response = await send(refreshed);
|
||||
} catch {
|
||||
return unavailable();
|
||||
}
|
||||
if (response.status === 401) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
return authRequired();
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status >= 500) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
return unavailable();
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await readBoundedJson(response);
|
||||
} catch {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
return invalidResponse();
|
||||
}
|
||||
return projectError(payload, response);
|
||||
}
|
||||
const expected = Array.isArray(spec.expectedStatus)
|
||||
? spec.expectedStatus
|
||||
: [spec.expectedStatus];
|
||||
if (!expected.includes(response.status)) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
return invalidResponse();
|
||||
}
|
||||
if (response.status === 204) return resultSuccess(response.status, null);
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await readBoundedJson(response);
|
||||
return resultSuccess(response.status, spec.project(payload));
|
||||
} catch {
|
||||
return invalidResponse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function projectLocalProjectError<T>(error: unknown): DataServiceHostResult<T> {
|
||||
if (!(error instanceof CodingProjectServiceError)) return unavailable();
|
||||
switch (error.code) {
|
||||
case 'CODING_PROJECT_IDENTITY_REQUIRED':
|
||||
return resultFailure(409, 'project_identity_required', 'A durable coding project identity is required', false);
|
||||
case 'CODING_ACTIVE_PROJECT_REQUIRED':
|
||||
return resultFailure(409, 'active_project_required', 'An active coding project is required', false);
|
||||
case 'CODING_ACTIVE_PROJECT_PATH_MISMATCH':
|
||||
return resultFailure(409, 'active_project_path_mismatch', 'The active coding project path is invalid', false);
|
||||
case 'CODING_ACTIVE_PROJECT_INVALID':
|
||||
case 'CODING_PROJECT_CONFIG_INVALID':
|
||||
return resultFailure(409, 'active_project_invalid', 'The active coding project is unavailable', false);
|
||||
default:
|
||||
return error.status >= 500
|
||||
? unavailable()
|
||||
: resultFailure(error.status, 'active_project_invalid', 'The active coding project is unavailable', false);
|
||||
}
|
||||
}
|
||||
|
||||
export type DataServiceOperationsDependencies = {
|
||||
projects: Pick<CodingProjectService, 'requireActiveRealProjectWithIdentity'>;
|
||||
client?: DataServiceCloudClient;
|
||||
};
|
||||
|
||||
export function createDataServiceOperations(
|
||||
dependencies: DataServiceOperationsDependencies,
|
||||
): DataServiceOperations {
|
||||
const client = dependencies.client ?? new DataServiceCloudClient();
|
||||
|
||||
async function withActive<T>(
|
||||
operation: (projectId: string) => Promise<DataServiceHostResult<T>>,
|
||||
): Promise<DataServiceHostResult<T>> {
|
||||
try {
|
||||
const active = await dependencies.projects.requireActiveRealProjectWithIdentity();
|
||||
return await operation(active.projectId);
|
||||
} catch (error) {
|
||||
return projectLocalProjectError<T>(error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
listProjects: () => client.listProjects(),
|
||||
configure: ({ collections }) => withActive((projectId) => client.configure(projectId, collections)),
|
||||
inspect: () => withActive((projectId) => client.inspect(projectId)),
|
||||
getDocument: ({ collection, document_id }) => withActive(
|
||||
(projectId) => client.getDocument(projectId, collection, document_id),
|
||||
),
|
||||
listDocuments: ({ collection, limit, cursor }) => withActive(
|
||||
(projectId) => client.listDocuments(projectId, collection, limit, cursor),
|
||||
),
|
||||
putDocument: (input) => withActive((projectId) => client.putDocument(projectId, input)),
|
||||
deleteDocument: ({ collection, document_id, if_revision, confirmed }) => confirmed === true
|
||||
? withActive((projectId) => client.deleteDocument(projectId, { collection, document_id, if_revision }))
|
||||
: Promise.resolve(confirmationRequired()),
|
||||
removeCollection: ({ collection, confirmed }) => confirmed === true
|
||||
? withActive((projectId) => client.removeCollection(projectId, collection))
|
||||
: Promise.resolve(confirmationRequired()),
|
||||
reset: ({ confirmed }) => confirmed === true
|
||||
? withActive((projectId) => client.reset(projectId))
|
||||
: Promise.resolve(confirmationRequired()),
|
||||
removeProject: ({ confirmed }) => confirmed === true
|
||||
? withActive((projectId) => client.removeProject(projectId))
|
||||
: Promise.resolve(confirmationRequired()),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user