The seed clears the changelog before deleting notes, so a caught-up browser keeps the old copies. A new workspace sync identity makes that browser replace its mirror with the seeded snapshot.
945 lines
31 KiB
TypeScript
945 lines
31 KiB
TypeScript
import {
|
|
createExcerpt,
|
|
docToText,
|
|
markdownToDoc,
|
|
normalizeTags,
|
|
} from "@edgeever/shared";
|
|
import { Hono } from "hono";
|
|
import { cors } from "hono/cors";
|
|
import { resolveContainerImageSource } from "./container-image-source";
|
|
import apiProbe from "../../../docs/openapi.json";
|
|
import releaseSummary from "../../../release-summary.json";
|
|
import {
|
|
authenticateRequest,
|
|
authenticateSession,
|
|
createSession,
|
|
getBearerToken,
|
|
getInstanceAuthMode,
|
|
getInstanceUser,
|
|
getLoginAttemptKeys,
|
|
revokeSession,
|
|
setSessionCookie,
|
|
tooManyLoginAttempts,
|
|
verifyLogin,
|
|
} from "./auth-service";
|
|
import {
|
|
isDatabaseNotReadyError,
|
|
} from "./auth-state";
|
|
import {
|
|
isDemoModeEnabled,
|
|
resolveDemoPasswordHash,
|
|
shouldUpsertDemoSeedRecord,
|
|
} from "./demo-mode";
|
|
import {
|
|
decodeDemoAttachment,
|
|
DEMO_SEED_ATTACHMENT_RESOURCES,
|
|
DEMO_SEED_MEMO_IDS,
|
|
DEMO_SEED_MEMOS,
|
|
DEMO_SEED_NOTEBOOK_IDS,
|
|
DEMO_SEED_NOTEBOOKS,
|
|
DEMO_SEED_REVISIONS,
|
|
} from "./demo-seed-data";
|
|
import { createCloudflareStorageAdapter } from "./cloudflare-storage-adapter";
|
|
import {
|
|
restoreJsonMemos as restoreJsonMemosService,
|
|
restoreJsonNotebooks as restoreJsonNotebooksService,
|
|
} from "./backup-service";
|
|
import { sha256, sha256Bytes } from "./hash-utils";
|
|
import { INSTANCE_BUILD_ID } from "./instance-build";
|
|
import { resolveInstanceDeploymentMetadata } from "./instance-deployment";
|
|
import type {
|
|
DatabaseAdapter,
|
|
PreparedStatementAdapter,
|
|
} from "./storage-contract";
|
|
import type { AppContext, AppEnv, AuthContext, Bindings, WorkerBindings } from "./api-context";
|
|
import { AppError } from "./app-error";
|
|
import { hashPassword } from "./auth-crypto";
|
|
import {
|
|
apiError,
|
|
authNotConfigured,
|
|
databaseNotReady,
|
|
unauthorized,
|
|
} from "./http-errors";
|
|
import { audit } from "./audit";
|
|
import { clampNumber, createId, isoNow } from "./entity-utils";
|
|
import {
|
|
upsertMemoSearchDocumentStatement,
|
|
} from "./memo-search-index";
|
|
import {
|
|
listMemoRevisions as listMemoRevisionsService,
|
|
restoreMemoRevision as restoreMemoRevisionService,
|
|
} from "./memo-revision-service";
|
|
import { registerTagRoutes } from "./tag-routes";
|
|
import { registerTemplateRoutes } from "./template-routes";
|
|
import { registerAuthRoutes } from "./auth-routes";
|
|
import { registerApiTokenRoutes } from "./api-token-routes";
|
|
import { registerObjectStorageRoutes } from "./object-storage-routes";
|
|
import { registerAiRoutes } from "./ai-routes";
|
|
import { registerPluginCapabilityRoutes } from "./plugin-capability-routes";
|
|
import { registerCompanionRoutes } from "./companion-routes";
|
|
import { registerAiPromptRoutes } from "./ai-prompt-routes";
|
|
import { registerResourceRoutes } from "./resource-routes";
|
|
import {
|
|
abortResourceUpload,
|
|
completeResourceUpload,
|
|
initiateResourceRestoreUpload,
|
|
initiateResourceUpload,
|
|
uploadResourcePart,
|
|
} from "./resource-upload-service";
|
|
import { registerPluginDistributionRoutes } from "./plugin-distribution-routes";
|
|
import { registerSyncRoutes } from "./sync-routes";
|
|
import { registerMemoRoutes } from "./memo-routes";
|
|
import { registerScheduledTaskRoutes } from "./scheduled-task-routes";
|
|
import { registerWorkspaceExtensionRoutes } from "./workspace-extension-routes";
|
|
import { registerBackupRoutes } from "./backup-routes";
|
|
import { registerMcpRoutes } from "./mcp-routes";
|
|
import { executeWorkspaceTool } from "./mcp-tool-executor";
|
|
import {
|
|
createMemoEditSession,
|
|
createMemoRecord,
|
|
deleteMemoRecord,
|
|
deleteMemosRecord,
|
|
emptyTrashMemosRecord,
|
|
getCurrentWorkspaceIdentity,
|
|
getMemoDetail,
|
|
getMemoDetailRow,
|
|
getMemosForBulkAction,
|
|
importMemosRecord,
|
|
listMemosForMcp,
|
|
mapMemoDetail,
|
|
mergeMemosRecord,
|
|
moveMemosRecord,
|
|
moveMemosToNotebook,
|
|
restoreMemoRecord,
|
|
restoreMemosRecord,
|
|
searchMemoSummaries,
|
|
updateMemoRecord,
|
|
} from "./memo-service";
|
|
|
|
export { createMemoEditSession, mergeMemosRecord, updateMemoRecord };
|
|
import { listMemos } from "./memo-list-service";
|
|
import {
|
|
registerUserRoutes,
|
|
} from "./user-routes";
|
|
import { registerNotebookRoutes } from "./notebook-routes";
|
|
import { registerMemoShareRoutes, registerPublicShareRoutes } from "./share-routes";
|
|
import { registerPublicTableFormRoutes, registerTableFormRoutes } from "./table-form-routes";
|
|
import {
|
|
deleteStoredObjects,
|
|
getActiveObjectStorageConfig,
|
|
} from "./object-storage";
|
|
import {
|
|
DEFAULT_WORKSPACE_ID,
|
|
ensureUserWorkspace,
|
|
} from "./workspace-provisioning";
|
|
import {
|
|
createAttachmentResource,
|
|
createImageResource,
|
|
getResourceRow,
|
|
replaceResourceContent,
|
|
} from "./resource-service";
|
|
|
|
// Compatibility aliases keep the existing SQL-heavy implementation small
|
|
// while routing its dependency through the platform-neutral contract above.
|
|
// New code should use DatabaseAdapter directly.
|
|
type D1Database = DatabaseAdapter;
|
|
type D1PreparedStatement = PreparedStatementAdapter;
|
|
|
|
const DEMO_RESET_LEASE_MS = 5 * 60 * 1000;
|
|
const DEMO_RESET_COOLDOWN_MS = 60 * 1000;
|
|
const DEFAULT_R2_BUCKET_NAME = "edgeever-resources";
|
|
const app = new Hono<AppEnv>();
|
|
|
|
// Packaged desktop uses edgeever-app://app; the previous file:// renderer sent Origin "null".
|
|
const API_CORS_ORIGINS = [
|
|
"http://127.0.0.1:5173",
|
|
"http://localhost:5173",
|
|
"null",
|
|
"edgeever-app://app",
|
|
];
|
|
|
|
app.use(
|
|
"/api/*",
|
|
cors({
|
|
origin: API_CORS_ORIGINS,
|
|
allowHeaders: ["Content-Type", "Authorization"],
|
|
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
credentials: true,
|
|
})
|
|
);
|
|
|
|
app.use(
|
|
"/mcp",
|
|
cors({
|
|
origin: API_CORS_ORIGINS,
|
|
allowHeaders: ["Content-Type", "Authorization"],
|
|
allowMethods: ["GET", "POST", "OPTIONS"],
|
|
credentials: true,
|
|
})
|
|
);
|
|
|
|
app.get("/api/release", (c) => c.json(releaseSummary));
|
|
|
|
const getAppliedMigration = async (env: Bindings) => {
|
|
try {
|
|
const row = await env.storage.db
|
|
.prepare(`SELECT name FROM ${env.storage.diagnostics.migrationTable} ORDER BY name DESC LIMIT 1`)
|
|
.first<{ name?: string }>();
|
|
return row?.name?.match(/^(\d+)/)?.[1] ?? row?.name ?? "unknown";
|
|
} catch {
|
|
return "unknown";
|
|
}
|
|
};
|
|
|
|
const getActiveObjectStorageProvider = async (env: Bindings) => {
|
|
try {
|
|
const active = await getActiveObjectStorageConfig(env.storage.db);
|
|
return active?.provider === "builtin" || active?.provider === "s3"
|
|
? active.provider
|
|
: "unknown";
|
|
} catch {
|
|
return "unknown";
|
|
}
|
|
};
|
|
|
|
app.get("/api/health", async (c) => {
|
|
const authMode = await getInstanceAuthMode(c.env, true);
|
|
|
|
if (authMode === "unconfigured") {
|
|
return authNotConfigured(c);
|
|
}
|
|
|
|
if (!c.env.storage.resources) {
|
|
return c.json(
|
|
{
|
|
error: {
|
|
code: "object_storage_not_ready",
|
|
message: "Object storage is not configured. Bind RESOURCES and redeploy.",
|
|
},
|
|
},
|
|
503,
|
|
);
|
|
}
|
|
|
|
const runtime = c.env.EDGE_EVER_RUNTIME ?? "cloudflare-workers";
|
|
|
|
return c.json({
|
|
ok: true,
|
|
name: "edgeever",
|
|
runtime,
|
|
...(runtime === "self-hosted-bun"
|
|
? { containerImageSource: resolveContainerImageSource(c.env.EDGE_EVER_CONTAINER_IMAGE) }
|
|
: {}),
|
|
authMode,
|
|
build: INSTANCE_BUILD_ID.slice(0, 12),
|
|
deployment: resolveInstanceDeploymentMetadata(c.env),
|
|
migration: await getAppliedMigration(c.env),
|
|
storage: {
|
|
database: c.env.storage.diagnostics.database,
|
|
resources: c.env.storage.diagnostics.resources,
|
|
},
|
|
objectStorageProvider: await getActiveObjectStorageProvider(c.env),
|
|
});
|
|
});
|
|
|
|
// Reachability probe only. Not an API catalog. Agents should use MCP.
|
|
app.get("/api/openapi.json", (c) => c.json(apiProbe));
|
|
|
|
registerPublicShareRoutes(app);
|
|
registerPublicTableFormRoutes(app);
|
|
|
|
registerAuthRoutes(app, {
|
|
authenticateRequest: (...args) => authenticateRequest(...args),
|
|
authenticateSession: (...args) => authenticateSession(...args),
|
|
createSession: (...args) => createSession(...args),
|
|
ensureUserWorkspace: (...args) => ensureUserWorkspace(...args),
|
|
getBearerToken: (...args) => getBearerToken(...args),
|
|
getInstanceAuthMode: (...args) => getInstanceAuthMode(...args),
|
|
getLoginAttemptKeys: (...args) => getLoginAttemptKeys(...args),
|
|
isDemoEnvironment: (environment) => isDemoMode(environment) || isLocalDemoSeedEnabled(environment),
|
|
isDemoMode: (...args) => isDemoMode(...args),
|
|
revokeSession: (...args) => revokeSession(...args),
|
|
setSessionCookie: (...args) => setSessionCookie(...args),
|
|
tooManyLoginAttempts: (...args) => tooManyLoginAttempts(...args),
|
|
verifyLogin: (...args) => verifyLogin(...args),
|
|
});
|
|
registerUserRoutes(app, {
|
|
authenticateRequest: (...args) => authenticateRequest(...args),
|
|
getInstanceUser: (...args) => getInstanceUser(...args),
|
|
});
|
|
|
|
app.use("/api/v1/*", async (c, next) => {
|
|
if (c.req.path.startsWith("/api/v1/auth/")) {
|
|
await next();
|
|
return;
|
|
}
|
|
|
|
const authMode = await getInstanceAuthMode(c.env);
|
|
|
|
if (authMode === "unconfigured") {
|
|
return authNotConfigured(c);
|
|
}
|
|
|
|
if (authMode === "disabled") {
|
|
c.set("auth", {
|
|
kind: "user",
|
|
actorType: "user",
|
|
actorId: null,
|
|
username: "owner",
|
|
displayName: "Owner",
|
|
scopes: [],
|
|
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
role: "owner",
|
|
});
|
|
await next();
|
|
return;
|
|
}
|
|
|
|
const auth = await authenticateRequest(c, true);
|
|
|
|
if (!auth) {
|
|
return unauthorized(c, "Authentication required.");
|
|
}
|
|
|
|
c.set("auth", auth);
|
|
await next();
|
|
});
|
|
|
|
registerObjectStorageRoutes(app, {
|
|
isDemoMode: (...args) => isDemoMode(...args),
|
|
});
|
|
registerAiRoutes(app, {
|
|
isDemoMode: (...args) => isDemoMode(...args),
|
|
});
|
|
registerPluginCapabilityRoutes(app, { isDemoMode: (...args) => isDemoMode(...args) });
|
|
registerCompanionRoutes(app, { isDemoMode: (...args) => isDemoMode(...args) });
|
|
registerAiPromptRoutes(app, {
|
|
isDemoMode: (...args) => isDemoMode(...args),
|
|
});
|
|
|
|
registerApiTokenRoutes(app, {
|
|
sha256: (...args) => sha256(...args),
|
|
});
|
|
registerNotebookRoutes(app, async (env) => {
|
|
if (isDemoMode(env)) await ensureDemoSeed(env);
|
|
});
|
|
|
|
registerSyncRoutes(app, {
|
|
clampNumber: (...args) => clampNumber(...args),
|
|
mapMemoDetail: (...args) => mapMemoDetail(...args),
|
|
});
|
|
registerTagRoutes(app);
|
|
registerPluginDistributionRoutes(app);
|
|
registerScheduledTaskRoutes(app);
|
|
registerWorkspaceExtensionRoutes(app, { isDemoMode: (...args) => isDemoMode(...args) });
|
|
registerMemoShareRoutes(app);
|
|
registerTableFormRoutes(app);
|
|
registerTemplateRoutes(app, {
|
|
createMemoRecord: (...args) => createMemoRecord(...args),
|
|
getMemoDetail: (...args) => getMemoDetail(...args),
|
|
});
|
|
|
|
registerMemoRoutes(app, {
|
|
clampNumber: (...args) => clampNumber(...args),
|
|
createImageResource: (...args) => createImageResource(...args),
|
|
createMemo: (...args) => createMemoRecord(...args),
|
|
createMemoEditSession: (...args) => createMemoEditSession(...args),
|
|
deleteMemo: (...args) => deleteMemoRecord(...args),
|
|
deleteMemos: (...args) => deleteMemosRecord(...args),
|
|
emptyTrash: (...args) => emptyTrashMemosRecord(...args),
|
|
getMemoDetail: (...args) => getMemoDetail(...args),
|
|
listMemos: (...args) => listMemos(...args),
|
|
listMemoRevisions: (...args) => listMemoRevisionsService(...args, getMemoDetail, false),
|
|
mergeMemos: (...args) => mergeMemosRecord(...args),
|
|
moveMemos: (...args) => moveMemosRecord(...args),
|
|
restoreMemo: (...args) => restoreMemoRecord(...args),
|
|
restoreMemoRevision: (...args) => restoreMemoRevisionService(...args, { getMemoDetail, getMemoDetailRow }),
|
|
updateMemo: (...args) => updateMemoRecord(...args),
|
|
});
|
|
|
|
registerBackupRoutes(app, {
|
|
clampNumber: (...args) => clampNumber(...args),
|
|
getMemoDetail: (...args) => getMemoDetail(...args),
|
|
mapMemoDetail: (...args) => mapMemoDetail(...args),
|
|
restoreJsonMemos: restoreJsonMemosService,
|
|
restoreJsonNotebooks: restoreJsonNotebooksService,
|
|
sha256Bytes,
|
|
initiateResourceRestoreUpload: (...args) => initiateResourceRestoreUpload(...args),
|
|
});
|
|
|
|
registerMcpRoutes(app, {
|
|
authenticateRequest: (...args) => authenticateRequest(...args),
|
|
callTool: (...args) => callMcpTool(...args),
|
|
});
|
|
|
|
|
|
registerResourceRoutes(app, {
|
|
clampNumber: (...args) => clampNumber(...args),
|
|
createAttachmentResource: (...args) => createAttachmentResource(...args),
|
|
createImageResource: (...args) => createImageResource(...args),
|
|
getMemoDetail: (...args) => getMemoDetail(...args),
|
|
getResourceRow: (...args) => getResourceRow(...args),
|
|
initiateResourceUpload: (...args) => initiateResourceUpload(...args),
|
|
uploadResourcePart: (...args) => uploadResourcePart(...args),
|
|
completeResourceUpload: (...args) => completeResourceUpload(...args),
|
|
abortResourceUpload: (...args) => abortResourceUpload(...args),
|
|
replaceResourceContent: (...args) => replaceResourceContent(...args),
|
|
});
|
|
|
|
app.post("/api/v1/demo/reset", async (c) => {
|
|
if (!isDemoMode(c.env) && !isLocalDemoSeedEnabled(c.env)) {
|
|
return c.json(
|
|
{
|
|
error: {
|
|
code: "demo_mode_disabled",
|
|
message: "Demo reset is only available when demo mode or local demo seed is enabled",
|
|
},
|
|
},
|
|
400
|
|
);
|
|
}
|
|
|
|
const reset = await resetDemoData(c.env, Date.now());
|
|
if (!reset) {
|
|
c.header("Retry-After", String(Math.ceil(DEMO_RESET_COOLDOWN_MS / 1000)));
|
|
return c.json(
|
|
{
|
|
error: {
|
|
code: "demo_reset_in_progress",
|
|
message: "Demo reset is already in progress or cooling down",
|
|
},
|
|
},
|
|
409
|
|
);
|
|
}
|
|
|
|
return c.json({
|
|
success: true,
|
|
message: "Demo seed data successfully restored",
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Executes the platform-neutral EdgeEver application with an injected storage
|
|
* adapter. Runtime entrypoints must remain thin and call this function rather
|
|
* than introducing platform-specific route or service implementations.
|
|
*/
|
|
export const fetchEdgeEverApp = async (
|
|
request: Request,
|
|
runtimeEnv: Bindings,
|
|
ctx: ExecutionContext,
|
|
) => {
|
|
if (isLocalDemoSeedEnabled(runtimeEnv)) {
|
|
await ensureLocalDemoSeed(runtimeEnv);
|
|
}
|
|
|
|
return app.fetch(request, runtimeEnv, ctx);
|
|
};
|
|
|
|
const worker = {
|
|
async fetch(request: Request, env: WorkerBindings, ctx: ExecutionContext) {
|
|
return fetchEdgeEverApp(request, {
|
|
...env,
|
|
storage: createCloudflareStorageAdapter(env),
|
|
// workerd's default Internet egress checks resolved addresses against its public-only network policy.
|
|
publicNetworkFetch: (url, init) => fetch(url, init),
|
|
}, ctx);
|
|
},
|
|
async scheduled(controller: ScheduledController, env: WorkerBindings, ctx: ExecutionContext) {
|
|
const runtimeEnv = {
|
|
...env,
|
|
storage: createCloudflareStorageAdapter(env),
|
|
} as Bindings;
|
|
|
|
if (!isDemoMode(runtimeEnv)) {
|
|
return;
|
|
}
|
|
|
|
ctx.waitUntil(resetDemoData(runtimeEnv, controller.scheduledTime, { resetCredentials: true }));
|
|
},
|
|
};
|
|
|
|
app.notFound((c) =>
|
|
c.json(
|
|
{
|
|
error: {
|
|
code: "not_found",
|
|
message: "Route not found",
|
|
},
|
|
},
|
|
404
|
|
)
|
|
);
|
|
|
|
app.onError((error, c) => {
|
|
if (error instanceof AppError) {
|
|
return apiError(c, error.code, error.message, error.status);
|
|
}
|
|
|
|
if (isDatabaseNotReadyError(error)) {
|
|
console.error("EdgeEver database readiness check failed", error);
|
|
return databaseNotReady(c);
|
|
}
|
|
|
|
console.error("Unhandled EdgeEver API error", error);
|
|
return apiError(c, "internal_error", "An unexpected server error occurred.", 500);
|
|
});
|
|
|
|
export default worker;
|
|
|
|
export const callMcpTool = (
|
|
context: AppContext,
|
|
auth: AuthContext,
|
|
name: string,
|
|
args: Record<string, unknown>,
|
|
) => executeWorkspaceTool(context, auth, name, args);
|
|
|
|
const isDemoMode = (env: Bindings) => isDemoModeEnabled(env.EDGE_EVER_DEMO_MODE);
|
|
const isLocalDemoSeedEnabled = (env: Bindings) =>
|
|
env.EDGE_EVER_LOCAL_DEMO_SEED?.trim().toLowerCase() === "true";
|
|
|
|
const rotateWorkspaceSyncIdentity = (db: Bindings["storage"]["db"], at: string) =>
|
|
db.prepare(`UPDATE workspaces SET created_at = ?`).bind(at).run();
|
|
|
|
let localDemoSeedPromise: Promise<void> | null = null;
|
|
|
|
const ensureLocalDemoSeed = (env: Bindings) => {
|
|
localDemoSeedPromise ??= (async () => {
|
|
const memoPlaceholders = DEMO_SEED_MEMO_IDS.map(() => "?").join(", ");
|
|
await env.storage.db.batch([
|
|
env.storage.db.prepare(`DELETE FROM mobile_sync_changes`),
|
|
env.storage.db.prepare(`DELETE FROM memo_search_documents`),
|
|
env.storage.db.prepare(`DELETE FROM resources`),
|
|
env.storage.db.prepare(`DELETE FROM memo_revisions`),
|
|
env.storage.db.prepare(`DELETE FROM memo_contents WHERE memo_id NOT IN (${memoPlaceholders})`).bind(...DEMO_SEED_MEMO_IDS),
|
|
env.storage.db.prepare(`DELETE FROM memos WHERE id NOT IN (${memoPlaceholders})`).bind(...DEMO_SEED_MEMO_IDS),
|
|
]);
|
|
|
|
await ensureDemoSeed(env, { overwriteExisting: true, refreshResources: true });
|
|
// The wipe deletes the changelog before the memo deletes, so a browser
|
|
// already caught up never sees those deletes. A new sync identity forces
|
|
// that browser to rebuild from this snapshot instead of keeping the old notes.
|
|
await rotateWorkspaceSyncIdentity(env.storage.db, isoNow());
|
|
await audit(env.storage.db, "system", null, "demo.local_seed", "demo", "edgeever-local", {
|
|
seedMemoCount: DEMO_SEED_MEMOS.length,
|
|
mode: "sync-seed",
|
|
});
|
|
})().catch((error) => {
|
|
localDemoSeedPromise = null;
|
|
throw error;
|
|
});
|
|
|
|
return localDemoSeedPromise;
|
|
};
|
|
|
|
const ensureDemoSeed = async (
|
|
env: Bindings,
|
|
options: { overwriteExisting?: boolean; refreshResources?: boolean } = {},
|
|
) => {
|
|
const db = env.storage.db;
|
|
const now = isoNow();
|
|
const statements: D1PreparedStatement[] = [];
|
|
const bucketName = env.EDGE_EVER_R2_BUCKET_NAME?.trim() || DEFAULT_R2_BUCKET_NAME;
|
|
const overwriteExisting = options.overwriteExisting === true;
|
|
const existingNotebookIds = overwriteExisting
|
|
? new Set<string>()
|
|
: new Set(
|
|
(
|
|
await db
|
|
.prepare(`SELECT id FROM notebooks WHERE id IN (${DEMO_SEED_NOTEBOOK_IDS.map(() => "?").join(", ")})`)
|
|
.bind(...DEMO_SEED_NOTEBOOK_IDS)
|
|
.all<{ id: string }>()
|
|
).results.map((notebook) => notebook.id),
|
|
);
|
|
const existingMemoRows = overwriteExisting
|
|
? []
|
|
: (
|
|
await db
|
|
.prepare(
|
|
`SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned,
|
|
m.is_archived, m.is_deleted, c.content_hash, c.revision,
|
|
s.title AS search_title, s.content_text AS search_content_text, s.tags AS search_tags
|
|
FROM memos m
|
|
LEFT JOIN memo_contents c ON c.memo_id = m.id
|
|
LEFT JOIN memo_search_documents s ON s.memo_id = m.id
|
|
WHERE m.id IN (${DEMO_SEED_MEMO_IDS.map(() => "?").join(", ")})`,
|
|
)
|
|
.bind(...DEMO_SEED_MEMO_IDS)
|
|
.all<{
|
|
id: string;
|
|
notebook_id: string;
|
|
title: string;
|
|
excerpt: string;
|
|
tags_json: string;
|
|
is_pinned: number;
|
|
is_archived: number;
|
|
is_deleted: number;
|
|
content_hash: string | null;
|
|
revision: number | null;
|
|
search_title: string | null;
|
|
search_content_text: string | null;
|
|
search_tags: string | null;
|
|
}>()
|
|
).results;
|
|
const existingMemosById = new Map(existingMemoRows.map((memo) => [memo.id, memo]));
|
|
|
|
for (const notebook of DEMO_SEED_NOTEBOOKS) {
|
|
if (!shouldUpsertDemoSeedRecord(existingNotebookIds, notebook.id, overwriteExisting)) {
|
|
continue;
|
|
}
|
|
|
|
statements.push(
|
|
db
|
|
.prepare(
|
|
`INSERT INTO notebooks (
|
|
id, parent_id, name, slug, icon, color, sort_order, is_deleted, created_at, updated_at, deleted_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, NULL)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
parent_id = excluded.parent_id,
|
|
name = excluded.name,
|
|
slug = excluded.slug,
|
|
icon = excluded.icon,
|
|
color = excluded.color,
|
|
sort_order = excluded.sort_order,
|
|
is_deleted = 0,
|
|
updated_at = excluded.updated_at,
|
|
deleted_at = NULL`
|
|
)
|
|
.bind(
|
|
notebook.id,
|
|
notebook.parentId,
|
|
notebook.name,
|
|
notebook.slug,
|
|
notebook.icon,
|
|
notebook.color,
|
|
notebook.sortOrder,
|
|
now,
|
|
now
|
|
)
|
|
);
|
|
}
|
|
|
|
for (const memo of DEMO_SEED_MEMOS) {
|
|
const contentJson = markdownToDoc(memo.markdown);
|
|
const applyDemoImageWidths = (nodes: any[]) => {
|
|
for (const node of nodes) {
|
|
if (node.type === "image") {
|
|
node.attrs = { ...node.attrs, width: 35 };
|
|
}
|
|
if (Array.isArray(node.content)) {
|
|
applyDemoImageWidths(node.content);
|
|
}
|
|
}
|
|
};
|
|
if (Array.isArray(contentJson.content)) {
|
|
applyDemoImageWidths(contentJson.content);
|
|
}
|
|
const contentText = docToText(contentJson);
|
|
const contentHash = await sha256(memo.markdown + JSON.stringify(contentJson));
|
|
const excerpt = createExcerpt(contentText);
|
|
const tagsJson = JSON.stringify(normalizeTags(memo.tags));
|
|
const searchTags = memo.tags.join(" ");
|
|
const revision = "revision" in memo ? memo.revision : 0;
|
|
const existing = existingMemosById.get(memo.id);
|
|
const seedIsCurrent = existing
|
|
&& existing.notebook_id === memo.notebookId
|
|
&& existing.title === memo.title
|
|
&& existing.excerpt === excerpt
|
|
&& existing.tags_json === tagsJson
|
|
&& existing.is_pinned === (memo.isPinned ? 1 : 0)
|
|
&& existing.is_archived === 0
|
|
&& existing.is_deleted === 0
|
|
&& existing.content_hash === contentHash
|
|
&& existing.revision === revision
|
|
&& existing.search_title === memo.title
|
|
&& existing.search_content_text === contentText
|
|
&& existing.search_tags === searchTags;
|
|
if (!overwriteExisting && seedIsCurrent) continue;
|
|
|
|
statements.push(
|
|
db
|
|
.prepare(
|
|
`INSERT INTO memos (
|
|
id, notebook_id, title, excerpt, tags_json, is_pinned, is_archived, is_deleted,
|
|
created_by, updated_by, created_at, updated_at, deleted_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 'system', 'system', ?, ?, NULL)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
notebook_id = excluded.notebook_id,
|
|
title = excluded.title,
|
|
excerpt = excluded.excerpt,
|
|
tags_json = excluded.tags_json,
|
|
is_pinned = excluded.is_pinned,
|
|
is_archived = 0,
|
|
is_deleted = 0,
|
|
updated_by = 'system',
|
|
updated_at = excluded.updated_at,
|
|
deleted_at = NULL`
|
|
)
|
|
.bind(
|
|
memo.id,
|
|
memo.notebookId,
|
|
memo.title,
|
|
excerpt,
|
|
tagsJson,
|
|
memo.isPinned ? 1 : 0,
|
|
now,
|
|
now
|
|
),
|
|
db
|
|
.prepare(
|
|
`INSERT INTO memo_contents (
|
|
memo_id, content_json, content_markdown, content_text, content_hash, revision, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(memo_id) DO UPDATE SET
|
|
content_json = excluded.content_json,
|
|
content_markdown = excluded.content_markdown,
|
|
content_text = excluded.content_text,
|
|
content_hash = excluded.content_hash,
|
|
revision = excluded.revision,
|
|
updated_at = excluded.updated_at`
|
|
)
|
|
.bind(
|
|
memo.id,
|
|
JSON.stringify(contentJson),
|
|
memo.markdown,
|
|
contentText,
|
|
contentHash,
|
|
revision,
|
|
now,
|
|
now,
|
|
),
|
|
upsertMemoSearchDocumentStatement(db, memo.id, memo.title, contentText, searchTags)
|
|
);
|
|
}
|
|
|
|
const existingRevisionRows = overwriteExisting
|
|
? []
|
|
: (
|
|
await db
|
|
.prepare(
|
|
`SELECT id, memo_id, revision, title, content_hash
|
|
FROM memo_revisions
|
|
WHERE id IN (${DEMO_SEED_REVISIONS.map(() => "?").join(", ")})`,
|
|
)
|
|
.bind(...DEMO_SEED_REVISIONS.map((revision) => revision.id))
|
|
.all<{
|
|
id: string;
|
|
memo_id: string;
|
|
revision: number;
|
|
title: string | null;
|
|
content_hash: string;
|
|
}>()
|
|
).results;
|
|
const existingRevisionsById = new Map(existingRevisionRows.map((revision) => [revision.id, revision]));
|
|
|
|
for (const revision of DEMO_SEED_REVISIONS) {
|
|
const contentJson = markdownToDoc(revision.markdown);
|
|
const contentHash = await sha256(revision.markdown + JSON.stringify(contentJson));
|
|
const existing = existingRevisionsById.get(revision.id);
|
|
const seedIsCurrent = existing
|
|
&& existing.memo_id === revision.memoId
|
|
&& existing.revision === revision.revision
|
|
&& existing.title === revision.title
|
|
&& existing.content_hash === contentHash;
|
|
if (!overwriteExisting && seedIsCurrent) continue;
|
|
|
|
statements.push(
|
|
db
|
|
.prepare(
|
|
`INSERT INTO memo_revisions (
|
|
id, memo_id, revision, title, content_json, content_markdown, content_hash,
|
|
created_by, created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 'system', ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
memo_id = excluded.memo_id,
|
|
revision = excluded.revision,
|
|
title = excluded.title,
|
|
content_json = excluded.content_json,
|
|
content_markdown = excluded.content_markdown,
|
|
content_hash = excluded.content_hash`
|
|
)
|
|
.bind(
|
|
revision.id,
|
|
revision.memoId,
|
|
revision.revision,
|
|
revision.title,
|
|
JSON.stringify(contentJson),
|
|
revision.markdown,
|
|
contentHash,
|
|
now,
|
|
),
|
|
);
|
|
}
|
|
|
|
const existingResourceIds = options.refreshResources || overwriteExisting
|
|
? new Set<string>()
|
|
: new Set(
|
|
(
|
|
await db
|
|
.prepare(`SELECT id FROM resources WHERE id IN (${DEMO_SEED_ATTACHMENT_RESOURCES.map(() => "?").join(", ")})`)
|
|
.bind(...DEMO_SEED_ATTACHMENT_RESOURCES.map((resource) => resource.id))
|
|
.all<{ id: string }>()
|
|
).results.map((resource) => resource.id)
|
|
);
|
|
|
|
for (const resource of DEMO_SEED_ATTACHMENT_RESOURCES) {
|
|
if (!shouldUpsertDemoSeedRecord(existingResourceIds, resource.id, overwriteExisting)) {
|
|
continue;
|
|
}
|
|
|
|
const isImageSeed = "svg" in resource;
|
|
const bytes = isImageSeed ? new TextEncoder().encode(resource.svg) : decodeDemoAttachment(resource);
|
|
const extension = isImageSeed ? "svg" : resource.filename.split(".").pop() || "bin";
|
|
const objectKey = `demo/${resource.memoId}/${resource.id}.${extension}`;
|
|
|
|
if (options.refreshResources || !existingResourceIds.has(resource.id)) {
|
|
await env.storage.resources.put(objectKey, bytes, {
|
|
httpMetadata: {
|
|
contentType: resource.mimeType,
|
|
cacheControl: "private, max-age=3600",
|
|
},
|
|
customMetadata: {
|
|
memoId: resource.memoId,
|
|
resourceId: resource.id,
|
|
filename: resource.filename,
|
|
demoSeed: "true",
|
|
},
|
|
});
|
|
}
|
|
|
|
statements.push(
|
|
db
|
|
.prepare(
|
|
`INSERT INTO resources (
|
|
id, memo_id, bucket_name, object_key, storage_config_id, kind, mime_type, filename,
|
|
byte_size, sha256, width, height, metadata_json, is_deleted, created_at, updated_at, deleted_at
|
|
) VALUES (?, ?, ?, ?, 'builtin', ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, NULL)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
memo_id = excluded.memo_id,
|
|
bucket_name = excluded.bucket_name,
|
|
object_key = excluded.object_key,
|
|
storage_config_id = excluded.storage_config_id,
|
|
kind = excluded.kind,
|
|
mime_type = excluded.mime_type,
|
|
filename = excluded.filename,
|
|
byte_size = excluded.byte_size,
|
|
sha256 = excluded.sha256,
|
|
width = excluded.width,
|
|
height = excluded.height,
|
|
metadata_json = excluded.metadata_json,
|
|
is_deleted = 0,
|
|
updated_at = excluded.updated_at,
|
|
deleted_at = NULL`
|
|
)
|
|
.bind(
|
|
resource.id,
|
|
resource.memoId,
|
|
bucketName,
|
|
objectKey,
|
|
isImageSeed ? "image" : "attachment",
|
|
resource.mimeType,
|
|
resource.filename,
|
|
bytes.byteLength,
|
|
await sha256Bytes(bytes),
|
|
isImageSeed ? resource.width : null,
|
|
isImageSeed ? resource.height : null,
|
|
JSON.stringify({ source: "demo-seed" }),
|
|
now,
|
|
now
|
|
)
|
|
);
|
|
}
|
|
|
|
if (statements.length > 0) {
|
|
await db.batch(statements);
|
|
}
|
|
};
|
|
|
|
export const acquireMaintenanceLease = async (
|
|
db: D1Database,
|
|
name: string,
|
|
leaseMs: number,
|
|
) => {
|
|
const ownerId = createId("lease");
|
|
const acquiredAt = isoNow();
|
|
const expiresAt = new Date(Date.now() + leaseMs).toISOString();
|
|
const acquired = await db.prepare(
|
|
`INSERT INTO maintenance_leases (name, owner_id, acquired_at, expires_at)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(name) DO UPDATE SET
|
|
owner_id = excluded.owner_id,
|
|
acquired_at = excluded.acquired_at,
|
|
expires_at = excluded.expires_at
|
|
WHERE maintenance_leases.expires_at <= excluded.acquired_at
|
|
RETURNING owner_id`,
|
|
).bind(name, ownerId, acquiredAt, expiresAt).first<{ owner_id: string }>();
|
|
|
|
return acquired?.owner_id === ownerId ? ownerId : null;
|
|
};
|
|
|
|
const resetDemoData = async (
|
|
env: Bindings,
|
|
scheduledTime: number,
|
|
options: { resetCredentials?: boolean } = {}
|
|
) => {
|
|
const db = env.storage.db;
|
|
const leaseOwnerId = await acquireMaintenanceLease(db, "demo-reset", DEMO_RESET_LEASE_MS);
|
|
if (!leaseOwnerId) return false;
|
|
|
|
const now = isoNow();
|
|
try {
|
|
const demoUsername = env.EDGE_EVER_AUTH_USERNAME?.trim() || "admin";
|
|
const demoPasswordHash = await resolveDemoPasswordHash(
|
|
env.EDGE_EVER_AUTH_PASSWORD,
|
|
env.EDGE_EVER_AUTH_PASSWORD_HASH,
|
|
hashPassword,
|
|
);
|
|
const resourceRows = await db.prepare(`SELECT object_key, storage_config_id FROM resources`).all<{ object_key: string; storage_config_id: string }>();
|
|
await deleteStoredObjects(env, resourceRows.results);
|
|
|
|
const resetStatements: D1PreparedStatement[] = [
|
|
db.prepare(`DELETE FROM mobile_sync_changes`),
|
|
db.prepare(`DELETE FROM memo_search_documents`),
|
|
db.prepare(`DELETE FROM resources`),
|
|
db.prepare(`DELETE FROM memo_revisions`),
|
|
db.prepare(`DELETE FROM memo_contents`),
|
|
db.prepare(`DELETE FROM memos`),
|
|
db.prepare(`UPDATE notebooks SET parent_id = NULL`),
|
|
db.prepare(`DELETE FROM notebooks`),
|
|
db.prepare(`DELETE FROM api_tokens`),
|
|
db.prepare(`DELETE FROM audit_events`),
|
|
];
|
|
|
|
if (options.resetCredentials && demoPasswordHash) {
|
|
resetStatements.push(
|
|
db.prepare(`UPDATE users SET password_hash = ?, updated_at = ? WHERE username = ? AND is_disabled = 0`)
|
|
.bind(demoPasswordHash, now, demoUsername),
|
|
db.prepare(
|
|
`UPDATE sessions SET revoked_at = ?
|
|
WHERE user_id IN (SELECT id FROM users WHERE username = ? AND is_disabled = 0)
|
|
AND revoked_at IS NULL`
|
|
).bind(now, demoUsername),
|
|
);
|
|
}
|
|
|
|
await db.batch(resetStatements);
|
|
|
|
await ensureDemoSeed(env, { overwriteExisting: true, refreshResources: true });
|
|
await rotateWorkspaceSyncIdentity(db, isoNow());
|
|
await audit(db, "system", null, "demo.reset", "demo", "edgeever-demo", {
|
|
scheduledTime: new Date(scheduledTime).toISOString(),
|
|
seedMemoCount: DEMO_SEED_MEMOS.length,
|
|
});
|
|
|
|
await db.prepare(
|
|
`UPDATE maintenance_leases SET expires_at = ? WHERE name = 'demo-reset' AND owner_id = ?`,
|
|
).bind(new Date(Date.now() + DEMO_RESET_COOLDOWN_MS).toISOString(), leaseOwnerId).run();
|
|
return true;
|
|
} catch (error) {
|
|
await db.prepare(
|
|
`DELETE FROM maintenance_leases WHERE name = 'demo-reset' AND owner_id = ?`,
|
|
).bind(leaseOwnerId).run();
|
|
throw error;
|
|
}
|
|
};
|