* feat(memos): duplicate a note with its files Add "Duplicate note" to the note list's context menu and the text editor's "More note actions" menu. The copy keeps the note's content and tags, is titled "<title> (copy)" in the UI language, and opens in the editor. POST /api/v1/memos/:id/duplicate (write:memos) copies every image and attachment of the note to a new resource with its own stored object and rewrites the content to reference the copies, so editing or deleting either note never affects the other. Shares, revisions, the pinned state, public form links and unsubmitted form uploads are not copied. A note with more than 20 files or more than 64 MiB of files is refused, and files written for a copy that fails are removed. The Web and desktop clients synchronize the note's queued changes before duplicating and refuse while any remain, including unresolved conflicts; the editor item stays disabled until the current edit is saved. * fix(memos): keep committed duplicate files and check the whole desktop outbox - Copied objects are removed only when the write batch fails. A failure after the commit, such as a transient error while reading the new memo, no longer deletes files that committed resource rows reference. When the memo row is missing after the commit (its notebook was removed meanwhile), the rows are removed before their objects. - The sidecar lists at most 200 outbox items, so a conflict of the note further down the queue was invisible and the stale server version could be copied. The desktop client now compares the listed items with the sidecar's status counts and refuses to duplicate unless the whole outstanding queue was listed, nothing is being synchronized, and no item belongs to the note. - Regression tests cover a read failure after the commit and a target conflict at position 201 behind 200 other conflicts.
958 lines
32 KiB
TypeScript
958 lines
32 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 { resolveDeploymentVersionCreatedAt, 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,
|
|
httpExceptionError,
|
|
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 { registerPosterRenderRoutes } from "./poster-render-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";
|
|
import { duplicateMemoRecord } from "./memo-duplicate-service";
|
|
|
|
export { createMemoEditSession, mergeMemosRecord, updateMemoRecord };
|
|
import { listMemos } from "./memo-list-service";
|
|
import {
|
|
registerUserRoutes,
|
|
} from "./user-routes";
|
|
import { registerNotebookRoutes } from "./notebook-routes";
|
|
import { registerNotebookShareRoutes, registerPublicNotebookShareRoutes } from "./notebook-share-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),
|
|
deploymentVersionCreatedAt: resolveDeploymentVersionCreatedAt(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);
|
|
registerPublicNotebookShareRoutes(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);
|
|
registerPosterRenderRoutes(app);
|
|
registerPluginDistributionRoutes(app);
|
|
registerScheduledTaskRoutes(app);
|
|
registerWorkspaceExtensionRoutes(app, { isDemoMode: (...args) => isDemoMode(...args) });
|
|
registerMemoShareRoutes(app);
|
|
registerNotebookShareRoutes(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),
|
|
duplicateMemo: (...args) => duplicateMemoRecord(...args),
|
|
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),
|
|
deploymentVersionCreatedAt: env.CF_VERSION_METADATA?.timestamp,
|
|
// 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);
|
|
}
|
|
|
|
const requestError = httpExceptionError(c, error);
|
|
if (requestError) return requestError;
|
|
|
|
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;
|
|
}
|
|
};
|