551 lines
18 KiB
TypeScript
551 lines
18 KiB
TypeScript
export type DataServiceDocument<T extends Record<string, unknown> = Record<string, unknown>> = {
|
|
id: string;
|
|
data: T;
|
|
revision: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
export type DataServiceDocumentPage<T extends Record<string, unknown> = Record<string, unknown>> = {
|
|
items: Array<DataServiceDocument<T>>;
|
|
next_cursor: string | null;
|
|
limit: number;
|
|
};
|
|
|
|
export type DataServiceListOptions = {
|
|
limit?: number;
|
|
cursor?: string;
|
|
};
|
|
|
|
export type DataServiceWriteOptions = {
|
|
ifRevision?: number;
|
|
};
|
|
|
|
export type DataServiceErrorContext = {
|
|
resource?: 'instances' | 'collections' | 'documents' | 'bytes';
|
|
limit?: number;
|
|
current?: number;
|
|
attempted?: number;
|
|
actual?: number;
|
|
allowed?: number;
|
|
current_revision?: number;
|
|
retry_after_seconds?: number;
|
|
};
|
|
|
|
export type DataServiceErrorCode =
|
|
| 'runtime_unavailable'
|
|
| 'upstream_invalid_response'
|
|
| 'invalid_request'
|
|
| 'invalid_content_type'
|
|
| 'invalid_revision'
|
|
| 'invalid_cursor'
|
|
| 'cursor_expired'
|
|
| 'invalid_collection_name'
|
|
| 'invalid_document_id'
|
|
| 'invalid_document_data'
|
|
| 'invalid_project_id'
|
|
| 'authentication_required'
|
|
| 'origin_not_allowed'
|
|
| 'method_not_allowed'
|
|
| 'route_not_found'
|
|
| 'instance_not_found'
|
|
| 'collection_not_found'
|
|
| 'document_not_found'
|
|
| 'revision_conflict'
|
|
| 'document_too_large'
|
|
| 'quota_exceeded'
|
|
| 'rate_limited'
|
|
| 'data_service_unavailable'
|
|
| 'request_too_large';
|
|
|
|
declare global {
|
|
var __MAKELORE_DATA__: unknown;
|
|
}
|
|
|
|
type FetchResponse = {
|
|
status: number;
|
|
headers?: { get(name: string): string | null };
|
|
json(): Promise<unknown>;
|
|
};
|
|
|
|
type FetchOptions = {
|
|
method: 'GET' | 'PUT' | 'DELETE';
|
|
headers: Record<string, string>;
|
|
body?: string;
|
|
};
|
|
|
|
type RuntimeBinding = {
|
|
endpoint: string;
|
|
token: string;
|
|
contractVersion: 1;
|
|
};
|
|
|
|
type RuntimeGlobal = typeof globalThis & {
|
|
__MAKELORE_DATA__?: unknown;
|
|
fetch?: (input: string, init: FetchOptions) => Promise<FetchResponse>;
|
|
crypto?: { randomUUID?: () => string };
|
|
};
|
|
|
|
const runtimeGlobal = globalThis as RuntimeGlobal;
|
|
const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/;
|
|
const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/;
|
|
const MAX_CURSOR_LENGTH = 1_024;
|
|
const MAX_ERROR_MESSAGE_LENGTH = 256;
|
|
const MAX_RETRY_AFTER_SECONDS = 86_400;
|
|
const ERROR_STATUS: Readonly<Record<DataServiceErrorCode, number | readonly number[]>> = {
|
|
runtime_unavailable: 503,
|
|
upstream_invalid_response: 502,
|
|
invalid_request: [400, 422],
|
|
invalid_content_type: 415,
|
|
invalid_revision: 422,
|
|
invalid_cursor: 400,
|
|
cursor_expired: 410,
|
|
invalid_collection_name: 422,
|
|
invalid_document_id: 422,
|
|
invalid_document_data: 422,
|
|
invalid_project_id: 422,
|
|
authentication_required: 401,
|
|
origin_not_allowed: 403,
|
|
method_not_allowed: 405,
|
|
route_not_found: 404,
|
|
instance_not_found: 404,
|
|
collection_not_found: 404,
|
|
document_not_found: 404,
|
|
revision_conflict: 409,
|
|
document_too_large: 413,
|
|
quota_exceeded: 409,
|
|
rate_limited: 429,
|
|
data_service_unavailable: 503,
|
|
request_too_large: 413,
|
|
};
|
|
|
|
const ERROR_MESSAGE: Readonly<Record<DataServiceErrorCode, string>> = {
|
|
runtime_unavailable: 'Preview data runtime is unavailable',
|
|
upstream_invalid_response: 'Data Service returned an invalid response',
|
|
invalid_request: 'Data Service request is invalid',
|
|
invalid_content_type: 'Data Service request content type is invalid',
|
|
invalid_revision: 'Data Service document revision is invalid',
|
|
invalid_cursor: 'Data Service cursor is invalid',
|
|
cursor_expired: 'Data Service cursor has expired',
|
|
invalid_collection_name: 'Data Service collection is invalid',
|
|
invalid_document_id: 'Data Service document ID is invalid',
|
|
invalid_document_data: 'Data Service document data is invalid',
|
|
invalid_project_id: 'Data Service project ID is invalid',
|
|
authentication_required: 'Preview data authorization is required',
|
|
origin_not_allowed: 'Preview data Origin is not allowed',
|
|
method_not_allowed: 'Preview data method is not allowed',
|
|
route_not_found: 'Preview data route was not found',
|
|
instance_not_found: 'Data Service project instance was not found',
|
|
collection_not_found: 'Data Service collection was not found',
|
|
document_not_found: 'Data Service document was not found',
|
|
revision_conflict: 'Data Service document revision conflicts',
|
|
document_too_large: 'Data Service document is too large',
|
|
quota_exceeded: 'Data Service quota exceeded',
|
|
rate_limited: 'Preview data request rate limit exceeded',
|
|
data_service_unavailable: 'Data Service is temporarily unavailable',
|
|
request_too_large: 'Preview data request is too large',
|
|
};
|
|
|
|
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']);
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function positiveInteger(value: unknown): value is number {
|
|
return Number.isSafeInteger(value) && (value as number) > 0;
|
|
}
|
|
|
|
function boundedText(value: unknown, maximum: number): value is string {
|
|
return typeof value === 'string' && value.length > 0 && value.length <= maximum;
|
|
}
|
|
|
|
function boundedTimestamp(value: unknown): value is string {
|
|
return boundedText(value, 64)
|
|
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/.test(value)
|
|
&& Number.isFinite(Date.parse(value));
|
|
}
|
|
|
|
function hasOnlyKeys(value: Record<string, unknown>, required: readonly string[], optional: readonly string[] = []): boolean {
|
|
const allowed = new Set([...required, ...optional]);
|
|
return required.every((key) => Object.prototype.hasOwnProperty.call(value, key))
|
|
&& Object.keys(value).every((key) => allowed.has(key));
|
|
}
|
|
|
|
function statusMatches(code: DataServiceErrorCode, status: number): boolean {
|
|
const expected = ERROR_STATUS[code];
|
|
return Array.isArray(expected) ? expected.includes(status) : expected === status;
|
|
}
|
|
|
|
function frozenContext(value: DataServiceErrorContext | undefined): DataServiceErrorContext | undefined {
|
|
return value === undefined ? undefined : Object.freeze({ ...value });
|
|
}
|
|
|
|
export class DataServiceError extends Error {
|
|
readonly code: DataServiceErrorCode;
|
|
readonly status: number;
|
|
readonly retryable: boolean;
|
|
readonly retryAfterSeconds?: number;
|
|
readonly context?: DataServiceErrorContext;
|
|
|
|
constructor(
|
|
code: DataServiceErrorCode,
|
|
status: number,
|
|
retryable: boolean,
|
|
retryAfterSeconds?: number,
|
|
context?: DataServiceErrorContext,
|
|
) {
|
|
super(ERROR_MESSAGE[code]);
|
|
Object.defineProperty(this, 'name', { value: 'DataServiceError', enumerable: false });
|
|
this.code = code;
|
|
this.status = status;
|
|
this.retryable = retryable;
|
|
if (retryAfterSeconds !== undefined) this.retryAfterSeconds = retryAfterSeconds;
|
|
const safeContext = frozenContext(context);
|
|
if (safeContext !== undefined) this.context = safeContext;
|
|
Object.freeze(this);
|
|
}
|
|
}
|
|
|
|
function runtimeUnavailable(): DataServiceError {
|
|
return new DataServiceError('runtime_unavailable', 503, true);
|
|
}
|
|
|
|
function invalidRequest(): DataServiceError {
|
|
return new DataServiceError('invalid_request', 422, false);
|
|
}
|
|
|
|
function upstreamInvalidResponse(): DataServiceError {
|
|
return new DataServiceError('upstream_invalid_response', 502, false);
|
|
}
|
|
|
|
function readRuntime(): RuntimeBinding {
|
|
const candidate = globalThis.__MAKELORE_DATA__;
|
|
if (!isRecord(candidate)
|
|
|| candidate.contractVersion !== 1
|
|
|| !boundedText(candidate.token, 256)
|
|
|| !/^[A-Za-z0-9_-]+$/.test(candidate.token)
|
|
|| !boundedText(candidate.endpoint, 256)) {
|
|
throw runtimeUnavailable();
|
|
}
|
|
let endpoint: URL;
|
|
try {
|
|
endpoint = new URL(candidate.endpoint);
|
|
} catch {
|
|
throw runtimeUnavailable();
|
|
}
|
|
if (endpoint.protocol !== 'http:'
|
|
|| endpoint.hostname !== '127.0.0.1'
|
|
|| !endpoint.port
|
|
|| endpoint.username
|
|
|| endpoint.password
|
|
|| endpoint.pathname !== '/api/runtime/data/v1'
|
|
|| endpoint.search
|
|
|| endpoint.hash) {
|
|
throw runtimeUnavailable();
|
|
}
|
|
return {
|
|
endpoint: `${endpoint.origin}${endpoint.pathname}`,
|
|
token: candidate.token,
|
|
contractVersion: 1,
|
|
};
|
|
}
|
|
|
|
function requireCollection(value: string): string {
|
|
if (typeof value !== 'string' || !COLLECTION_PATTERN.test(value)) throw invalidRequest();
|
|
return value;
|
|
}
|
|
|
|
function requireDocumentId(value: string): string {
|
|
if (typeof value !== 'string' || value === '.' || value === '..' || !DOCUMENT_ID_PATTERN.test(value)) {
|
|
throw invalidRequest();
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requireData(value: Record<string, unknown>): Record<string, unknown> {
|
|
if (!isRecord(value)) throw invalidRequest();
|
|
try {
|
|
if (JSON.stringify(value) === undefined) throw new Error('data is not JSON');
|
|
} catch {
|
|
throw invalidRequest();
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requireOptions(value: unknown, allowed: string[]): Record<string, unknown> {
|
|
if (value === undefined) return {};
|
|
if (!isRecord(value) || Object.keys(value).some((key) => !allowed.includes(key))) throw invalidRequest();
|
|
return value;
|
|
}
|
|
|
|
function readIfRevision(value: unknown): number | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (!positiveInteger(value)) throw invalidRequest();
|
|
return value;
|
|
}
|
|
|
|
function buildDocumentPath(endpoint: string, collection: string, documentId: string): string {
|
|
return `${endpoint}/collections/${encodeURIComponent(collection)}/documents/${encodeURIComponent(documentId)}`;
|
|
}
|
|
|
|
function parseContext(value: unknown): DataServiceErrorContext | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (!isRecord(value)) throw upstreamInvalidResponse();
|
|
const context: DataServiceErrorContext = {};
|
|
for (const [key, item] of Object.entries(value)) {
|
|
if (!CONTEXT_KEYS.has(key)) throw upstreamInvalidResponse();
|
|
if (key === 'resource') {
|
|
if (!boundedText(item, 16) || !CONTEXT_RESOURCES.has(item)) throw upstreamInvalidResponse();
|
|
context.resource = item as DataServiceErrorContext['resource'];
|
|
} else {
|
|
if (!positiveInteger(item) && item !== 0) throw upstreamInvalidResponse();
|
|
context[key as Exclude<keyof DataServiceErrorContext, 'resource'>] = item as never;
|
|
}
|
|
}
|
|
return Object.keys(context).length === 0 ? undefined : context;
|
|
}
|
|
|
|
function readRetryAfter(response: FetchResponse): 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;
|
|
}
|
|
|
|
async function parseError(response: FetchResponse): Promise<DataServiceError> {
|
|
try {
|
|
const payload = await response.json();
|
|
if (!isRecord(payload) || !isRecord(payload.detail)) throw upstreamInvalidResponse();
|
|
const detail = payload.detail;
|
|
if (!hasOnlyKeys(detail, ['code', 'message', 'retryable'], ['context'])
|
|
|| !boundedText(detail.code, 64)
|
|
|| !Object.prototype.hasOwnProperty.call(ERROR_STATUS, detail.code)
|
|
|| !boundedText(detail.message, MAX_ERROR_MESSAGE_LENGTH)
|
|
|| typeof detail.retryable !== 'boolean'
|
|
|| !statusMatches(detail.code as DataServiceErrorCode, response.status)) {
|
|
throw upstreamInvalidResponse();
|
|
}
|
|
const context = parseContext(detail.context);
|
|
return new DataServiceError(
|
|
detail.code as DataServiceErrorCode,
|
|
response.status,
|
|
detail.retryable,
|
|
readRetryAfter(response),
|
|
context,
|
|
);
|
|
} catch (error: unknown) {
|
|
if (error instanceof DataServiceError) throw error;
|
|
throw upstreamInvalidResponse();
|
|
}
|
|
}
|
|
|
|
async function parseJson(response: FetchResponse): Promise<unknown> {
|
|
try {
|
|
return await response.json();
|
|
} catch {
|
|
throw upstreamInvalidResponse();
|
|
}
|
|
}
|
|
|
|
function parseDocument(value: unknown): DataServiceDocument {
|
|
if (!isRecord(value)
|
|
|| !hasOnlyKeys(value, ['id', 'data', 'revision', 'created_at', 'updated_at'])
|
|
|| typeof value.id !== 'string'
|
|
|| value.id === '.'
|
|
|| value.id === '..'
|
|
|| !DOCUMENT_ID_PATTERN.test(value.id)
|
|
|| !isRecord(value.data)
|
|
|| !positiveInteger(value.revision)
|
|
|| !boundedTimestamp(value.created_at)
|
|
|| !boundedTimestamp(value.updated_at)) {
|
|
throw upstreamInvalidResponse();
|
|
}
|
|
return {
|
|
id: value.id,
|
|
data: value.data,
|
|
revision: value.revision,
|
|
created_at: value.created_at,
|
|
updated_at: value.updated_at,
|
|
};
|
|
}
|
|
|
|
function parsePage(value: unknown): DataServiceDocumentPage {
|
|
if (!isRecord(value)
|
|
|| !hasOnlyKeys(value, ['items', 'next_cursor', 'limit'])
|
|
|| !Array.isArray(value.items)
|
|
|| value.items.length > 100
|
|
|| !positiveInteger(value.limit)
|
|
|| value.limit > 100
|
|
|| (value.next_cursor !== null && !boundedText(value.next_cursor, MAX_CURSOR_LENGTH))) {
|
|
throw upstreamInvalidResponse();
|
|
}
|
|
const items = value.items.map(parseDocument);
|
|
if (new Set(items.map((item) => item.id)).size !== items.length) throw upstreamInvalidResponse();
|
|
return {
|
|
items,
|
|
next_cursor: value.next_cursor === null ? null : value.next_cursor,
|
|
limit: value.limit,
|
|
};
|
|
}
|
|
|
|
async function request<T>(
|
|
method: FetchOptions['method'],
|
|
url: string,
|
|
expectedStatus: number,
|
|
parse: (value: unknown) => T,
|
|
body?: Record<string, unknown>,
|
|
ifRevision?: number,
|
|
): Promise<T> {
|
|
const runtime = readRuntime();
|
|
const fetchImpl = runtimeGlobal.fetch;
|
|
if (typeof fetchImpl !== 'function') throw runtimeUnavailable();
|
|
const headers: Record<string, string> = {
|
|
Accept: 'application/json',
|
|
Authorization: `Bearer ${runtime.token}`,
|
|
};
|
|
const serializedBody = body === undefined ? undefined : (() => {
|
|
try {
|
|
const encoded = JSON.stringify(body);
|
|
return encoded === undefined ? null : encoded;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
if (serializedBody === null) throw invalidRequest();
|
|
if (serializedBody !== undefined) headers['Content-Type'] = 'application/json';
|
|
if (ifRevision !== undefined) headers['If-Match'] = `"${ifRevision}"`;
|
|
let response: FetchResponse;
|
|
try {
|
|
response = await fetchImpl(url, {
|
|
method,
|
|
headers,
|
|
...(serializedBody === undefined ? {} : { body: serializedBody }),
|
|
});
|
|
} catch {
|
|
throw runtimeUnavailable();
|
|
}
|
|
if (!response || !Number.isSafeInteger(response.status)) throw upstreamInvalidResponse();
|
|
if (response.status !== expectedStatus) throw await parseError(response);
|
|
if (expectedStatus === 204) return undefined as T;
|
|
return parse(await parseJson(response));
|
|
}
|
|
|
|
function listPath(endpoint: string, collection: string, options: DataServiceListOptions): string {
|
|
const params = new URLSearchParams();
|
|
if (options.limit !== undefined) params.set('limit', String(options.limit));
|
|
if (options.cursor !== undefined) params.set('cursor', options.cursor);
|
|
const query = params.toString();
|
|
return `${endpoint}/collections/${encodeURIComponent(collection)}/documents${query ? `?${query}` : ''}`;
|
|
}
|
|
|
|
async function getDocument(collection: string, documentId: string): Promise<DataServiceDocument> {
|
|
const runtime = readRuntime();
|
|
const safeCollection = requireCollection(collection);
|
|
const safeDocumentId = requireDocumentId(documentId);
|
|
return await request(
|
|
'GET',
|
|
buildDocumentPath(runtime.endpoint, safeCollection, safeDocumentId),
|
|
200,
|
|
parseDocument,
|
|
);
|
|
}
|
|
|
|
async function listDocuments(
|
|
collection: string,
|
|
input: DataServiceListOptions = {},
|
|
): Promise<DataServiceDocumentPage> {
|
|
const runtime = readRuntime();
|
|
const safeCollection = requireCollection(collection);
|
|
const options = requireOptions(input, ['limit', 'cursor']);
|
|
const limit = options.limit;
|
|
if (limit !== undefined && (!positiveInteger(limit) || limit > 100)) throw invalidRequest();
|
|
const cursor = options.cursor;
|
|
if (cursor !== undefined && (!boundedText(cursor, MAX_CURSOR_LENGTH))) throw invalidRequest();
|
|
return await request(
|
|
'GET',
|
|
listPath(runtime.endpoint, safeCollection, {
|
|
...(limit === undefined ? {} : { limit }),
|
|
...(cursor === undefined ? {} : { cursor }),
|
|
}),
|
|
200,
|
|
parsePage,
|
|
);
|
|
}
|
|
|
|
async function putDocument(
|
|
collection: string,
|
|
documentId: string,
|
|
value: Record<string, unknown>,
|
|
input: DataServiceWriteOptions = {},
|
|
): Promise<DataServiceDocument> {
|
|
const runtime = readRuntime();
|
|
const safeCollection = requireCollection(collection);
|
|
const safeDocumentId = requireDocumentId(documentId);
|
|
const data = requireData(value);
|
|
const options = requireOptions(input, ['ifRevision']);
|
|
const ifRevision = readIfRevision(options.ifRevision);
|
|
return await request(
|
|
'PUT',
|
|
buildDocumentPath(runtime.endpoint, safeCollection, safeDocumentId),
|
|
200,
|
|
parseDocument,
|
|
{ data },
|
|
ifRevision,
|
|
);
|
|
}
|
|
|
|
async function deleteDocument(
|
|
collection: string,
|
|
documentId: string,
|
|
input: DataServiceWriteOptions = {},
|
|
): Promise<void> {
|
|
const runtime = readRuntime();
|
|
const safeCollection = requireCollection(collection);
|
|
const safeDocumentId = requireDocumentId(documentId);
|
|
const options = requireOptions(input, ['ifRevision']);
|
|
const ifRevision = readIfRevision(options.ifRevision);
|
|
await request(
|
|
'DELETE',
|
|
buildDocumentPath(runtime.endpoint, safeCollection, safeDocumentId),
|
|
204,
|
|
() => undefined,
|
|
undefined,
|
|
ifRevision,
|
|
);
|
|
}
|
|
|
|
async function addDocument(
|
|
collection: string,
|
|
value: Record<string, unknown>,
|
|
): Promise<DataServiceDocument> {
|
|
readRuntime();
|
|
requireCollection(collection);
|
|
requireData(value);
|
|
const randomUuid = runtimeGlobal.crypto?.randomUUID;
|
|
if (typeof randomUuid !== 'function') throw runtimeUnavailable();
|
|
const id = randomUuid();
|
|
if (!boundedText(id, 128) || !DOCUMENT_ID_PATTERN.test(id)) throw runtimeUnavailable();
|
|
return await putDocument(collection, id, value);
|
|
}
|
|
|
|
export const data = Object.freeze({
|
|
get: getDocument,
|
|
list: listDocuments,
|
|
put: putDocument,
|
|
delete: deleteDocument,
|
|
add: addDocument,
|
|
});
|
|
|
|
export default data;
|