feat(coding): add generated Data Service SDK skill
This commit is contained in:
93
resources/coding-skills/data-service/SKILL.md
Normal file
93
resources/coding-skills/data-service/SKILL.md
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: data-service
|
||||
description: 当用户明确要求为当前 MakeLore 项目添加持久化开发数据、集合或数据读写示例时使用;只在本地预览中验证,不用于已发布作品。
|
||||
---
|
||||
|
||||
# MakeLore 开发数据
|
||||
|
||||
为当前项目添加开发数据时,使用本 Skill 的顺序和完成条件。数据能力是
|
||||
显式 opt-in,必须得到 explicit user intent;读取项目、创建项目、打开预览或复制项目本身都不会配置服务、
|
||||
安装 SDK 或编辑源码。
|
||||
|
||||
## 1. Inspect
|
||||
|
||||
先 inspect 当前项目的实际目录、`package.json`、`tsconfig.json`(如果存在)、
|
||||
已有入口文件和数据需求。识别完成当前功能所需的最小集合名称及每个集合的
|
||||
最小文档形状;按实际源文件而不是 `projectType` 标签判断 TypeScript 或
|
||||
JavaScript。
|
||||
|
||||
完成条件:已经列出实际布局、候选集合和最小示例,并且还没有修改应用源码、
|
||||
安装 SDK 或调用 `data_service_configure`。
|
||||
|
||||
## 2. Explain and wait
|
||||
|
||||
向用户说明候选集合、示例文档、将要写入的目标文件和预览验证动作。等待用户
|
||||
明确同意这个具体集合方案;推测用户意图、提前配置或先写模板都不算同意。
|
||||
|
||||
完成条件:用户明确同意本次集合方案;若用户拒绝或改变需求,回到 Inspect,
|
||||
不要产生源码编辑。
|
||||
|
||||
## 3. Configure once
|
||||
|
||||
在明确同意后,用 `data_service_configure` 对这一个最终集合列表调用一次,且
|
||||
只传实际项目上下文。不要因为响应慢、限流或不确定结果重复调用;配置响应是
|
||||
后续步骤的唯一门槛。
|
||||
|
||||
完成条件:一次调用明确成功并返回配置/实例 DTO。若失败或返回
|
||||
`project_identity_required`,立即停止,保持应用源码零编辑,并如实报告结果;
|
||||
不要安装模板、创建示例或声称服务已配置。
|
||||
|
||||
## 4. Install the canonical SDK
|
||||
|
||||
配置成功后,根据实际布局选择唯一目标:
|
||||
|
||||
1. 有 `src/` 时,若源代码/`tsconfig.json` 证明项目使用 TypeScript,目标是
|
||||
`src/lib/makelore-data.ts`,否则是 `src/lib/makelore-data.js`。
|
||||
2. 没有 `src/` 时,若已验证 TypeScript 工具链,目标是项目根的
|
||||
`makelore-data.ts`,否则是根目录 `makelore-data.js`。
|
||||
|
||||
从本 Skill 的 `assets/makelore-data.ts` 或 `assets/makelore-data.js` 逐字复制
|
||||
选中的文件;不要让模型从说明重写 transport。目标不存在时创建它;目标文本
|
||||
已与选中的 asset 完全相同则保持精确文本 no-op。目标存在但文本不同,先展示
|
||||
实际路径和冲突事实,明确询问“替换 canonical 文件”或“保留并由用户自行
|
||||
适配”;没有明确选择时不覆盖、不继续报告成功。
|
||||
|
||||
在最小有用的现有应用文件中加入指向该精确目标的相对 import,并添加最小
|
||||
示例调用。只编辑完成示例所需的 import/应用代码;不要加入 cloud URL、账号
|
||||
凭据、缓存、重试、离线同步、订阅、schema 或 policy 层。SDK 的 DELETE 是
|
||||
普通程序操作,不接受 `confirmed`。
|
||||
|
||||
完成条件:配置成功后,canonical asset 已按实际布局逐字落到唯一目标;重复
|
||||
运行会得到字节级 no-op;修改过的目标会先产生清晰冲突询问;最小应用编辑
|
||||
只引用该目标且没有凭据或远端地址。
|
||||
|
||||
## 5. Preview verification
|
||||
|
||||
用 `agent_browser` 打开当前项目的 data-enabled preview,要求其使用
|
||||
`inject_project_data: true` 的本地预览能力;不要在外部浏览器、发布运行时或
|
||||
通用 Host 路径中寻找替代能力。实际运行最小示例,先执行一次真实 `put`,再
|
||||
用返回的文档标识执行 `get`,读取并直接比较返回的文档数据与刚写入的数据。
|
||||
|
||||
完成条件:同一次数据预览会话中的真实 `put` 已成功,随后真实 `get` 的数据
|
||||
逐项匹配且带有服务端返回的 revision。没有匹配的 read-back、没有预览注入、
|
||||
或出现 `runtime_unavailable` 时,报告阻塞事实,不报告配置成功或配额状态。
|
||||
|
||||
## 6. report
|
||||
|
||||
只有 read-back 匹配后,才可用 `data_service_inspect` 读取并报告实际配置的
|
||||
集合和响应中的 quota/usage 状态。报告只引用本次工具响应观察到的字段,不猜
|
||||
测实例、owner、project、路径或剩余配额;如果 inspect 失败,报告验证失败而
|
||||
不是补造状态。
|
||||
|
||||
完成条件:报告明确区分配置响应、真实 put/get 结果和观察到的 quota/usage,
|
||||
并没有暴露凭据、云端地址、绝对路径或未观察到的服务端状态。
|
||||
|
||||
## Fixed SDK surface
|
||||
|
||||
程序只使用 `assets/makelore-data.ts` 或 `assets/makelore-data.js` 提供的
|
||||
`data.get`、`data.list`、`data.put`、`data.delete` 和可选 `data.add`。SDK 每次
|
||||
调用读取 `globalThis.__MAKELORE_DATA__`,只接受 `contractVersion === 1`,将
|
||||
集合/文档路径片段用 `encodeURIComponent` 编码,并把 `ifRevision` 转成一个
|
||||
strong `If-Match`。它严格解析直接的文档/page/error DTO;缺失注入时返回稳定
|
||||
`runtime_unavailable` 且不发网络请求。SDK 不持有凭据,不访问云端,不重试,
|
||||
不缓存,也不实现离线、订阅、schema 或 policy。
|
||||
369
resources/coding-skills/data-service/assets/makelore-data.js
Normal file
369
resources/coding-skills/data-service/assets/makelore-data.js
Normal file
@@ -0,0 +1,369 @@
|
||||
const runtimeGlobal = globalThis;
|
||||
const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/;
|
||||
const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/;
|
||||
const MAX_CURSOR_LENGTH = 1024;
|
||||
const MAX_ERROR_MESSAGE_LENGTH = 256;
|
||||
const MAX_RETRY_AFTER_SECONDS = 86400;
|
||||
const ERROR_STATUS = {
|
||||
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 = {
|
||||
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 = /* @__PURE__ */ new Set([
|
||||
"resource",
|
||||
"limit",
|
||||
"current",
|
||||
"attempted",
|
||||
"actual",
|
||||
"allowed",
|
||||
"current_revision",
|
||||
"retry_after_seconds"
|
||||
]);
|
||||
const CONTEXT_RESOURCES = /* @__PURE__ */ new Set(["instances", "collections", "documents", "bytes"]);
|
||||
function isRecord(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
function positiveInteger(value) {
|
||||
return Number.isSafeInteger(value) && value > 0;
|
||||
}
|
||||
function boundedText(value, maximum) {
|
||||
return typeof value === "string" && value.length > 0 && value.length <= maximum;
|
||||
}
|
||||
function boundedTimestamp(value) {
|
||||
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, required, optional = []) {
|
||||
const allowed = /* @__PURE__ */ 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, status) {
|
||||
const expected = ERROR_STATUS[code];
|
||||
return Array.isArray(expected) ? expected.includes(status) : expected === status;
|
||||
}
|
||||
function frozenContext(value) {
|
||||
return value === void 0 ? void 0 : Object.freeze({ ...value });
|
||||
}
|
||||
class DataServiceError extends Error {
|
||||
code;
|
||||
status;
|
||||
retryable;
|
||||
retryAfterSeconds;
|
||||
context;
|
||||
constructor(code, status, retryable, retryAfterSeconds, context) {
|
||||
super(ERROR_MESSAGE[code]);
|
||||
Object.defineProperty(this, "name", { value: "DataServiceError", enumerable: false });
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.retryable = retryable;
|
||||
if (retryAfterSeconds !== void 0) this.retryAfterSeconds = retryAfterSeconds;
|
||||
const safeContext = frozenContext(context);
|
||||
if (safeContext !== void 0) this.context = safeContext;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
function runtimeUnavailable() {
|
||||
return new DataServiceError("runtime_unavailable", 503, true);
|
||||
}
|
||||
function invalidRequest() {
|
||||
return new DataServiceError("invalid_request", 422, false);
|
||||
}
|
||||
function upstreamInvalidResponse() {
|
||||
return new DataServiceError("upstream_invalid_response", 502, false);
|
||||
}
|
||||
function readRuntime() {
|
||||
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;
|
||||
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) {
|
||||
if (typeof value !== "string" || !COLLECTION_PATTERN.test(value)) throw invalidRequest();
|
||||
return value;
|
||||
}
|
||||
function requireDocumentId(value) {
|
||||
if (typeof value !== "string" || value === "." || value === ".." || !DOCUMENT_ID_PATTERN.test(value)) {
|
||||
throw invalidRequest();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function requireData(value) {
|
||||
if (!isRecord(value)) throw invalidRequest();
|
||||
try {
|
||||
if (JSON.stringify(value) === void 0) throw new Error("data is not JSON");
|
||||
} catch {
|
||||
throw invalidRequest();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function requireOptions(value, allowed) {
|
||||
if (value === void 0) return {};
|
||||
if (!isRecord(value) || Object.keys(value).some((key) => !allowed.includes(key))) throw invalidRequest();
|
||||
return value;
|
||||
}
|
||||
function readIfRevision(value) {
|
||||
if (value === void 0) return void 0;
|
||||
if (!positiveInteger(value)) throw invalidRequest();
|
||||
return value;
|
||||
}
|
||||
function buildDocumentPath(endpoint, collection, documentId) {
|
||||
return `${endpoint}/collections/${encodeURIComponent(collection)}/documents/${encodeURIComponent(documentId)}`;
|
||||
}
|
||||
function parseContext(value) {
|
||||
if (value === void 0) return void 0;
|
||||
if (!isRecord(value)) throw upstreamInvalidResponse();
|
||||
const context = {};
|
||||
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;
|
||||
} else {
|
||||
if (!positiveInteger(item) && item !== 0) throw upstreamInvalidResponse();
|
||||
context[key] = item;
|
||||
}
|
||||
}
|
||||
return Object.keys(context).length === 0 ? void 0 : context;
|
||||
}
|
||||
function readRetryAfter(response) {
|
||||
const raw = response.headers?.get("retry-after")?.trim();
|
||||
if (!raw || !/^\d+$/.test(raw)) return void 0;
|
||||
const seconds = Number(raw);
|
||||
return Number.isSafeInteger(seconds) && seconds <= MAX_RETRY_AFTER_SECONDS ? seconds : void 0;
|
||||
}
|
||||
async function parseError(response) {
|
||||
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, response.status)) {
|
||||
throw upstreamInvalidResponse();
|
||||
}
|
||||
const context = parseContext(detail.context);
|
||||
return new DataServiceError(
|
||||
detail.code,
|
||||
response.status,
|
||||
detail.retryable,
|
||||
readRetryAfter(response),
|
||||
context
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DataServiceError) throw error;
|
||||
throw upstreamInvalidResponse();
|
||||
}
|
||||
}
|
||||
async function parseJson(response) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
throw upstreamInvalidResponse();
|
||||
}
|
||||
}
|
||||
function parseDocument(value) {
|
||||
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) {
|
||||
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(method, url, expectedStatus, parse, body, ifRevision) {
|
||||
const runtime = readRuntime();
|
||||
const fetchImpl = runtimeGlobal.fetch;
|
||||
if (typeof fetchImpl !== "function") throw runtimeUnavailable();
|
||||
const headers = {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${runtime.token}`
|
||||
};
|
||||
const serializedBody = body === void 0 ? void 0 : (() => {
|
||||
try {
|
||||
const encoded = JSON.stringify(body);
|
||||
return encoded === void 0 ? null : encoded;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (serializedBody === null) throw invalidRequest();
|
||||
if (serializedBody !== void 0) headers["Content-Type"] = "application/json";
|
||||
if (ifRevision !== void 0) headers["If-Match"] = `"${ifRevision}"`;
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(url, {
|
||||
method,
|
||||
headers,
|
||||
...serializedBody === void 0 ? {} : { 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 void 0;
|
||||
return parse(await parseJson(response));
|
||||
}
|
||||
function listPath(endpoint, collection, options) {
|
||||
const params = new URLSearchParams();
|
||||
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
||||
if (options.cursor !== void 0) params.set("cursor", options.cursor);
|
||||
const query = params.toString();
|
||||
return `${endpoint}/collections/${encodeURIComponent(collection)}/documents${query ? `?${query}` : ""}`;
|
||||
}
|
||||
async function getDocument(collection, documentId) {
|
||||
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, input = {}) {
|
||||
const runtime = readRuntime();
|
||||
const safeCollection = requireCollection(collection);
|
||||
const options = requireOptions(input, ["limit", "cursor"]);
|
||||
const limit = options.limit;
|
||||
if (limit !== void 0 && (!positiveInteger(limit) || limit > 100)) throw invalidRequest();
|
||||
const cursor = options.cursor;
|
||||
if (cursor !== void 0 && !boundedText(cursor, MAX_CURSOR_LENGTH)) throw invalidRequest();
|
||||
return await request(
|
||||
"GET",
|
||||
listPath(runtime.endpoint, safeCollection, {
|
||||
...limit === void 0 ? {} : { limit },
|
||||
...cursor === void 0 ? {} : { cursor }
|
||||
}),
|
||||
200,
|
||||
parsePage
|
||||
);
|
||||
}
|
||||
async function putDocument(collection, documentId, value, input = {}) {
|
||||
const runtime = readRuntime();
|
||||
const safeCollection = requireCollection(collection);
|
||||
const safeDocumentId = requireDocumentId(documentId);
|
||||
const data2 = requireData(value);
|
||||
const options = requireOptions(input, ["ifRevision"]);
|
||||
const ifRevision = readIfRevision(options.ifRevision);
|
||||
return await request(
|
||||
"PUT",
|
||||
buildDocumentPath(runtime.endpoint, safeCollection, safeDocumentId),
|
||||
200,
|
||||
parseDocument,
|
||||
{ data: data2 },
|
||||
ifRevision
|
||||
);
|
||||
}
|
||||
async function deleteDocument(collection, documentId, input = {}) {
|
||||
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,
|
||||
() => void 0,
|
||||
void 0,
|
||||
ifRevision
|
||||
);
|
||||
}
|
||||
async function addDocument(collection, value) {
|
||||
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);
|
||||
}
|
||||
const data = Object.freeze({
|
||||
get: getDocument,
|
||||
list: listDocuments,
|
||||
put: putDocument,
|
||||
delete: deleteDocument,
|
||||
add: addDocument
|
||||
});
|
||||
var makelore_data_default = data;
|
||||
export {
|
||||
DataServiceError,
|
||||
data,
|
||||
makelore_data_default as default
|
||||
};
|
||||
550
resources/coding-skills/data-service/assets/makelore-data.ts
Normal file
550
resources/coding-skills/data-service/assets/makelore-data.ts
Normal file
@@ -0,0 +1,550 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user