Initial import of LogDarioSeq baseline

Establishes version control for the personal Logseq DB fork before
starting the server-client architecture migration and domain features
described in docs/agent-guide and the approved implementation plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 15:32:15 +02:00
commit 40631362b3
2016 changed files with 543898 additions and 0 deletions

9
deps/db-sync/worker/api_docs.mjs vendored Normal file
View File

@@ -0,0 +1,9 @@
export function apiDocsResponse(path, html) {
if (path !== "/api-docs" && path !== "/api-docs/") return null;
return new Response(html, {
headers: {
"cache-control": "public, max-age=300",
"content-type": "text/html; charset=utf-8",
},
});
}

19
deps/db-sync/worker/api_docs_test.mjs vendored Normal file
View File

@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
import { apiDocsResponse } from "./api_docs.mjs";
test("API docs are served as standalone HTML from both docs paths", async () => {
const html = "<!doctype html><title>Logseq Server API</title>";
for (const path of ["/api-docs", "/api-docs/"]) {
const response = apiDocsResponse(path, html);
assert.equal(response.status, 200);
assert.equal(response.headers.get("content-type"), "text/html; charset=utf-8");
assert.equal(await response.text(), html);
}
});
test("API docs helper ignores unrelated paths", () => {
assert.equal(apiDocsResponse("/openapi.json", "docs"), null);
});

232
deps/db-sync/worker/asset_image.mjs vendored Normal file
View File

@@ -0,0 +1,232 @@
function bytesToBase64(bytes) {
const chunkSize = 32 * 1024;
const chunks = [];
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)));
}
return btoa(chunks.join(""));
}
const MAX_DISPLAY_IMAGE_SIZE = 10 * 1024 * 1024;
export const ASSET_IMAGE_RESOURCE_URI = "ui://widget/logseq-asset-image.html";
const ASSET_IMAGE_COMPONENT_HTML = `
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
:root { color-scheme: light dark; }
body {
margin: 0;
padding: 8px;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
figure { margin: 0; }
#image-button {
display: block;
width: 100%;
padding: 0;
border: 0;
background: transparent;
cursor: zoom-in;
}
img {
display: block;
width: 100%;
height: auto;
max-height: min(70vh, 720px);
object-fit: contain;
border-radius: 8px;
}
figcaption {
margin-top: 6px;
color: rgba(127, 127, 127, 0.95);
font-size: 12px;
line-height: 1.4;
}
.error {
color: #b42318;
font-size: 13px;
}
</style>
</head>
<body>
<figure>
<button id="image-button" type="button" aria-label="Open image fullscreen">
<img id="image" alt="">
</button>
<figcaption id="caption"></figcaption>
</figure>
<p id="error" class="error" hidden>Image data was not provided by the tool result.</p>
<script>
let objectUrl;
function currentToolResult() {
const metadata = window.openai?.toolResponseMetadata;
return metadata?.mcp_tool_result
|| metadata?.call_tool_result
|| { structuredContent: window.openai?.toolOutput };
}
function imageFromResult(result) {
const metadataImage = result?._meta?.image;
if (metadataImage?.data && metadataImage?.mimeType) return metadataImage;
const contentImage = result?.content?.find((item) => item?.type === "image");
if (!contentImage?.data || !contentImage?.mimeType) return null;
const details = result?.structuredContent || {};
return {
data: contentImage.data,
mimeType: contentImage.mimeType,
title: details.title,
uuid: details.uuid,
};
}
function bytesFromBase64(base64) {
const raw = atob(base64);
const payload = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) payload[i] = raw.charCodeAt(i);
return payload;
}
function notifyHeight() {
const notify = window.openai?.notifyIntrinsicHeight;
if (!notify) return;
const height = Math.ceil(document.documentElement.scrollHeight);
try {
notify({ height });
} catch (_) {
try {
notify(height);
} catch (_) {
notify();
}
}
}
function scheduleHeightNotification() {
requestAnimationFrame(() => notifyHeight());
}
async function requestFullscreen() {
await window.openai?.requestDisplayMode?.({ mode: "fullscreen" });
scheduleHeightNotification();
}
function render(result) {
const image = imageFromResult(result);
const buttonElement = document.getElementById("image-button");
const imageElement = document.getElementById("image");
const captionElement = document.getElementById("caption");
const errorElement = document.getElementById("error");
if (!image) {
buttonElement.hidden = true;
imageElement.hidden = true;
captionElement.textContent = "";
errorElement.hidden = false;
scheduleHeightNotification();
return;
}
if (objectUrl) URL.revokeObjectURL(objectUrl);
objectUrl = URL.createObjectURL(new Blob([bytesFromBase64(image.data)], { type: image.mimeType }));
imageElement.src = objectUrl;
imageElement.alt = image.title || "Logseq image asset";
buttonElement.hidden = false;
imageElement.hidden = false;
errorElement.hidden = true;
captionElement.textContent = image.title
? image.uuid ? image.title + " (" + image.uuid + ")" : image.title
: image.uuid || "";
scheduleHeightNotification();
}
document.getElementById("image").addEventListener("load", notifyHeight);
document.getElementById("image-button").addEventListener("click", () => {
requestFullscreen().catch(() => {});
});
render(currentToolResult());
window.addEventListener("message", (event) => {
if (event.source !== window.parent) return;
const message = event.data;
if (!message || message.jsonrpc !== "2.0") return;
if (message.method !== "ui/notifications/tool-result") return;
render(message.params);
}, { passive: true });
window.addEventListener("openai:set_globals", (event) => {
const metadata = event.detail?.globals?.toolResponseMetadata;
const output = event.detail?.globals?.toolOutput;
render(metadata?.mcp_tool_result || metadata?.call_tool_result || { structuredContent: output });
}, { passive: true });
</script>
</body>
</html>
`.trim();
export function assertDisplayableImageMetadata(metadata) {
if (!Number.isSafeInteger(metadata.size) || metadata.size < 0) {
throw new Error("asset has an invalid size");
}
if (metadata.size > MAX_DISPLAY_IMAGE_SIZE) {
throw new Error("asset is too large to display directly (10MB maximum)");
}
}
export async function assetImageResult(response, metadata) {
const mimeType = response.headers.get("content-type")?.split(";", 1)[0];
if (!response.ok || !mimeType?.startsWith("image/")) {
throw new Error(`asset response is not an image (${response.status} ${mimeType ?? "unknown"})`);
}
const payload = new Uint8Array(await response.arrayBuffer());
const data = bytesToBase64(payload);
const title = metadata.title ?? "Image asset";
const uuid = metadata.uuid;
return {
structuredContent: {
uuid,
title,
mimeType,
size: payload.length,
},
content: [
{ type: "image", data, mimeType },
{ type: "text", text: `${title} (${uuid})` },
],
_meta: {
ui: { resourceUri: ASSET_IMAGE_RESOURCE_URI },
"openai/outputTemplate": ASSET_IMAGE_RESOURCE_URI,
image: {
data,
mimeType,
title,
uuid,
},
},
};
}
export async function assetImageResource() {
return {
contents: [
{
uri: ASSET_IMAGE_RESOURCE_URI,
mimeType: "text/html;profile=mcp-app",
text: ASSET_IMAGE_COMPONENT_HTML,
_meta: {
ui: {
prefersBorder: true,
},
},
},
],
};
}

View File

@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as assetImage from "./asset_image.mjs";
test("asset image result returns MCP image content", async () => {
const response = new Response(new Uint8Array([1, 2, 3, 4]), {
headers: { "content-type": "image/jpeg" },
});
assert.deepEqual(await assetImage.assetImageResult(response, { uuid: "asset-1", title: "Photo" }), {
structuredContent: {
uuid: "asset-1",
title: "Photo",
mimeType: "image/jpeg",
size: 4,
},
content: [
{ type: "image", data: "AQIDBA==", mimeType: "image/jpeg" },
{ type: "text", text: "Photo (asset-1)" },
],
_meta: {
ui: { resourceUri: "ui://widget/logseq-asset-image.html" },
"openai/outputTemplate": "ui://widget/logseq-asset-image.html",
image: {
data: "AQIDBA==",
mimeType: "image/jpeg",
title: "Photo",
uuid: "asset-1",
},
},
});
});
test("asset image result rejects non-image responses", async () => {
await assert.rejects(
assetImage.assetImageResult(new Response("{}", { headers: { "content-type": "application/json" } }), {}),
/not an image/,
);
});
test("asset image display rejects payloads that are unsafe to base64 in Worker memory", () => {
assert.throws(
() => assetImage.assertDisplayableImageMetadata({ size: 10 * 1024 * 1024 + 1 }),
/too large to display/,
);
});
test("asset image resource is an MCP app component that renders tool results", async () => {
assert.equal(typeof assetImage.assetImageResource, "function");
const resource = await assetImage.assetImageResource();
const content = resource.contents[0];
assert.equal(content.uri, assetImage.ASSET_IMAGE_RESOURCE_URI);
assert.equal(assetImage.ASSET_IMAGE_RESOURCE_URI, "ui://widget/logseq-asset-image.html");
assert.equal(content.mimeType, "text/html;profile=mcp-app");
assert.match(content.text, /ui\/notifications\/tool-result/);
assert.match(content.text, /toolResponseMetadata/);
assert.match(content.text, /createObjectURL/);
assert.match(content.text, /notifyIntrinsicHeight/);
assert.match(content.text, /requestDisplayMode/);
assert.match(content.text, /image-button/);
assert.match(content.text, /zoom-in/);
assert.doesNotMatch(content.text, /https?:\/\//);
});

160
deps/db-sync/worker/chatgpt_app.mjs vendored Normal file
View File

@@ -0,0 +1,160 @@
import { ASSET_IMAGE_RESOURCE_URI } from "./asset_image.mjs";
const writeScopes = ["logseq/read", "logseq/write"];
const imageOutputSchema = {
type: "object",
properties: {
uuid: { type: "string", description: "Image asset block UUID" },
title: { type: "string", description: "Image asset title" },
mimeType: { type: "string", description: "Image MIME type" },
size: { type: "integer", description: "Image size in bytes" },
},
required: ["uuid", "title", "mimeType", "size"],
additionalProperties: false,
};
const fileInputSchema = {
type: "object",
properties: {
download_url: { type: "string", description: "Temporary HTTPS URL for the original ChatGPT file" },
file_id: { type: "string", description: "ChatGPT file identifier" },
mime_type: { type: "string", description: "File MIME type" },
file_name: { type: "string", description: "Original file name including its extension" },
},
required: ["download_url", "file_id"],
additionalProperties: false,
};
const assetOutputSchema = {
type: "object",
properties: {
uuid: { type: "string", description: "Asset block UUID" },
title: { type: "string", description: "Asset block title" },
type: { type: "string", description: "Asset file extension" },
size: { type: "integer", description: "Original file size in bytes" },
checksum: { type: "string", description: "SHA-256 checksum of the original file" },
},
required: ["uuid", "title", "type", "size", "checksum"],
additionalProperties: false,
};
function oauthSecurity(scopes) {
return [{ type: "oauth2", scopes }];
}
export function chatGptToolDescriptors() {
const searchSecurity = oauthSecurity(writeScopes);
const executeSecurity = oauthSecurity(writeScopes);
const imageSecurity = oauthSecurity(["logseq/read"]);
return [
{
name: "search",
title: "Discover Logseq operations",
description: "Search the Logseq DB graph OpenAPI document before making requests. For tasks, discover the dedicated /tasks operations: never use Markdown TODO syntax to create tasks and never use graph search to list tasks. Use upload_asset for ChatGPT attachments and never encode an attachment as base64 in Code Mode. This API uses DB-version typed properties; never use legacy file-graph key:: value syntax. Pass JavaScript as an async arrow function; codemode.spec() returns the OpenAPI document with references resolved inline.",
inputSchema: {
type: "object",
properties: {
code: { type: "string", description: "JavaScript async arrow function that searches the Logseq OpenAPI document" },
},
required: ["code"],
additionalProperties: false,
},
annotations: {
readOnlyHint: true,
openWorldHint: false,
destructiveHint: false,
idempotentHint: true,
},
securitySchemes: searchSecurity,
_meta: {
securitySchemes: searchSecurity,
"openai/toolInvocation/invoking": "Discovering Logseq operations…",
"openai/toolInvocation/invoked": "Logseq operations ready",
},
},
{
name: "execute",
title: "Use Logseq",
description: "Read or change the user's non-encrypted Logseq DB graph through the semantic API. Use the dedicated /tasks operations for DB tasks: never use Markdown TODO syntax to create tasks and never use graph search to list tasks. Use upload_asset for ChatGPT attachments and never encode an attachment as base64 in Code Mode. Use DB-version typed properties and property UUIDs or idents; never write legacy file-graph key:: value syntax into block titles. First use search to discover endpoints. Pass JavaScript as an async arrow function; codemode.request(options) calls only documented Logseq endpoints.",
inputSchema: {
type: "object",
properties: {
code: { type: "string", description: "JavaScript async arrow function that calls the Logseq API" },
},
required: ["code"],
additionalProperties: false,
},
annotations: {
readOnlyHint: false,
openWorldHint: false,
destructiveHint: true,
},
securitySchemes: executeSecurity,
_meta: {
securitySchemes: executeSecurity,
"openai/toolInvocation/invoking": "Updating Logseq…",
"openai/toolInvocation/invoked": "Logseq request complete",
},
},
{
name: "upload_asset",
title: "Upload Logseq asset",
description: "Upload the original ChatGPT attachment, unchanged and without base64 re-encoding, to a non-encrypted Logseq DB graph and create its Asset block. Optionally append it to a page. The file must not exceed 100MB; its original name is preserved when available.",
inputSchema: {
type: "object",
properties: {
graphId: { type: "string", description: "Graph UUID" },
file: fileInputSchema,
pageId: { type: "string", description: "Optional destination page UUID, ident, or title" },
title: { type: "string", description: "Optional asset block title" },
},
required: ["graphId", "file"],
additionalProperties: false,
},
outputSchema: assetOutputSchema,
annotations: {
readOnlyHint: false,
openWorldHint: true,
destructiveHint: false,
idempotentHint: false,
},
securitySchemes: executeSecurity,
_meta: {
securitySchemes: executeSecurity,
"openai/fileParams": ["file"],
"openai/toolInvocation/invoking": "Uploading Logseq asset…",
"openai/toolInvocation/invoked": "Logseq asset uploaded",
},
},
{
name: "get_asset_image",
title: "Display Logseq image asset",
description: "Fetch an image asset up to 10MB from a non-encrypted Logseq DB graph and return it as image content that ChatGPT can display directly. Use this tool instead of getAsset when the user asks to view or display an image. It is read-only and does not download to local storage or modify the graph.",
inputSchema: {
type: "object",
properties: {
graphId: { type: "string", description: "Graph UUID" },
assetBlockId: { type: "string", description: "Image asset block UUID" },
},
required: ["graphId", "assetBlockId"],
additionalProperties: false,
},
outputSchema: imageOutputSchema,
annotations: {
readOnlyHint: true,
openWorldHint: false,
destructiveHint: false,
idempotentHint: true,
},
securitySchemes: imageSecurity,
_meta: {
securitySchemes: imageSecurity,
ui: { resourceUri: ASSET_IMAGE_RESOURCE_URI },
"openai/outputTemplate": ASSET_IMAGE_RESOURCE_URI,
"openai/toolInvocation/invoking": "Loading Logseq image…",
"openai/toolInvocation/invoked": "Logseq image ready",
},
},
];
}

View File

@@ -0,0 +1,128 @@
import { createHash } from "node:crypto";
const MAX_ASSET_SIZE = 100 * 1024 * 1024;
const MIME_TYPE_EXTENSIONS = new Map([
["image/png", "png"],
["image/jpeg", "jpg"],
["image/gif", "gif"],
["image/webp", "webp"],
["image/bmp", "bmp"],
["image/svg+xml", "svg"],
["image/x-icon", "ico"],
["application/pdf", "pdf"],
]);
function requiredFileLength(response) {
const value = response.headers.get("content-length");
if (!/^\d+$/.test(value ?? "")) {
throw new Error("ChatGPT file download must include content-length");
}
const size = Number(value);
if (!Number.isSafeInteger(size) || size > MAX_ASSET_SIZE) {
throw new Error("ChatGPT file is larger than 100MB");
}
return size;
}
function fixedLengthBody(body, size) {
if (typeof FixedLengthStream !== "function") return { body, pipePromise: null };
const fixed = new FixedLengthStream(size);
return {
body: fixed.readable,
pipePromise: body.pipeTo(fixed.writable),
};
}
function validateInput({ graphId, file }) {
if (!graphId) throw new Error("graphId is required");
if (!file?.file_id) throw new Error("file.file_id is required");
if (!file?.download_url) throw new Error("file.download_url is required");
const downloadUrl = new URL(file.download_url);
if (downloadUrl.protocol !== "https:") {
throw new Error("file.download_url must use HTTPS");
}
return downloadUrl;
}
function resolvedFileName(file, downloadUrl, contentType) {
if (file.file_name) return file.file_name;
const pathName = decodeURIComponent(downloadUrl.pathname.split("/").pop() ?? "");
if (/\.[a-z0-9]+$/i.test(pathName)) return pathName;
const extension = MIME_TYPE_EXTENSIONS.get(file.mime_type ?? contentType);
if (!extension) throw new Error("file.file_name is required for this file type");
return `${file.file_id}.${extension}`;
}
export async function uploadChatGptAsset(input, dependencies) {
const {
origin,
authorization,
env,
ctx,
workerFetch,
fetchFile = fetch,
} = dependencies;
const downloadUrl = validateInput(input);
const bucket = env.LOGSEQ_SYNC_ASSETS;
if (!bucket) throw new Error("missing assets bucket");
const fileResponse = await fetchFile(downloadUrl);
if (!fileResponse.ok || !fileResponse.body) {
throw new Error(`ChatGPT file download failed: ${fileResponse.status}`);
}
const size = requiredFileLength(fileResponse);
const contentType = input.file.mime_type ?? fileResponse.headers.get("content-type");
const fileName = resolvedFileName(input.file, downloadUrl, contentType);
const hash = createHash("sha256");
let received = 0;
const hashingStream = new TransformStream({
transform(chunk, controller) {
const payload = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
received += payload.byteLength;
if (received > size) throw new Error("ChatGPT file exceeds declared content-length");
hash.update(payload);
controller.enqueue(payload);
},
});
const hashedBody = fileResponse.body.pipeThrough(hashingStream);
const { body, pipePromise } = fixedLengthBody(hashedBody, size);
const tempKey = `chatgpt-uploads/${crypto.randomUUID()}`;
try {
const uploadResults = await Promise.allSettled([
bucket.put(tempKey, body, {
httpMetadata: { contentType: contentType ?? "application/octet-stream" },
}),
pipePromise,
].filter(Boolean));
const uploadFailure = uploadResults.find((result) => result.status === "rejected");
if (uploadFailure) throw uploadFailure.reason;
if (received !== size) throw new Error("ChatGPT file is shorter than declared content-length");
const stored = await bucket.get(tempKey);
if (!stored?.body) throw new Error("temporary ChatGPT upload is missing");
const url = new URL(`/api/v1/graphs/${encodeURIComponent(input.graphId)}/assets`, origin);
url.searchParams.set("file-name", fileName);
url.searchParams.set("size", String(size));
url.searchParams.set("checksum", hash.digest("hex"));
if (input.pageId) url.searchParams.set("page-id", input.pageId);
if (input.title) url.searchParams.set("title", input.title);
const headers = new Headers();
if (authorization) headers.set("authorization", authorization);
headers.set("content-type", contentType ?? "application/octet-stream");
const response = await workerFetch(new Request(url, {
method: "POST",
headers,
body: stored.body,
duplex: "half",
}), env, ctx);
const result = await response.json();
if (!response.ok) {
throw new Error(`Logseq API request failed: ${response.status} ${JSON.stringify(result)}`);
}
return result;
} finally {
await bucket.delete(tempKey);
}
}

191
deps/db-sync/worker/entry.mjs vendored Normal file
View File

@@ -0,0 +1,191 @@
import { DynamicWorkerExecutor } from "@cloudflare/codemode";
import { openApiMcpServer } from "@cloudflare/codemode/mcp";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { chatGptToolDescriptors } from "./chatgpt_app.mjs";
import { uploadChatGptAsset } from "./chatgpt_asset_upload.mjs";
import {
ASSET_IMAGE_RESOURCE_URI,
assertDisplayableImageMetadata,
assetImageResource,
assetImageResult,
} from "./asset_image.mjs";
import { apiDocsResponse } from "./api_docs.mjs";
import { logseqIconResponse, setLogseqMcpServerInfoIcons } from "./logseq_icon.mjs";
import { ensureMcpAcceptHeader } from "./mcp_request.mjs";
import { openAiAppsChallengeResponse } from "./openai_app_challenge.mjs";
import { semanticRequestBody, semanticRequestUrl } from "./semantic_request.mjs";
import apiDocsHtml from "./dist/api-docs.generated.mjs";
import worker, { SyncDO } from "./dist/worker/main.js";
function protectedResourceMetadata(request, env) {
const url = new URL(request.url);
return Response.json({
resource: `${url.origin}/mcp`,
authorization_servers: [env.COGNITO_ISSUER],
scopes_supported: ["logseq/read", "logseq/write"],
bearer_methods_supported: ["header"],
});
}
async function semanticSpec(request, env, ctx) {
const url = new URL("/openapi.json", request.url);
const response = await worker.fetch(new Request(url), env, ctx);
if (!response.ok) {
throw new Error(`OpenAPI request failed: ${response.status}`);
}
return response.json();
}
async function handleMcp(request, env, ctx) {
const authorization = request.headers.get("authorization");
const server = openApiMcpServer({
spec: await semanticSpec(request, env, ctx),
executor: new DynamicWorkerExecutor({ loader: env.LOADER }),
name: "logseq",
version: "1.0.0",
request: async (options) => {
if (!options.path.startsWith("/api/v1/")) {
throw new Error("Only Logseq server API paths are allowed");
}
const url = semanticRequestUrl(options, request.url);
const headers = {};
if (authorization) headers.authorization = authorization;
if (options.contentType) headers["content-type"] = options.contentType;
else if (options.body !== undefined) headers["content-type"] = "application/json";
const response = await worker.fetch(
new Request(url, {
method: options.method,
headers,
body: semanticRequestBody(options, url),
}),
env,
ctx,
);
if (response.status === 204) return null;
const result = (response.headers.get("content-type") ?? "").includes("application/json")
? await response.json()
: await response.text();
if (!response.ok) {
throw new Error(`Logseq API request failed: ${response.status} ${JSON.stringify(result)}`);
}
return result;
},
});
setLogseqMcpServerInfoIcons(server, request);
server.registerResource("asset-image", ASSET_IMAGE_RESOURCE_URI, {
title: "Logseq image asset",
description: "Renders a Logseq image asset returned by the image asset tool.",
mimeType: "text/html;profile=mcp-app",
}, assetImageResource);
server.registerTool("upload_asset", {
title: "Upload Logseq asset",
description: "Upload an original ChatGPT attachment to a Logseq DB graph.",
inputSchema: {
graphId: z.string(),
file: z.object({
download_url: z.string(),
file_id: z.string(),
mime_type: z.string().optional(),
file_name: z.string().optional(),
}),
pageId: z.string().optional(),
title: z.string().optional(),
},
outputSchema: {
uuid: z.string(),
title: z.string(),
type: z.string(),
size: z.number().int().nonnegative(),
checksum: z.string(),
},
annotations: {
readOnlyHint: false,
openWorldHint: true,
destructiveHint: false,
idempotentHint: false,
},
_meta: {
"openai/fileParams": ["file"],
},
}, (input) => uploadChatGptAsset(input, {
origin: new URL(request.url).origin,
authorization,
env,
ctx,
workerFetch: worker.fetch.bind(worker),
}).then((result) => ({
structuredContent: result,
content: [{ type: "text", text: `Uploaded Logseq asset ${result.title} (${result.uuid}).` }],
})));
server.registerTool("get_asset_image", {
title: "Display Logseq image asset",
description: "Return a Logseq image asset as MCP image content for direct display.",
inputSchema: {
graphId: z.string(),
assetBlockId: z.string(),
},
outputSchema: {
uuid: z.string(),
title: z.string(),
mimeType: z.string(),
size: z.number().int().nonnegative(),
},
annotations: {
readOnlyHint: true,
openWorldHint: false,
destructiveHint: false,
idempotentHint: true,
},
_meta: {
ui: { resourceUri: ASSET_IMAGE_RESOURCE_URI },
"openai/outputTemplate": ASSET_IMAGE_RESOURCE_URI,
},
}, async ({ graphId, assetBlockId }) => {
const metadataUrl = new URL(
`/api/v1/graphs/${encodeURIComponent(graphId)}/assets/${encodeURIComponent(assetBlockId)}`,
request.url,
);
const metadataResponse = await worker.fetch(new Request(metadataUrl, {
headers: authorization ? { authorization } : {},
}), env, ctx);
const metadata = await metadataResponse.json();
if (!metadataResponse.ok) {
throw new Error(`Logseq API request failed: ${metadataResponse.status} ${JSON.stringify(metadata)}`);
}
assertDisplayableImageMetadata(metadata);
const imageResponse = await worker.fetch(new Request(metadata.url), env, ctx);
return assetImageResult(imageResponse, metadata);
});
const transport = new WebStandardStreamableHTTPServerTransport();
await server.connect(transport);
server.server.setRequestHandler(ListToolsRequestSchema, () => ({
tools: chatGptToolDescriptors(),
}));
return transport.handleRequest(request);
}
export default {
async fetch(request, env, ctx) {
const path = new URL(request.url).pathname;
const challengeResponse = openAiAppsChallengeResponse(request, env);
if (challengeResponse) return challengeResponse;
const docsResponse = apiDocsResponse(path, apiDocsHtml);
if (docsResponse) return docsResponse;
if (path === "/.well-known/oauth-protected-resource" ||
path === "/.well-known/oauth-protected-resource/mcp") {
return protectedResourceMetadata(request, env);
}
if (path === "/mcp/icon.png") return logseqIconResponse();
if (path === "/mcp") return handleMcp(ensureMcpAcceptHeader(request), env, ctx);
return worker.fetch(request, env, ctx);
},
};
export { SyncDO };

311
deps/db-sync/worker/entry_test.mjs vendored Normal file
View File

@@ -0,0 +1,311 @@
import assert from "node:assert/strict";
import test from "node:test";
import { chatGptToolDescriptors } from "./chatgpt_app.mjs";
import {
logseqIconResponse,
setLogseqMcpServerInfoIcons,
} from "./logseq_icon.mjs";
import { ensureMcpAcceptHeader } from "./mcp_request.mjs";
import { openAiAppsChallengeResponse } from "./openai_app_challenge.mjs";
import { semanticRequestBody, semanticRequestUrl } from "./semantic_request.mjs";
import { uploadChatGptAsset } from "./chatgpt_asset_upload.mjs";
test("OpenAI app domain challenge returns the configured token as plain text", async () => {
const token = "openai-domain-verification-token";
const response = openAiAppsChallengeResponse(
new Request("https://api.logseq.io/.well-known/openai-apps-challenge"),
{ OPENAI_APPS_CHALLENGE: token },
);
assert.equal(response.status, 200);
assert.equal(response.headers.get("content-type"), "text/plain; charset=utf-8");
assert.equal(response.headers.get("cache-control"), "no-store");
assert.equal(await response.text(), token);
});
test("OpenAI app domain challenge fails fast when the token is not configured", () => {
assert.throws(
() => openAiAppsChallengeResponse(
new Request("https://api.logseq.io/.well-known/openai-apps-challenge"),
{},
),
/OPENAI_APPS_CHALLENGE is required/,
);
});
test("OpenAI app domain challenge route does not match nested paths", async () => {
const response = openAiAppsChallengeResponse(
new Request("https://api.logseq.io/mcp/.well-known/openai-apps-challenge"),
{ OPENAI_APPS_CHALLENGE: "token" },
);
assert.equal(response, null);
});
test("OpenAI app domain challenge route only handles GET", () => {
const response = openAiAppsChallengeResponse(
new Request("https://api.logseq.io/.well-known/openai-apps-challenge", { method: "POST" }),
{ OPENAI_APPS_CHALLENGE: "token" },
);
assert.equal(response, null);
});
test("Code Mode derives decoded asset size instead of trusting the agent", () => {
const url = semanticRequestUrl({
method: "POST",
path: "/api/v1/graphs/graph-1/assets",
query: { encoding: "base64", size: 3, checksum: "a".repeat(64) },
body: "AQIDBA==",
rawBody: true,
}, "https://staging.example/mcp");
assert.equal(url.searchParams.get("size"), "4");
});
test("Code Mode rejects invalid base64 before forwarding", () => {
assert.throws(() => semanticRequestUrl({
path: "/api/v1/graphs/graph-1/assets",
query: { encoding: "base64", size: 1 },
body: "not base64!",
}, "https://staging.example/mcp"), /invalid base64/);
});
test("Code Mode always forwards base64 assets as a raw body", () => {
const options = {
path: "/api/v1/graphs/graph-1/assets",
query: { encoding: "base64", size: 3 },
body: "AQIDBA==",
};
const url = semanticRequestUrl(options, "https://staging.example/mcp");
assert.equal(semanticRequestBody(options, url), "AQIDBA==");
});
test("ChatGPT tool descriptors declare titles, impact, and per-tool auth", () => {
const descriptors = chatGptToolDescriptors();
const search = descriptors.find((tool) => tool.name === "search");
const execute = descriptors.find((tool) => tool.name === "execute");
assert.deepEqual(search.annotations, {
readOnlyHint: true,
openWorldHint: false,
destructiveHint: false,
idempotentHint: true,
});
assert.deepEqual(search.securitySchemes, [
{ type: "oauth2", scopes: ["logseq/read", "logseq/write"] },
]);
assert.deepEqual(search._meta.securitySchemes, search.securitySchemes);
assert.deepEqual(execute.annotations, {
readOnlyHint: false,
openWorldHint: false,
destructiveHint: true,
});
assert.deepEqual(execute.securitySchemes, [
{ type: "oauth2", scopes: ["logseq/read", "logseq/write"] },
]);
assert.deepEqual(execute._meta.securitySchemes, execute.securitySchemes);
});
test("ChatGPT tools require DB graph property semantics", () => {
const descriptors = chatGptToolDescriptors().filter((tool) => ["search", "execute"].includes(tool.name));
for (const tool of descriptors) {
assert.match(tool.description, /DB graph/);
assert.match(tool.description, /typed properties/);
assert.match(tool.description, /key:: value/);
}
});
test("ChatGPT tools describe the DB Task workflow", () => {
const descriptors = chatGptToolDescriptors().filter((tool) => ["search", "execute"].includes(tool.name));
for (const tool of descriptors) {
assert.match(tool.description, /\/tasks/);
assert.match(tool.description, /never use Markdown TODO/);
assert.match(tool.description, /never use graph search to list tasks/);
}
});
test("ChatGPT tools direct attachment uploads to the file-aware tool", () => {
for (const tool of chatGptToolDescriptors().filter((descriptor) => ["search", "execute"].includes(descriptor.name))) {
assert.match(tool.description, /upload_asset/);
assert.match(tool.description, /never encode.*base64/i);
}
});
test("ChatGPT exposes the asset attachment as an OpenAI file parameter", () => {
const tool = chatGptToolDescriptors().find((descriptor) => descriptor.name === "upload_asset");
assert.equal(tool.title, "Upload Logseq asset");
assert.deepEqual(tool._meta["openai/fileParams"], ["file"]);
assert.equal(tool.inputSchema.properties.file.type, "object");
assert.deepEqual(tool.inputSchema.properties.file.required, ["download_url", "file_id"]);
assert.deepEqual(tool.securitySchemes, [{ type: "oauth2", scopes: ["logseq/read", "logseq/write"] }]);
assert.deepEqual(tool.annotations, {
readOnlyHint: false,
openWorldHint: true,
destructiveHint: false,
idempotentHint: false,
});
});
test("ChatGPT asset upload preserves the original bytes and derives metadata", async () => {
const original = new Uint8Array(106816);
for (let index = 0; index < original.length; index += 1) original[index] = index % 251;
const tempObjects = new Map();
const bucket = {
async put(key, body) {
tempObjects.set(key, new Uint8Array(await new Response(body).arrayBuffer()));
},
async get(key) {
const payload = tempObjects.get(key);
return payload ? { body: new Response(payload).body } : null;
},
async delete(key) {
tempObjects.delete(key);
},
};
let semanticRequest;
const result = await uploadChatGptAsset({
graphId: "graph-1",
pageId: "page-1",
title: "Logseq Logo 2.0",
file: {
download_url: "https://files.openai.example/logo.jpg",
file_id: "file-1",
mime_type: "image/jpeg",
file_name: "logo.jpg",
},
}, {
origin: "https://api.logseq.io",
authorization: "Bearer token",
env: { LOGSEQ_SYNC_ASSETS: bucket },
fetchFile: async () => new Response(original, {
headers: { "content-length": String(original.byteLength), "content-type": "image/jpeg" },
}),
workerFetch: async (request) => {
semanticRequest = request;
const uploaded = new Uint8Array(await request.arrayBuffer());
assert.deepEqual(uploaded, original);
return Response.json({ uuid: "asset-1", size: uploaded.byteLength }, { status: 201 });
},
});
const url = new URL(semanticRequest.url);
assert.equal(url.pathname, "/api/v1/graphs/graph-1/assets");
assert.equal(url.searchParams.get("file-name"), "logo.jpg");
assert.equal(url.searchParams.get("page-id"), "page-1");
assert.equal(url.searchParams.get("title"), "Logseq Logo 2.0");
assert.equal(url.searchParams.get("size"), "106816");
assert.equal(url.searchParams.get("checksum"), "2845255da015e951337b70413a1af5b661c59e41cfe5ba4c64dd1b8168143b60");
assert.equal(semanticRequest.headers.get("authorization"), "Bearer token");
assert.equal(semanticRequest.headers.get("content-type"), "image/jpeg");
assert.deepEqual(result, { uuid: "asset-1", size: 106816 });
assert.equal(tempObjects.size, 0);
});
test("ChatGPT asset upload rejects missing length without buffering the file", async () => {
await assert.rejects(() => uploadChatGptAsset({
graphId: "graph-1",
file: {
download_url: "https://files.openai.example/logo.jpg",
file_id: "file-1",
file_name: "logo.jpg",
},
}, {
origin: "https://api.logseq.io",
env: { LOGSEQ_SYNC_ASSETS: {} },
fetchFile: async () => new Response(new Uint8Array([1, 2, 3])),
}), /content-length/);
});
test("ChatGPT exposes a read-only image asset tool", () => {
const tool = chatGptToolDescriptors().find((descriptor) => descriptor.name === "get_asset_image");
assert.equal(tool.title, "Display Logseq image asset");
assert.deepEqual(tool.annotations, {
readOnlyHint: true,
openWorldHint: false,
destructiveHint: false,
idempotentHint: true,
});
assert.deepEqual(tool.securitySchemes, [{ type: "oauth2", scopes: ["logseq/read"] }]);
});
test("ChatGPT image asset tool declares a UI resource for ordinary chat rendering", () => {
const tool = chatGptToolDescriptors().find((descriptor) => descriptor.name === "get_asset_image");
assert.ok(tool._meta?.ui);
assert.equal(tool._meta.ui.resourceUri, "ui://widget/logseq-asset-image.html");
assert.equal(tool._meta["openai/outputTemplate"], "ui://widget/logseq-asset-image.html");
assert.equal(tool.outputSchema.type, "object");
assert.deepEqual(tool.outputSchema.required, ["uuid", "title", "mimeType", "size"]);
});
test("MCP serverInfo advertises the Logseq icon from the worker origin", () => {
const server = { server: { _serverInfo: { name: "logseq", version: "1.0.0" } } };
setLogseqMcpServerInfoIcons(server, new Request("https://staging.example/mcp"));
assert.deepEqual(server.server._serverInfo.icons, [{
src: "https://staging.example/mcp/icon.png",
mimeType: "image/png",
sizes: ["192x192"],
}]);
});
test("MCP icon response serves a PNG logo", async () => {
const response = logseqIconResponse();
const payload = new Uint8Array(await response.arrayBuffer());
assert.equal(response.headers.get("content-type"), "image/png");
assert.match(response.headers.get("cache-control"), /public/);
assert.deepEqual([...payload.slice(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
});
test("MCP requests with JSON-only Accept are upgraded for streamable HTTP discovery", () => {
const request = new Request("https://staging.example/mcp", {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
},
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
});
const upgraded = ensureMcpAcceptHeader(request);
assert.notEqual(upgraded, request);
assert.equal(upgraded.headers.get("accept"), "application/json, text/event-stream");
assert.equal(upgraded.headers.get("content-type"), "application/json");
});
test("MCP requests without Accept are upgraded for streamable HTTP discovery", () => {
const request = new Request("https://staging.example/mcp", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
});
const upgraded = ensureMcpAcceptHeader(request);
assert.equal(upgraded.headers.get("accept"), "application/json, text/event-stream");
});
test("MCP requests that already support streamable HTTP are preserved", () => {
const request = new Request("https://staging.example/mcp", {
method: "POST",
headers: {
accept: "application/json, text/event-stream",
"content-type": "application/json",
},
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
});
assert.equal(ensureMcpAcceptHeader(request), request);
});

34
deps/db-sync/worker/logseq_icon.mjs vendored Normal file

File diff suppressed because one or more lines are too long

12
deps/db-sync/worker/mcp_request.mjs vendored Normal file
View File

@@ -0,0 +1,12 @@
export function ensureMcpAcceptHeader(request) {
const accept = request.headers.get("accept") ?? "";
const normalizedAccept = accept.toLowerCase();
if (normalizedAccept.includes("application/json") &&
normalizedAccept.includes("text/event-stream")) {
return request;
}
const headers = new Headers(request.headers);
headers.set("accept", "application/json, text/event-stream");
return new Request(request, { headers });
}

View File

@@ -0,0 +1,42 @@
create table if not exists graphs (
graph_id text primary key,
graph_name text,
user_id text,
schema_version text,
created_at integer,
updated_at integer
);
create table if not exists users (
id text primary key,
email text,
email_verified integer,
username text
);
create table if not exists user_rsa_keys (
user_id text primary key,
public_key text,
encrypted_private_key text,
created_at integer,
updated_at integer
);
create table if not exists graph_members (
user_id text,
graph_id text,
role text,
invited_by text,
created_at integer,
primary key (user_id, graph_id),
check (role in ('manager', 'member'))
);
create table if not exists graph_aes_keys (
graph_id text,
user_id text,
encrypted_aes_key text,
created_at integer,
updated_at integer,
primary key (graph_id, user_id)
);

View File

@@ -0,0 +1,2 @@
alter table graphs
add column graph_e2ee integer default 1;

View File

@@ -0,0 +1,8 @@
create index if not exists idx_graph_members_graph_id_created_at
on graph_members (graph_id, created_at);
create index if not exists idx_graphs_user_id_updated_at
on graphs (user_id, updated_at desc);
create index if not exists idx_users_email
on users (email);

View File

@@ -0,0 +1,2 @@
alter table graphs
add column graph_ready_for_use integer default 1;

View File

@@ -0,0 +1,2 @@
alter table users
add column created_at integer;

View File

@@ -0,0 +1,11 @@
create table if not exists daily_active_entities (
day_utc text,
entity_type text,
entity_id text,
first_seen_at integer,
primary key (day_utc, entity_type, entity_id),
check (entity_type in ('user', 'graph'))
);
create index if not exists idx_daily_active_entities_type_day
on daily_active_entities (entity_type, day_utc);

View File

@@ -0,0 +1,16 @@
const OPENAI_APPS_CHALLENGE_PATH = "/.well-known/openai-apps-challenge";
export function openAiAppsChallengeResponse(request, env) {
const url = new URL(request.url);
if (request.method !== "GET" || url.pathname !== OPENAI_APPS_CHALLENGE_PATH) return null;
const token = env.OPENAI_APPS_CHALLENGE;
if (!token) throw new Error("OPENAI_APPS_CHALLENGE is required");
return new Response(token, {
headers: {
"cache-control": "no-store",
"content-type": "text/plain; charset=utf-8",
},
});
}

View File

@@ -0,0 +1,180 @@
#!/usr/bin/env node
const path = require("node:path");
const readline = require("node:readline/promises");
const { stdin, stdout } = require("node:process");
const { parseArgs } = require("node:util");
const {
buildAdminGraphDeleteUrl,
buildUserGraphsSql,
buildWranglerArgs,
defaultConfigPath,
fail,
formatUserGraphsResult,
parseWranglerResults,
printUserGraphsTable,
runWranglerQuery,
} = require("./graph_user_lib");
function escapeSqlValue(value) {
return value.replaceAll("'", "''");
}
function ensureMutationSuccess(output, context) {
if (!Array.isArray(output) || output.length === 0) {
fail(`Unexpected empty response from wrangler while ${context}.`);
}
output.forEach((statement, index) => {
if (!statement.success) {
fail(`Wrangler mutation failed while ${context} (statement ${index + 1}).`);
}
});
}
function deleteGraphAesKeys(options, graphId) {
const sql = `delete from graph_aes_keys where graph_id = '${escapeSqlValue(graphId)}'`;
const wranglerArgs = buildWranglerArgs({
database: options.database,
config: options.config,
env: options.env,
sql,
});
ensureMutationSuccess(runWranglerQuery(wranglerArgs), `deleting graph_aes_keys for ${graphId}`);
}
function printHelp() {
console.log(`Delete db-sync graphs owned by a user from a remote D1 environment.
Usage:
node worker/scripts/delete_graphs_for_user.js --username <username> [--env prod]
node worker/scripts/delete_graphs_for_user.js --user-id <user-id> [--env prod]
Options:
--username <username> Look up the target user by username.
--user-id <user-id> Look up the target user by user id.
--env <env> Wrangler environment to use. Defaults to "prod".
--database <name> D1 binding or database name. Defaults to "DB".
--config <path> Wrangler config path. Defaults to worker/wrangler.toml.
--base-url <url> Worker base URL. Defaults to DB_SYNC_BASE_URL.
--admin-token <token> Admin delete token. Defaults to DB_SYNC_ADMIN_TOKEN.
--help Show this message.
`);
}
function parseCliArgs(argv) {
const { values } = parseArgs({
args: argv,
options: {
username: { type: "string" },
"user-id": { type: "string" },
env: { type: "string", default: "prod" },
database: { type: "string", default: "DB" },
config: { type: "string", default: defaultConfigPath },
"base-url": { type: "string", default: process.env.DB_SYNC_BASE_URL },
"admin-token": { type: "string", default: process.env.DB_SYNC_ADMIN_TOKEN },
help: { type: "boolean", default: false },
},
strict: true,
allowPositionals: false,
});
if (values.help) {
printHelp();
process.exit(0);
}
const lookupCount = Number(Boolean(values.username)) + Number(Boolean(values["user-id"]));
if (lookupCount !== 1) {
fail("Pass exactly one of --username or --user-id.");
}
return {
lookupField: values.username ? "username" : "id",
lookupLabel: values.username ? "username" : "user-id",
lookupValue: values.username ?? values["user-id"],
env: values.env,
database: values.database,
config: path.resolve(values.config),
baseUrl: values["base-url"],
adminToken: values["admin-token"],
};
}
async function confirmDeletion(result) {
const rl = readline.createInterface({ input: stdin, output: stdout });
try {
const answer = await rl.question(
`Type DELETE to remove ${result.graphs.length} owned graph(s) for ${result.user.user_id}: `,
);
return answer.trim() === "DELETE";
} finally {
rl.close();
}
}
async function main() {
const options = parseCliArgs(process.argv.slice(2));
const lookupSql = buildUserGraphsSql({ ...options, ownedOnly: true });
const lookupArgs = buildWranglerArgs({
database: options.database,
config: options.config,
env: options.env,
sql: lookupSql,
});
const lookupRows = parseWranglerResults(runWranglerQuery(lookupArgs));
const result = formatUserGraphsResult(lookupRows);
if (!result) {
fail(`No user found for ${options.lookupLabel}=${options.lookupValue}.`);
}
printUserGraphsTable(result, "Owned graphs to delete");
if (result.graphs.length === 0) {
console.log("No owned graphs found. Nothing to delete.");
return;
}
if (!options.baseUrl) {
fail("Missing worker base URL. Pass --base-url or set DB_SYNC_BASE_URL.");
}
if (!options.adminToken) {
fail("Missing admin token. Pass --admin-token or set DB_SYNC_ADMIN_TOKEN.");
}
const confirmed = await confirmDeletion(result);
if (!confirmed) {
console.log("Aborted.");
return;
}
for (const graph of result.graphs) {
const response = await fetch(buildAdminGraphDeleteUrl(options.baseUrl, graph.graph_id), {
method: "DELETE",
headers: {
"x-db-sync-admin-token": options.adminToken,
},
});
if (!response.ok) {
const body = await response.text();
fail(`Delete failed for ${graph.graph_id}: ${response.status} ${body}`);
}
deleteGraphAesKeys(options, graph.graph_id);
}
console.log(`Deleted ${result.graphs.length} owned graph(s).`);
}
if (require.main === module) {
main().catch((error) => {
fail(error instanceof Error ? error.message : String(error));
});
}
module.exports = {
confirmDeletion,
parseCliArgs,
};

View File

@@ -0,0 +1,254 @@
#!/usr/bin/env node
const path = require("node:path");
const readline = require("node:readline/promises");
const { stdin, stdout } = require("node:process");
const { parseArgs } = require("node:util");
const {
buildAdminGraphDeleteUrl,
buildUserGraphsSql,
buildWranglerArgs,
defaultConfigPath,
fail,
formatUserGraphsResult,
parseWranglerResults,
printUserGraphsTable,
runWranglerQuery,
} = require("./graph_user_lib");
function printHelp() {
console.log(`Delete a db-sync user and all related data from a remote D1 environment.
Usage:
node worker/scripts/delete_user_totally.js --username <username> [--env prod]
node worker/scripts/delete_user_totally.js --user-id <user-id> [--env prod]
Options:
--username <username> Look up the target user by username.
--user-id <user-id> Look up the target user by user id.
--env <env> Wrangler environment to use. Defaults to "prod".
--database <name> D1 binding or database name. Defaults to "DB".
--config <path> Wrangler config path. Defaults to worker/wrangler.toml.
--base-url <url> Worker base URL. Defaults to DB_SYNC_BASE_URL.
--admin-token <token> Admin delete token. Defaults to DB_SYNC_ADMIN_TOKEN.
--help Show this message.
`);
}
function parseCliArgs(argv) {
const { values } = parseArgs({
args: argv,
options: {
username: { type: "string" },
"user-id": { type: "string" },
env: { type: "string", default: "prod" },
database: { type: "string", default: "DB" },
config: { type: "string", default: defaultConfigPath },
"base-url": { type: "string", default: process.env.DB_SYNC_BASE_URL },
"admin-token": { type: "string", default: process.env.DB_SYNC_ADMIN_TOKEN },
help: { type: "boolean", default: false },
},
strict: true,
allowPositionals: false,
});
if (values.help) {
printHelp();
process.exit(0);
}
const lookupCount = Number(Boolean(values.username)) + Number(Boolean(values["user-id"]));
if (lookupCount !== 1) {
fail("Pass exactly one of --username or --user-id.");
}
return {
lookupField: values.username ? "username" : "id",
lookupLabel: values.username ? "username" : "user-id",
lookupValue: values.username ?? values["user-id"],
env: values.env,
database: values.database,
config: path.resolve(values.config),
baseUrl: values["base-url"],
adminToken: values["admin-token"],
};
}
function escapeSqlValue(value) {
return value.replaceAll("'", "''");
}
function runSelectQuery(options, sql) {
const wranglerArgs = buildWranglerArgs({
database: options.database,
config: options.config,
env: options.env,
sql,
});
return parseWranglerResults(runWranglerQuery(wranglerArgs));
}
function runMutationQuery(options, sql) {
const wranglerArgs = buildWranglerArgs({
database: options.database,
config: options.config,
env: options.env,
sql,
});
const output = runWranglerQuery(wranglerArgs);
if (!Array.isArray(output) || output.length === 0) {
throw new Error("Unexpected empty response from wrangler.");
}
output.forEach((statement, index) => {
if (!statement.success) {
throw new Error(`Wrangler reported an unsuccessful mutation (statement ${index + 1}).`);
}
});
return output.reduce((sum, statement) => sum + Number(statement?.meta?.changes ?? 0), 0);
}
function sqlCountToNumber(value) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : 0;
}
function isDeleteConfirmationAccepted(answer, userId) {
const normalizedAnswer = answer.trim();
return normalizedAnswer === "DELETE" || normalizedAnswer === `DELETE USER ${userId}`;
}
async function confirmDeletion({ user, ownedGraphsCount, memberGraphsCount }) {
const rl = readline.createInterface({ input: stdin, output: stdout });
try {
const answer = await rl.question(
`Type DELETE to permanently delete this user (${user.user_id}; ${ownedGraphsCount} owned graph(s), ${memberGraphsCount} membership(s)): `,
);
return isDeleteConfirmationAccepted(answer, user.user_id);
} finally {
rl.close();
}
}
async function deleteOwnedGraphs(options, ownedGraphs) {
for (const graph of ownedGraphs) {
const response = await fetch(buildAdminGraphDeleteUrl(options.baseUrl, graph.graph_id), {
method: "DELETE",
headers: {
"x-db-sync-admin-token": options.adminToken,
},
});
if (!response.ok) {
const payload = await response.text();
fail(`Delete failed for owned graph ${graph.graph_id}: ${response.status} ${payload}`);
}
}
}
async function main() {
const options = parseCliArgs(process.argv.slice(2));
const graphRows = runSelectQuery(options, buildUserGraphsSql({ ...options, ownedOnly: false }));
const result = formatUserGraphsResult(graphRows);
if (!result) {
fail(`No user found for ${options.lookupLabel}=${options.lookupValue}.`);
}
const ownedGraphs = result.graphs.filter((graph) => graph.access_role === "owner");
const memberGraphs = result.graphs.filter((graph) => graph.access_role !== "owner");
printUserGraphsTable(result, "Graphs linked to user");
console.log(`Owned graphs: ${ownedGraphs.length}`);
console.log(`Member graphs: ${memberGraphs.length}`);
if (ownedGraphs.length > 0 && !options.baseUrl) {
fail("Missing worker base URL. Pass --base-url or set DB_SYNC_BASE_URL.");
}
if (ownedGraphs.length > 0 && !options.adminToken) {
fail("Missing admin token. Pass --admin-token or set DB_SYNC_ADMIN_TOKEN.");
}
const confirmed = await confirmDeletion({
user: result.user,
ownedGraphsCount: ownedGraphs.length,
memberGraphsCount: memberGraphs.length,
});
if (!confirmed) {
console.log("Aborted.");
return;
}
if (ownedGraphs.length > 0) {
await deleteOwnedGraphs(options, ownedGraphs);
}
const escapedUserId = escapeSqlValue(result.user.user_id);
const remainingOwnedGraphRows = runSelectQuery(
options,
`select count(1) as owned_graph_count from graphs where user_id = '${escapedUserId}'`,
);
const remainingOwnedGraphCount = sqlCountToNumber(remainingOwnedGraphRows[0]?.owned_graph_count);
if (remainingOwnedGraphCount > 0) {
fail(
`Owned graph cleanup incomplete: ${remainingOwnedGraphCount} graph(s) still owned by ${result.user.user_id}.`,
);
}
const deletedGraphAesKeys = runMutationQuery(
options,
`delete from graph_aes_keys where user_id = '${escapedUserId}'`,
);
const deletedGraphMembers = runMutationQuery(
options,
`delete from graph_members where user_id = '${escapedUserId}'`,
);
const clearedInvitedBy = runMutationQuery(
options,
`update graph_members set invited_by = null where invited_by = '${escapedUserId}'`,
);
const deletedUserRsaKeys = runMutationQuery(
options,
`delete from user_rsa_keys where user_id = '${escapedUserId}'`,
);
const deletedUsers = runMutationQuery(options, `delete from users where id = '${escapedUserId}'`);
if (deletedUsers !== 1) {
fail(`Expected to delete exactly one user row, but deleted ${deletedUsers}.`);
}
const userRowsAfterDelete = runSelectQuery(
options,
`select id from users where id = '${escapedUserId}' limit 1`,
);
if (userRowsAfterDelete.length > 0) {
fail(`User ${result.user.user_id} still exists after deletion.`);
}
console.table([
{ step: "owned graphs deleted", rows: ownedGraphs.length },
{ step: "graph_aes_keys deleted", rows: deletedGraphAesKeys },
{ step: "graph_members deleted", rows: deletedGraphMembers },
{ step: "graph_members invited_by cleared", rows: clearedInvitedBy },
{ step: "user_rsa_keys deleted", rows: deletedUserRsaKeys },
{ step: "users deleted", rows: deletedUsers },
]);
console.log(`Deleted user ${result.user.user_id} successfully.`);
}
if (require.main === module) {
main().catch((error) => {
fail(error instanceof Error ? error.message : String(error));
});
}
module.exports = {
confirmDeletion,
isDeleteConfirmationAccepted,
parseCliArgs,
};

View File

@@ -0,0 +1,59 @@
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const path = require("node:path");
const test = require("node:test");
const { isDeleteConfirmationAccepted, parseCliArgs } = require("./delete_user_totally");
const { defaultConfigPath } = require("./graph_user_lib");
function runCli(args) {
return spawnSync(process.execPath, [path.join(__dirname, "delete_user_totally.js"), ...args], {
encoding: "utf8",
});
}
test("parseCliArgs accepts --username", () => {
const parsed = parseCliArgs(["--username", "alice"]);
assert.equal(parsed.lookupField, "username");
assert.equal(parsed.lookupLabel, "username");
assert.equal(parsed.lookupValue, "alice");
assert.equal(parsed.env, "prod");
assert.equal(parsed.database, "DB");
assert.equal(parsed.config, path.resolve(defaultConfigPath));
});
test("parseCliArgs accepts --user-id", () => {
const parsed = parseCliArgs(["--user-id", "user-123"]);
assert.equal(parsed.lookupField, "id");
assert.equal(parsed.lookupLabel, "user-id");
assert.equal(parsed.lookupValue, "user-123");
});
test("CLI --help exits successfully", () => {
const result = runCli(["--help"]);
assert.equal(result.status, 0);
assert.match(result.stdout, /Delete a db-sync user and all related data/);
});
test("CLI rejects passing both --username and --user-id", () => {
const result = runCli(["--username", "alice", "--user-id", "user-123"]);
assert.equal(result.status, 1);
assert.match(result.stderr, /Pass exactly one of --username or --user-id\./);
});
test("confirmation accepts DELETE", () => {
assert.equal(isDeleteConfirmationAccepted("DELETE", "user-123"), true);
});
test("confirmation accepts legacy DELETE USER <id>", () => {
assert.equal(isDeleteConfirmationAccepted("DELETE USER user-123", "user-123"), true);
});
test("confirmation rejects unrelated input", () => {
assert.equal(isDeleteConfirmationAccepted("DELETE USER other-user", "user-123"), false);
assert.equal(isDeleteConfirmationAccepted("yes", "user-123"), false);
});

View File

@@ -0,0 +1,246 @@
#!/usr/bin/env node
const Database = require("better-sqlite3");
const fs = require("node:fs");
const path = require("node:path");
const zlib = require("node:zlib");
const { parseArgs } = require("node:util");
const transit = require("transit-js");
const { fail } = require("./graph_user_lib");
const defaultBaseUrl = "https://api.logseq.com";
function printHelp() {
console.log(`Download a graph snapshot and store it as a local sqlite debug DB.
Usage:
node worker/scripts/download_graph_db.js --graph-id <graph-id> --admin-token <token>
node worker/scripts/download_graph_db.js --graph-id <graph-id> --output ./tmp/my-graph.sqlite
Options:
--graph-id <id> Target graph id. Required.
--admin-token <token> Admin token. Defaults to DB_SYNC_ADMIN_TOKEN.
--base-url <url> Worker base URL. Defaults to DB_SYNC_BASE_URL or https://api.logseq.com.
--output <path> SQLite output path. Defaults to ./tmp/graph-<graph-id>.snapshot.sqlite.
--help Show this message.
Notes:
The output sqlite matches local graph DB schema and contains only:
kvs(addr, content, addresses).
`);
}
function sanitizeGraphIdForFilename(graphId) {
return graphId.replaceAll(/[^a-zA-Z0-9.-]/g, "_");
}
function parseCliArgs(argv) {
const { values } = parseArgs({
args: argv,
options: {
"graph-id": { type: "string" },
"admin-token": { type: "string", default: process.env.DB_SYNC_ADMIN_TOKEN },
"base-url": { type: "string", default: process.env.DB_SYNC_BASE_URL || defaultBaseUrl },
output: { type: "string" },
help: { type: "boolean", default: false },
},
strict: true,
allowPositionals: false,
});
if (values.help) {
printHelp();
process.exit(0);
}
if (!values["graph-id"]) {
fail("Missing required --graph-id.");
}
if (!values["admin-token"]) {
fail("Missing admin token. Pass --admin-token or set DB_SYNC_ADMIN_TOKEN.");
}
const output = values.output
? path.resolve(values.output)
: path.resolve("tmp", `graph-${sanitizeGraphIdForFilename(values["graph-id"])}.snapshot.sqlite`);
return {
graphId: values["graph-id"],
adminToken: values["admin-token"],
baseUrl: values["base-url"],
output,
};
}
function authHeaders(adminToken) {
return {
"x-db-sync-admin-token": adminToken,
};
}
function normalizeBaseUrl(baseUrl) {
return baseUrl.replace(/\/+$/, "");
}
async function fetchJson(url, adminToken) {
const response = await fetch(url, {
method: "GET",
headers: authHeaders(adminToken),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Request failed (${response.status}) for ${url}: ${body}`);
}
return response.json();
}
async function fetchSnapshotDescriptor(options) {
const baseUrl = normalizeBaseUrl(options.baseUrl);
const url = `${baseUrl}/sync/${encodeURIComponent(options.graphId)}/snapshot/download`;
return fetchJson(url, options.adminToken);
}
async function fetchSnapshotBytes(url, adminToken) {
const response = await fetch(url, {
method: "GET",
headers: authHeaders(adminToken),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Snapshot download failed (${response.status}) for ${url}: ${body}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const contentEncoding = response.headers.get("content-encoding");
return {
buffer,
contentEncoding,
};
}
function hasGzipMagic(buffer) {
return buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b;
}
function maybeDecompressBuffer(buffer, contentEncoding) {
if (contentEncoding === "gzip" && hasGzipMagic(buffer)) {
return zlib.gunzipSync(buffer);
}
return buffer;
}
function parseFramedRows(buffer) {
const rows = [];
const reader = transit.reader("json");
let offset = 0;
while (offset < buffer.length) {
if (buffer.length - offset < 4) {
throw new Error("Invalid snapshot payload: incomplete frame header");
}
const frameLength = buffer.readUInt32BE(offset);
offset += 4;
if (buffer.length - offset < frameLength) {
throw new Error("Invalid snapshot payload: incomplete frame payload");
}
const payload = buffer.subarray(offset, offset + frameLength);
offset += frameLength;
const batch = reader.read(payload.toString("utf8"));
if (!Array.isArray(batch)) {
throw new Error("Invalid snapshot payload: decoded frame is not an array");
}
for (const row of batch) {
if (!Array.isArray(row) || row.length < 2) {
throw new Error("Invalid snapshot payload: row must be [addr, content, addresses?]");
}
rows.push(row);
}
}
return rows;
}
function writeSnapshotSqlite({
outputPath,
rows,
}) {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
if (fs.existsSync(outputPath)) {
fs.rmSync(outputPath);
}
const db = new Database(outputPath);
try {
db.exec(`
create table if not exists kvs (
addr INTEGER primary key,
content TEXT,
addresses JSON
);
`);
const upsertKvs = db.prepare(
"insert into kvs (addr, content, addresses) values (?, ?, ?) on conflict(addr) do update set content = excluded.content, addresses = excluded.addresses",
);
const writeAll = db.transaction(() => {
for (const row of rows) {
const [addr, content, addresses] = row;
upsertKvs.run(addr, content, addresses ?? null);
}
});
writeAll();
} finally {
db.close();
}
}
async function main() {
const options = parseCliArgs(process.argv.slice(2));
const descriptor = await fetchSnapshotDescriptor(options);
if (!descriptor || !descriptor.url) {
fail("Snapshot download response missing URL.");
}
const snapshot = await fetchSnapshotBytes(descriptor.url, options.adminToken);
const effectiveEncoding = descriptor["content-encoding"] || snapshot.contentEncoding || "";
const decompressed = maybeDecompressBuffer(snapshot.buffer, effectiveEncoding);
const rows = parseFramedRows(decompressed);
writeSnapshotSqlite({
outputPath: options.output,
rows,
});
console.log(`Saved graph snapshot sqlite to ${options.output}`);
console.log(`Graph: ${options.graphId}`);
console.log(`Rows: ${rows.length}`);
if (descriptor.key) {
console.log(`Snapshot key: ${descriptor.key}`);
}
}
if (require.main === module) {
main().catch((error) => {
fail(error instanceof Error ? error.message : String(error));
});
}
module.exports = {
parseCliArgs,
parseFramedRows,
sanitizeGraphIdForFilename,
writeSnapshotSqlite,
};

View File

@@ -0,0 +1,117 @@
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const transit = require("transit-js");
const Database = require("better-sqlite3");
const {
parseFramedRows,
parseCliArgs,
sanitizeGraphIdForFilename,
writeSnapshotSqlite,
} = require("./download_graph_db");
function runCli(args, env = {}) {
return spawnSync(process.execPath, [path.join(__dirname, "download_graph_db.js"), ...args], {
encoding: "utf8",
env: { ...process.env, ...env },
});
}
test("parseCliArgs accepts required args and defaults output", () => {
const parsed = parseCliArgs([
"--graph-id",
"graph-1",
"--admin-token",
"admin-token-value",
]);
assert.equal(parsed.graphId, "graph-1");
assert.equal(parsed.baseUrl, "https://api.logseq.com");
assert.equal(parsed.adminToken, "admin-token-value");
assert.equal(parsed.output, path.resolve("tmp", "graph-graph-1.snapshot.sqlite"));
});
test("sanitizeGraphIdForFilename replaces unsafe chars", () => {
assert.equal(sanitizeGraphIdForFilename("us-east-1:abc/def"), "us-east-1_abc_def");
});
test("CLI --help exits successfully", () => {
const result = runCli(["--help"]);
assert.equal(result.status, 0);
assert.match(result.stdout, /Download a graph snapshot and store it as a local sqlite debug DB/);
});
test("CLI rejects missing --graph-id", () => {
const result = runCli(["--admin-token", "admin-token-value"]);
assert.equal(result.status, 1);
assert.match(result.stderr, /Missing required --graph-id\./);
});
test("CLI rejects missing admin-token when env is absent", () => {
const result = runCli(["--graph-id", "graph-1"], { DB_SYNC_ADMIN_TOKEN: "" });
assert.equal(result.status, 1);
assert.match(result.stderr, /Missing admin token/);
});
test("parseFramedRows decodes framed transit batches", () => {
const writer = transit.writer("json");
const rowsA = [
[1, "line-1", null],
[2, "line-2", null],
];
const rowsB = [
[1000606, "line-1000606", "{\"1\":[2,3]}"],
];
const encodeFrame = (rows) => {
const payload = Buffer.from(writer.write(rows), "utf8");
const frame = Buffer.allocUnsafe(4 + payload.length);
frame.writeUInt32BE(payload.length, 0);
payload.copy(frame, 4);
return frame;
};
const framed = Buffer.concat([encodeFrame(rowsA), encodeFrame(rowsB)]);
const parsed = parseFramedRows(framed);
assert.deepEqual(parsed, [...rowsA, ...rowsB]);
});
test("writeSnapshotSqlite writes kvs-only sqlite like local dbs", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "db-sync-download-test-"));
const dbPath = path.join(tmpDir, "graph-debug.sqlite");
const rows = [
[1, "line-1", null],
[2, "line-2", null],
[1000606, "line-1000606", "{\"1\":[2,3]}"],
];
writeSnapshotSqlite({
outputPath: dbPath,
rows,
});
const db = new Database(dbPath, { readonly: true });
const tableNames = db
.prepare("select name from sqlite_master where type = 'table' order by name")
.all()
.map((row) => row.name);
const rowCount = db.prepare("select count(1) as count from kvs").get().count;
const row1000606 = db.prepare("select addr, content, addresses from kvs where addr = 1000606").get();
assert.deepEqual(tableNames, ["kvs"]);
assert.equal(rowCount, 3);
assert.equal(row1000606.addr, 1000606);
assert.equal(row1000606.content, "line-1000606");
assert.equal(row1000606.addresses, "{\"1\":[2,3]}");
db.close();
});

View File

@@ -0,0 +1,7 @@
import { readFileSync, writeFileSync } from "node:fs";
const html = readFileSync(new URL("../dist/api-docs.html", import.meta.url), "utf8");
writeFileSync(
new URL("../dist/api-docs.generated.mjs", import.meta.url),
`export default ${JSON.stringify(html)};\n`,
);

View File

@@ -0,0 +1,196 @@
const { execFileSync } = require("node:child_process");
const path = require("node:path");
const repoRoot = path.resolve(__dirname, "..", "..");
const defaultConfigPath = path.join(repoRoot, "worker", "wrangler.toml");
function fail(message) {
console.error(message);
process.exit(1);
}
function escapeSqlValue(value) {
return value.replaceAll("'", "''");
}
function sqlBooleanToBool(value) {
if (value === null || value === undefined) return null;
return Number(value) === 1;
}
function sqlTimestampToIso(value) {
if (value === null || value === undefined || value === "") return null;
const timestamp = Number(value);
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
}
function buildUserGraphsSql({ lookupField, lookupValue, ownedOnly = false }) {
const escapedValue = escapeSqlValue(lookupValue);
const memberUnion = ownedOnly
? ""
: `
union all
select g.graph_id,
g.graph_name,
g.user_id as owner_user_id,
m.role as access_role,
m.invited_by,
g.schema_version,
g.graph_e2ee,
g.graph_ready_for_use,
g.created_at,
g.updated_at
from graph_members m
join graphs g on g.graph_id = m.graph_id
join target_user u on m.user_id = u.id
where g.user_id <> u.id`;
return `with target_user as (
select id, email, username
from users
where ${lookupField} = '${escapedValue}'
limit 1
),
matching_graphs as (
select g.graph_id,
g.graph_name,
g.user_id as owner_user_id,
'owner' as access_role,
null as invited_by,
g.schema_version,
g.graph_e2ee,
g.graph_ready_for_use,
g.created_at,
g.updated_at
from graphs g
join target_user u on g.user_id = u.id${memberUnion}
)
select u.id as user_id,
u.email as user_email,
u.username as user_username,
g.graph_id,
g.graph_name,
g.access_role,
g.invited_by,
g.owner_user_id,
owner.email as owner_email,
owner.username as owner_username,
g.schema_version,
g.graph_e2ee,
g.graph_ready_for_use,
g.created_at,
g.updated_at
from target_user u
left join matching_graphs g on 1 = 1
left join users owner on owner.id = g.owner_user_id
order by g.updated_at desc;`;
}
function buildWranglerArgs({ database, config, env, sql, local = false }) {
const useLocal = local || env === "local";
const args = [
"dlx",
"wrangler",
"d1",
"execute",
database,
"--config",
config,
];
if (useLocal) {
args.push("--local");
} else {
args.push("--env", env, "--remote");
}
args.push("--json", "--command", sql);
return args;
}
function runWranglerQuery(args) {
const output = execFileSync("pnpm", args, {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "inherit"],
});
return JSON.parse(output);
}
function parseWranglerResults(output) {
if (!Array.isArray(output) || output.length === 0) {
throw new Error("Unexpected empty response from wrangler.");
}
const [statement] = output;
if (!statement.success) {
throw new Error("Wrangler reported an unsuccessful D1 query.");
}
return Array.isArray(statement.results) ? statement.results : [];
}
function formatUserGraphsResult(rows) {
if (rows.length === 0) {
return null;
}
const [firstRow] = rows;
const graphs = rows
.filter((row) => row.graph_id)
.map((row) => ({
graph_id: row.graph_id,
graph_name: row.graph_name,
access_role: row.access_role,
invited_by: row.invited_by ?? null,
owner_user_id: row.owner_user_id,
owner_username: row.owner_username ?? null,
owner_email: row.owner_email ?? null,
schema_version: row.schema_version ?? null,
graph_e2ee: sqlBooleanToBool(row.graph_e2ee),
graph_ready_for_use: sqlBooleanToBool(row.graph_ready_for_use),
created_at: sqlTimestampToIso(row.created_at),
updated_at: sqlTimestampToIso(row.updated_at),
}));
return {
user: {
user_id: firstRow.user_id,
username: firstRow.user_username ?? null,
email: firstRow.user_email ?? null,
},
graphs,
};
}
function printUserGraphsTable(result, countLabel = "Graphs") {
console.log(
`User: ${result.user.user_id}` +
(result.user.username ? ` (${result.user.username})` : "") +
(result.user.email ? ` <${result.user.email}>` : ""),
);
console.log(`${countLabel}: ${result.graphs.length}`);
if (result.graphs.length > 0) {
console.table(result.graphs);
}
}
function buildAdminGraphDeleteUrl(baseUrl, graphId) {
const normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
return `${normalizedBaseUrl}/admin/graphs/${encodeURIComponent(graphId)}`;
}
module.exports = {
buildAdminGraphDeleteUrl,
buildUserGraphsSql,
buildWranglerArgs,
defaultConfigPath,
fail,
formatUserGraphsResult,
parseWranglerResults,
printUserGraphsTable,
repoRoot,
runWranglerQuery,
};

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env node
const path = require("node:path");
const { parseArgs } = require("node:util");
const {
buildUserGraphsSql,
buildWranglerArgs,
defaultConfigPath,
fail,
formatUserGraphsResult,
parseWranglerResults,
printUserGraphsTable,
runWranglerQuery,
} = require("./graph_user_lib");
function printHelp() {
console.log(`Show db-sync graphs for a user from a remote D1 environment.
Usage:
node worker/scripts/show_graphs_for_user.js --username <username> [--env prod] [--json]
node worker/scripts/show_graphs_for_user.js --user-id <user-id> [--env prod] [--json]
Options:
--username <username> Look up the target user by username.
--user-id <user-id> Look up the target user by user id.
--env <env> Wrangler environment to use. Defaults to "prod".
--database <name> D1 binding or database name. Defaults to "DB".
--config <path> Wrangler config path. Defaults to worker/wrangler.toml.
--json Print JSON instead of a table.
--help Show this message.
`);
}
function parseCliArgs(argv) {
const { values } = parseArgs({
args: argv,
options: {
username: { type: "string" },
"user-id": { type: "string" },
env: { type: "string", default: "prod" },
database: { type: "string", default: "DB" },
config: { type: "string", default: defaultConfigPath },
json: { type: "boolean", default: false },
help: { type: "boolean", default: false },
},
strict: true,
allowPositionals: false,
});
if (values.help) {
printHelp();
process.exit(0);
}
const lookupCount = Number(Boolean(values.username)) + Number(Boolean(values["user-id"]));
if (lookupCount !== 1) {
fail("Pass exactly one of --username or --user-id.");
}
return {
lookupField: values.username ? "username" : "id",
lookupLabel: values.username ? "username" : "user-id",
lookupValue: values.username ?? values["user-id"],
env: values.env,
database: values.database,
config: path.resolve(values.config),
json: values.json,
};
}
function main() {
const options = parseCliArgs(process.argv.slice(2));
const sql = buildUserGraphsSql(options);
const wranglerArgs = buildWranglerArgs({
database: options.database,
config: options.config,
env: options.env,
sql,
});
const rows = parseWranglerResults(runWranglerQuery(wranglerArgs));
const result = formatUserGraphsResult(rows);
if (!result) {
fail(`No user found for ${options.lookupLabel}=${options.lookupValue}.`);
}
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else {
printUserGraphsTable(result);
}
}
if (require.main === module) {
try {
main();
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
}
module.exports = {
parseCliArgs,
};

View File

@@ -0,0 +1,188 @@
#!/usr/bin/env node
const path = require("node:path");
const { parseArgs } = require("node:util");
const {
buildWranglerArgs,
defaultConfigPath,
fail,
parseWranglerResults,
runWranglerQuery,
} = require("./graph_user_lib");
function printHelp() {
console.log(`Show db-sync usage stats from a D1 environment.
Usage:
node worker/scripts/show_usage_stats.js [--env prod|local] [--days 7] [--json]
Options:
--env <env> Wrangler environment to use. Defaults to "prod"; use "local" to query the local D1 database.
--days <n> Active window size in days. Defaults to 1.
--database <name> D1 binding or database name. Defaults to "DB".
--config <path> Wrangler config path. Defaults to worker/wrangler.toml.
--json Print JSON instead of a table.
--help Show this message.
Output:
days
active_since_utc
active_users_last_n_days
active_graphs_last_n_days
total_users
total_graphs
users_created_today
graphs_created_today
today_utc
`);
}
function parseDays(value) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1) {
fail("--days must be an integer >= 1.");
}
return parsed;
}
function parseCliArgs(argv) {
const { values } = parseArgs({
args: argv,
options: {
env: { type: "string", default: "prod" },
days: { type: "string", default: "1" },
database: { type: "string", default: "DB" },
config: { type: "string", default: defaultConfigPath },
json: { type: "boolean", default: false },
help: { type: "boolean", default: false },
},
strict: true,
allowPositionals: false,
});
if (values.help) {
printHelp();
process.exit(0);
}
return {
env: values.env,
days: parseDays(values.days),
database: values.database,
config: path.resolve(values.config),
json: values.json,
};
}
function buildUsageStatsSql(days) {
const sinceDays = days - 1;
return `with bounds as (
select
cast(strftime('%s', 'now', 'start of day') as integer) * 1000 as today_start_ms,
cast(strftime('%s', 'now', 'start of day', '+1 day') as integer) * 1000 as tomorrow_start_ms,
date('now') as today_utc,
date('now', '-${sinceDays} days') as active_since_utc
)
select
${days} as days,
(select active_since_utc from bounds) as active_since_utc,
(select count(distinct entity_id)
from daily_active_entities a
join bounds b on 1 = 1
where a.entity_type = 'user'
and a.day_utc >= b.active_since_utc
and a.day_utc <= b.today_utc) as active_users_last_n_days,
(select count(distinct entity_id)
from daily_active_entities a
join bounds b on 1 = 1
where a.entity_type = 'graph'
and a.day_utc >= b.active_since_utc
and a.day_utc <= b.today_utc) as active_graphs_last_n_days,
(select count(1) from users) as total_users,
(select count(1) from graphs) as total_graphs,
(select count(1)
from users u
join bounds b on 1 = 1
where u.created_at is not null
and u.created_at >= b.today_start_ms
and u.created_at < b.tomorrow_start_ms) as users_created_today,
(select count(1)
from graphs g
join bounds b on 1 = 1
where g.created_at is not null
and g.created_at >= b.today_start_ms
and g.created_at < b.tomorrow_start_ms) as graphs_created_today,
(select today_utc from bounds) as today_utc;`;
}
function sqlCountToNumber(value) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : 0;
}
function formatUsageStats(rows) {
if (!rows.length) {
return null;
}
const [row] = rows;
return {
days: sqlCountToNumber(row.days),
active_since_utc: typeof row.active_since_utc === "string" ? row.active_since_utc : null,
active_users_last_n_days: sqlCountToNumber(row.active_users_last_n_days),
active_graphs_last_n_days: sqlCountToNumber(row.active_graphs_last_n_days),
total_users: sqlCountToNumber(row.total_users),
total_graphs: sqlCountToNumber(row.total_graphs),
users_created_today: sqlCountToNumber(row.users_created_today),
graphs_created_today: sqlCountToNumber(row.graphs_created_today),
today_utc: typeof row.today_utc === "string" ? row.today_utc : null,
};
}
function printUsageStatsTable(stats) {
console.table([stats]);
}
function main() {
const options = parseCliArgs(process.argv.slice(2));
const sql = buildUsageStatsSql(options.days);
const wranglerArgs = buildWranglerArgs({
database: options.database,
config: options.config,
env: options.env,
sql,
});
const rows = parseWranglerResults(runWranglerQuery(wranglerArgs));
const stats = formatUsageStats(rows);
if (!stats) {
fail("No stats returned from D1.");
}
if (options.json) {
console.log(JSON.stringify(stats, null, 2));
} else {
printUsageStatsTable(stats);
}
}
if (require.main === module) {
try {
main();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/no such table: daily_active_entities/i.test(message)) {
fail("Missing daily_active_entities. Apply worker migration 0006_add_daily_active_entities.sql.");
}
if (/no such column: created_at/i.test(message)) {
fail("Missing users.created_at. Apply worker migration 0005_add_user_created_at.sql.");
}
fail(message);
}
}
module.exports = {
buildUsageStatsSql,
formatUsageStats,
parseCliArgs,
};

32
deps/db-sync/worker/scripts/ws_test.js vendored Normal file
View File

@@ -0,0 +1,32 @@
const baseUrl = process.env.BASE_URL || "http://127.0.0.1:8787";
const graphId = process.env.GRAPH_ID || "dev-graph";
const wsUrl = baseUrl.replace(/^http/, "ws") + `/sync/${graphId}`;
const WebSocketCtor = globalThis.WebSocket;
if (!WebSocketCtor) {
console.error("WebSocket not available. Use node>=20 or provide a WebSocket polyfill.");
process.exit(1);
}
const ws = new WebSocketCtor(wsUrl);
let pending = 0;
ws.addEventListener("open", () => {
ws.send(JSON.stringify({ type: "hello", client: "ws-test" }));
ws.send(JSON.stringify({ type: "ping" }));
ws.send(JSON.stringify({ type: "pull", since: 0 }));
pending = 3;
});
ws.addEventListener("message", (event) => {
console.log(String(event.data));
pending -= 1;
if (pending <= 0) {
ws.close();
}
});
ws.addEventListener("error", (event) => {
console.error("ws error", event);
});

View File

@@ -0,0 +1,48 @@
const MAX_ASSET_SIZE = 100 * 1024 * 1024;
function decodedBase64Size(value) {
if (typeof value !== "string") throw new Error("base64 asset body must be a string");
let length = 0;
let padding = 0;
let sawPadding = false;
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
const code = value.charCodeAt(index);
if (code === 9 || code === 10 || code === 12 || code === 13 || code === 32) continue;
if (character === "=") {
sawPadding = true;
padding += 1;
} else {
const base64Character = (code >= 65 && code <= 90) ||
(code >= 97 && code <= 122) ||
(code >= 48 && code <= 57) || code === 43 || code === 47;
if (sawPadding || !base64Character) {
throw new Error("invalid base64 asset body");
}
}
length += 1;
}
if (length % 4 !== 0 || padding > 2) throw new Error("invalid base64 asset body");
return (length / 4) * 3 - padding;
}
export function semanticRequestUrl(options, baseUrl) {
const url = new URL(options.path, baseUrl);
for (const [key, value] of Object.entries(options.query ?? {})) {
if (value !== undefined) url.searchParams.set(key, String(value));
}
if (url.searchParams.get("encoding") === "base64") {
const size = decodedBase64Size(options.body);
if (size > MAX_ASSET_SIZE) throw new Error("decoded asset exceeds the 100MB limit");
url.searchParams.set("size", String(size));
}
return url;
}
export function semanticRequestBody(options, url) {
if (options.body === undefined) return undefined;
return options.rawBody || url.searchParams.get("encoding") === "base64"
? options.body
: JSON.stringify(options.body);
}

163
deps/db-sync/worker/wrangler.toml vendored Normal file
View File

@@ -0,0 +1,163 @@
name = "logseq-sync"
main = "entry.mjs"
compatibility_date = "2026-07-11"
compatibility_flags = [ "nodejs_compat" ]
[[worker_loaders]]
binding = "LOADER"
[limits]
cpu_ms = 300000
[version_metadata]
binding = "CF_VERSION_METADATA"
[observability]
enabled = true
[observability.logs]
invocation_logs = false
[[durable_objects.bindings]]
name = "LOGSEQ_SYNC_DO"
class_name = "SyncDO"
[[r2_buckets]]
binding = "LOGSEQ_SYNC_ASSETS"
bucket_name = "logseq-sync-assets-prod"
[[d1_databases]]
binding = "DB"
database_name = "logseq-sync-graph-meta-prod"
database_id = "00325aa2-c805-4693-b599-900a25dcde42"
[[migrations]]
tag = "v1"
new_sqlite_classes = [ "SyncDO" ]
[vars]
COGNITO_JWKS_URL = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8/.well-known/jwks.json"
COGNITO_ISSUER = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8"
COGNITO_CLIENT_ID = "69cs1lgme7p8kbgld8n5kseii6"
[[ratelimits]]
name = "SEMANTIC_READ_RATE_LIMITER"
namespace_id = "1001"
[ratelimits.simple]
limit = 120
period = 60
[[ratelimits]]
name = "SEMANTIC_WRITE_RATE_LIMITER"
namespace_id = "1002"
[ratelimits.simple]
limit = 20
period = 60
[env.staging]
name = "logseq-sync-staging"
[env.staging.limits]
cpu_ms = 300000
[env.staging.vars]
COGNITO_JWKS_URL = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8/.well-known/jwks.json"
COGNITO_ISSUER = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8"
COGNITO_CLIENT_ID = "69cs1lgme7p8kbgld8n5kseii6"
COGNITO_CLIENT_IDS = "7cm3bi0ng53iitncabjnb4hs52,7ob5ih73g8rgjbcjj8du3ofhe4"
SENTRY_DSN = "https://dc09d27243b9492bbe15e0dd279ad7de@o416451.ingest.us.sentry.io/5311485"
SENTRY_ENVIRONMENT = "staging"
SENTRY_TRACES_SAMPLE_RATE = "0.1"
[[env.staging.durable_objects.bindings]]
name = "LOGSEQ_SYNC_DO"
class_name = "SyncDO"
[[env.staging.migrations]]
tag = "v1"
new_sqlite_classes = [ "SyncDO" ]
[[env.staging.r2_buckets]]
binding = "LOGSEQ_SYNC_ASSETS"
bucket_name = "logseq-sync-assets-dev"
[[env.staging.d1_databases]]
binding = "DB"
database_name = "logseq-sync-graph-meta-staging"
database_id = "00325aa2-c805-4693-b599-900a25dcde42"
[env.staging.version_metadata]
binding = "CF_VERSION_METADATA"
[[env.staging.worker_loaders]]
binding = "LOADER"
[[env.staging.ratelimits]]
name = "SEMANTIC_READ_RATE_LIMITER"
namespace_id = "1101"
[env.staging.ratelimits.simple]
limit = 120
period = 60
[[env.staging.ratelimits]]
name = "SEMANTIC_WRITE_RATE_LIMITER"
namespace_id = "1102"
[env.staging.ratelimits.simple]
limit = 20
period = 60
[env.prod]
name = "logseq-sync-prod"
[env.prod.vars]
COGNITO_JWKS_URL = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8/.well-known/jwks.json"
COGNITO_ISSUER = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8"
COGNITO_CLIENT_ID = "69cs1lgme7p8kbgld8n5kseii6"
COGNITO_CLIENT_IDS = "5nrhvuvjn9aagbpff45rjpfmm9"
OPENAI_APPS_CHALLENGE = "LEs8t_lCac2IDOaMNTZ1_wc_hQ5w9UgdTkww9kGxjW8"
SENTRY_DSN = "https://dc09d27243b9492bbe15e0dd279ad7de@o416451.ingest.us.sentry.io/5311485"
SENTRY_ENVIRONMENT = "prod"
SENTRY_TRACES_SAMPLE_RATE = "0.1"
[[env.prod.durable_objects.bindings]]
name = "LOGSEQ_SYNC_DO"
class_name = "SyncDO"
[[env.prod.migrations]]
tag = "v1"
new_sqlite_classes = [ "SyncDO" ]
[[env.prod.r2_buckets]]
binding = "LOGSEQ_SYNC_ASSETS"
bucket_name = "logseq-sync-assets-prod"
[[env.prod.d1_databases]]
binding = "DB"
database_name = "logseq-sync-graphs-prod"
database_id = "4c80e058-69b5-4985-88d1-f53711d817ba"
[env.prod.version_metadata]
binding = "CF_VERSION_METADATA"
[[env.prod.worker_loaders]]
binding = "LOADER"
[[env.prod.ratelimits]]
name = "SEMANTIC_READ_RATE_LIMITER"
namespace_id = "1201"
[env.prod.ratelimits.simple]
limit = 120
period = 60
[[env.prod.ratelimits]]
name = "SEMANTIC_WRITE_RATE_LIMITER"
namespace_id = "1202"
[env.prod.ratelimits.simple]
limit = 20
period = 60