Files
makelore/electron/services/data-service-client.ts

786 lines
30 KiB
TypeScript

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']);
const ERROR_STATUS_BY_CODE: Readonly<Record<string, number>> = {
invalid_cursor: 400,
instance_not_found: 404,
collection_not_found: 404,
document_not_found: 404,
cursor_expired: 410,
revision_conflict: 409,
quota_exceeded: 409,
document_too_large: 413,
invalid_project_id: 422,
invalid_collection_name: 422,
invalid_document_id: 422,
invalid_document_data: 422,
invalid_revision: 422,
rate_limited: 429,
};
type FetchImplementation = typeof fetch;
type AccessTokenGetter = typeof getValidWorksSquareAccessToken;
export type DataServiceOperations = {
configure(input: { collections: string[] }, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceInstanceState>>;
inspect(trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceInstanceState>>;
listProjects(): Promise<DataServiceHostResult<DataServiceInstanceList>>;
getDocument(input: { collection: string; document_id: string }, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceDocument>>;
listDocuments(input: { collection: string; limit?: number; cursor?: string }, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceDocumentList>>;
putDocument(input: DataServicePutDocumentInput, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceDocument>>;
deleteDocument(input: DataServiceDocumentTargetInput & { confirmed: true }, trustedProjectPath?: string): Promise<DataServiceHostResult<null>>;
removeCollection(input: DataServiceCollectionTargetInput, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceCollectionRemoval>>;
reset(input: DataServiceConfirmationInput, trustedProjectPath?: string): Promise<DataServiceHostResult<DataServiceInstanceState>>;
removeProject(input: DataServiceConfirmationInput, trustedProjectPath?: string): 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 (ERROR_STATUS_BY_CODE[rawCode] !== response.status) 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>(
trustedProjectPath: string | undefined,
operation: (projectId: string) => Promise<DataServiceHostResult<T>>,
): Promise<DataServiceHostResult<T>> {
try {
const active = await dependencies.projects.requireActiveRealProjectWithIdentity(trustedProjectPath);
return await operation(active.projectId);
} catch (error) {
return projectLocalProjectError<T>(error);
}
}
return {
listProjects: () => client.listProjects(),
configure: ({ collections }, trustedProjectPath) => withActive(trustedProjectPath, (projectId) => client.configure(projectId, collections)),
inspect: (trustedProjectPath) => withActive(trustedProjectPath, (projectId) => client.inspect(projectId)),
getDocument: ({ collection, document_id }, trustedProjectPath) => withActive(trustedProjectPath,
(projectId) => client.getDocument(projectId, collection, document_id),
),
listDocuments: ({ collection, limit, cursor }, trustedProjectPath) => withActive(trustedProjectPath,
(projectId) => client.listDocuments(projectId, collection, limit, cursor),
),
putDocument: (input, trustedProjectPath) => withActive(trustedProjectPath, (projectId) => client.putDocument(projectId, input)),
deleteDocument: ({ collection, document_id, if_revision, confirmed }, trustedProjectPath) => confirmed === true
? withActive(trustedProjectPath, (projectId) => client.deleteDocument(projectId, { collection, document_id, if_revision }))
: Promise.resolve(confirmationRequired()),
removeCollection: ({ collection, confirmed }, trustedProjectPath) => confirmed === true
? withActive(trustedProjectPath, (projectId) => client.removeCollection(projectId, collection))
: Promise.resolve(confirmationRequired()),
reset: ({ confirmed }, trustedProjectPath) => confirmed === true
? withActive(trustedProjectPath, (projectId) => client.reset(projectId))
: Promise.resolve(confirmationRequired()),
removeProject: ({ confirmed }, trustedProjectPath) => confirmed === true
? withActive(trustedProjectPath, (projectId) => client.removeProject(projectId))
: Promise.resolve(confirmationRequired()),
};
}