feat: migrate todo note to nc01 github storage

This commit is contained in:
Codex
2026-07-10 03:47:30 +02:00
parent e11c140b09
commit 0184bd7334
57 changed files with 3738 additions and 350 deletions
@@ -606,6 +606,16 @@ const TODO_NOTE_ACTION_TYPES: &[&str] = &[
];
const TODO_NOTE_WRITABLE_ENDPOINTS: &[(&str, &str, &str)] = &[
(
"POST",
"/api/storage/import",
"Import a confirmed snapshot or gzip-packed migration request.",
),
(
"POST",
"/api/storage/migrate",
"Persist the current runtime index to the configured GitHub store.",
),
(
"POST",
"/api/instances",
@@ -175,6 +175,7 @@ const requirementRecordTypes = new Set<RequirementRecordType>(["decision", "goal
const recordLevels = new Set<DecisionRecordLevel>(["G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"]);
const recordStatuses = new Set<DecisionRecordStatus>(["active", "blocked", "parked", "done"]);
const serviceStartedAt = new Date().toISOString();
const isCheckMode = process.execArgv.includes("--check");
const recentLogs: JsonRecord[] = [];
let schemaReady = false;
let schemaLastError: JsonRecord | null = null;
@@ -196,7 +197,7 @@ function envNumber(name: string, fallback: number): number {
}
function configFromEnv(): RuntimeConfig {
const databaseUrl = process.env.DATABASE_URL || "";
const databaseUrl = process.env.DATABASE_URL || (isCheckMode ? "postgres://syntax-check@127.0.0.1:1/syntax-check" : "");
if (!databaseUrl) throw new Error("DATABASE_URL is required");
const storagePrimary = envString("DECISION_CENTER_STORAGE_PRIMARY", "postgres");
if (storagePrimary !== "postgres" && storagePrimary !== "github-repo") throw new Error("DECISION_CENTER_STORAGE_PRIMARY must be postgres or github-repo");
@@ -240,7 +241,7 @@ const logWriter = config.logFile
maxBytes: logRetentionBytesForService("decision-center"),
})
: null;
logWriter?.prune();
if (!isCheckMode) logWriter?.prune();
function log(level: "info" | "warn" | "error", event: string, detail: JsonRecord = {}): void {
const record: JsonRecord = { at: new Date().toISOString(), service: "decision-center", level, event, ...detail };
@@ -1111,9 +1112,28 @@ async function writeSnapshotToGit(reason: string, dryRun: boolean): Promise<Json
maxBuffer: 8 * 1024 * 1024,
});
if ((commit.status ?? 1) !== 0) throw new HttpError(503, "git commit failed", { stderr: String(commit.stderr).slice(-2000) });
runGit(["push", "origin", config.storage.branch]);
let pushAttempts = 0;
let pushSucceeded = false;
let pushStderr = "";
for (let attempt = 1; attempt <= 3; attempt += 1) {
pushAttempts = attempt;
const pushed = runGit(["push", "origin", config.storage.branch], { allowFailure: true });
pushStderr = pushed.stderr;
if (pushed.ok) {
pushSucceeded = true;
break;
}
const fetched = runGit(["fetch", "origin", config.storage.branch], { allowFailure: true });
if (!fetched.ok) continue;
const rebased = runGit(["rebase", `origin/${config.storage.branch}`], { allowFailure: true });
if (!rebased.ok) {
runGit(["rebase", "--abort"], { allowFailure: true });
throw new HttpError(503, "git storage rebase failed", { attempt, stderr: rebased.stderr.slice(-2000) });
}
}
if (!pushSucceeded) throw new HttpError(503, "git storage push failed after retries", { attempts: pushAttempts, stderr: pushStderr.slice(-2000) });
await updateStorageHealth();
return { ok: true, dryRun: false, changed: true, records: records.length, diaryEntries: diaryEntries.length, head: gitHeadSummary() };
return { ok: true, dryRun: false, changed: true, records: records.length, diaryEntries: diaryEntries.length, pushAttempts, head: gitHeadSummary() };
});
}
@@ -2144,30 +2164,32 @@ async function route(req: Request): Promise<Response> {
throw new HttpError(404, "route not found", { path: url.pathname });
}
Bun.serve({
hostname: config.host,
port: config.port,
async fetch(req) {
try {
return await route(req);
} catch (error) {
return errorResponse(error);
}
},
});
void waitForSchema()
.then(async () => {
await refreshDatabaseHealth();
await updateStorageHealth();
})
.catch((error) => {
log("error", "schema_init_failed", { error: errorToJson(error) });
if (import.meta.main && !isCheckMode) {
Bun.serve({
hostname: config.host,
port: config.port,
async fetch(req) {
try {
return await route(req);
} catch (error) {
return errorResponse(error);
}
},
});
setInterval(() => {
if (schemaReady) {
void refreshDatabaseHealth();
void updateStorageHealth();
}
}, 15_000).unref();
log("info", "service_started", { host: config.host, port: config.port, storage: storageConfigSummary() });
void waitForSchema()
.then(async () => {
await refreshDatabaseHealth();
await updateStorageHealth();
})
.catch((error) => {
log("error", "schema_init_failed", { error: errorToJson(error) });
});
setInterval(() => {
if (schemaReady) {
void refreshDatabaseHealth();
void updateStorageHealth();
}
}, 15_000).unref();
log("info", "service_started", { host: config.host, port: config.port, storage: storageConfigSummary() });
}
@@ -0,0 +1,16 @@
ARG TODO_NOTE_BASE_IMAGE=oven/bun:1-debian
FROM ${TODO_NOTE_BASE_IMAGE}
WORKDIR /app/src/components/microservices/todo-note
RUN apt-get update \
&& apt-get install -y --no-install-recommends git openssh-client ca-certificates \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY src/components/microservices/todo-note/package.json src/components/microservices/todo-note/bun.lock ./
RUN bun install --production --frozen-lockfile
COPY src/components/microservices/todo-note/tsconfig.json ./tsconfig.json
COPY src/components/shared /app/src/components/shared
COPY src/components/microservices/todo-note/src ./src
EXPOSE 4288
CMD ["bun", "run", "src/index.ts"]
@@ -0,0 +1,224 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@unidesk/todo-note",
"dependencies": {
"express": "latest",
"postgres": "latest",
},
"devDependencies": {
"@types/bun": "latest",
"@types/express": "latest",
"@types/node": "latest",
"typescript": "latest",
},
},
},
"packages": {
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.2", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg=="],
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
"@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
"range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
}
}
@@ -0,0 +1,20 @@
{
"name": "@unidesk/todo-note",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"check": "bun --check src/index.ts && bun --check src/repository.ts && bun --check src/git-storage.ts && bun --check src/reminder-notifier.ts && bun --check scripts/export-postgres.ts && bun --check scripts/pack-import.ts"
},
"dependencies": {
"express": "latest",
"postgres": "latest"
},
"devDependencies": {
"@types/bun": "latest",
"@types/express": "latest",
"@types/node": "latest",
"typescript": "latest"
}
}
@@ -0,0 +1,134 @@
import { createHash } from "node:crypto";
import { chmod } from "node:fs/promises";
import postgres from "postgres";
interface InstanceRow {
id: string;
name: string;
file_path: string;
history_path: string;
created_at: string;
updated_at: string;
history_pointer: number;
todos: unknown;
source: unknown;
}
interface HistoryRow {
instance_id: string;
sequence: number;
timestamp: string;
kind: string;
action: string;
snapshot: unknown;
detail: unknown;
}
interface ReminderNotificationRow {
instance_id: string;
todo_id: string;
reminder_at: string;
target: string;
title: string;
message: string;
response_preview: string;
sent_at: string;
}
function outputPath(): string {
const index = Bun.argv.indexOf("--output");
const value = index >= 0 ? Bun.argv[index + 1] : process.env.TODO_NOTE_EXPORT_PATH;
if (!value) throw new Error("--output PATH is required");
return value;
}
async function main(): Promise<void> {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error("DATABASE_URL is required");
const sql = postgres(databaseUrl, {
max: 1,
connect_timeout: 10,
connection: { application_name: "todo-note-cc01-export" },
});
try {
const instances = await sql<InstanceRow[]>`
SELECT id, name, file_path, history_path, created_at, updated_at, history_pointer, todos, source
FROM todo_note_instances
ORDER BY created_at ASC, id ASC
`;
const histories = await sql<HistoryRow[]>`
SELECT instance_id, sequence, timestamp, kind, action, snapshot, detail
FROM todo_note_history
ORDER BY instance_id ASC, id ASC
`;
const reminderNotifications = await sql<ReminderNotificationRow[]>`
SELECT instance_id, todo_id, reminder_at, target, title, message, response_preview, sent_at::text AS sent_at
FROM todo_note_reminder_notifications
ORDER BY id ASC
`;
const historyByInstance = new Map<string, HistoryRow[]>();
for (const row of histories) {
const entries = historyByInstance.get(row.instance_id) ?? [];
entries.push(row);
historyByInstance.set(row.instance_id, entries);
}
const snapshot = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
instances: instances.map((row) => ({
instance: {
id: row.id,
name: row.name,
filePath: row.file_path,
historyPath: row.history_path,
createdAt: row.created_at,
updatedAt: row.updated_at,
historyPointer: Number(row.history_pointer),
todos: row.todos,
},
history: (historyByInstance.get(row.id) ?? []).map((entry) => ({
sequence: Number(entry.sequence),
timestamp: entry.timestamp,
kind: entry.kind,
action: entry.action,
...(entry.snapshot === null ? {} : { snapshot: entry.snapshot }),
...(entry.detail === null ? {} : { detail: entry.detail }),
})),
source: typeof row.source === "object" && row.source !== null ? row.source : {},
})),
reminderNotifications: reminderNotifications.map((row) => ({
instanceId: row.instance_id,
todoId: row.todo_id,
reminderAt: row.reminder_at,
target: row.target,
title: row.title,
message: row.message,
responsePreview: row.response_preview,
sentAt: row.sent_at,
})),
};
const serialized = `${JSON.stringify(snapshot, null, 2)}\n`;
const target = outputPath();
await Bun.write(target, serialized);
await chmod(target, 0o600);
process.stdout.write(`${JSON.stringify({
ok: true,
output: target,
instances: instances.length,
historyEntries: histories.length,
reminderNotifications: reminderNotifications.length,
bytes: Buffer.byteLength(serialized),
sha256: createHash("sha256").update(serialized).digest("hex"),
})}\n`);
} finally {
await sql.end({ timeout: 5 });
}
}
if (import.meta.main && !process.execArgv.includes("--check")) {
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
}
@@ -0,0 +1,36 @@
import { createHash } from "node:crypto";
import { chmod, readFile, writeFile } from "node:fs/promises";
import { gzipSync } from "node:zlib";
function option(name: string): string {
const index = Bun.argv.indexOf(name);
const value = index >= 0 ? Bun.argv[index + 1] : undefined;
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
return value;
}
async function main(): Promise<void> {
const inputPath = option("--input");
const outputPath = option("--output");
const source = await readFile(inputPath);
JSON.parse(source.toString("utf8"));
const compressed = gzipSync(source, { level: 9 });
const payload = `${JSON.stringify({ confirm: true, snapshotGzipBase64: compressed.toString("base64") })}\n`;
await writeFile(outputPath, payload, { encoding: "utf8", mode: 0o600 });
await chmod(outputPath, 0o600);
process.stdout.write(`${JSON.stringify({
ok: true,
output: outputPath,
sourceBytes: source.length,
compressedBytes: compressed.length,
requestBytes: Buffer.byteLength(payload),
sourceSha256: createHash("sha256").update(source).digest("hex"),
})}\n`);
}
if (import.meta.main && !process.execArgv.includes("--check")) {
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
}
@@ -0,0 +1,398 @@
import type { InstanceSummary, TodoAction, TodoInstance, TodoItem } from "./types";
function cloneTodo(todo: TodoItem): TodoItem {
return {
...todo,
children: todo.children.map(cloneTodo)
};
}
function makeId(prefix: string): string {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}
export function createTodo(title: string, now: string): TodoItem {
return {
id: makeId("todo"),
title,
completed: false,
expanded: true,
reminderAt: null,
createdAt: now,
updatedAt: now,
children: []
};
}
export function createInstance(name: string, filePath: string, historyPath: string, now: string): TodoInstance {
return {
id: makeId("instance"),
name,
filePath,
historyPath,
createdAt: now,
updatedAt: now,
historyPointer: 0,
todos: []
};
}
function updateNodeList(
nodes: TodoItem[],
todoId: string,
updater: (todo: TodoItem) => TodoItem
): { nodes: TodoItem[]; found: boolean } {
let found = false;
const nextNodes = nodes.map((node) => {
if (node.id === todoId) {
found = true;
return updater(node);
}
const nested = updateNodeList(node.children, todoId, updater);
if (nested.found) {
found = true;
return {
...node,
children: nested.nodes
};
}
return node;
});
return { nodes: nextNodes, found };
}
function appendNode(nodes: TodoItem[], parentId: string, todo: TodoItem): { nodes: TodoItem[]; found: boolean } {
let found = false;
const nextNodes = nodes.map((node) => {
if (node.id === parentId) {
found = true;
return {
...node,
expanded: true,
children: [...node.children, todo]
};
}
const nested = appendNode(node.children, parentId, todo);
if (nested.found) {
found = true;
return {
...node,
children: nested.nodes
};
}
return node;
});
return { nodes: nextNodes, found };
}
function removeNode(nodes: TodoItem[], todoId: string): { nodes: TodoItem[]; found: boolean } {
let found = false;
const nextNodes: TodoItem[] = [];
for (const node of nodes) {
if (node.id === todoId) {
found = true;
continue;
}
const nested = removeNode(node.children, todoId);
if (nested.found) {
found = true;
nextNodes.push({
...node,
children: nested.nodes
});
continue;
}
nextNodes.push(node);
}
return { nodes: nextNodes, found };
}
function containsTodo(nodes: TodoItem[], todoId: string): boolean {
return nodes.some((node) => node.id === todoId || containsTodo(node.children, todoId));
}
function clampIndex(index: number, max: number): number {
return Math.max(0, Math.min(index, max));
}
function removeNodeWithLocation(
nodes: TodoItem[],
todoId: string,
parentId: string | null = null
): { nodes: TodoItem[]; removed: TodoItem | null; parentId: string | null; index: number } {
for (let index = 0; index < nodes.length; index += 1) {
const node = nodes[index];
if (node.id === todoId) {
return {
nodes: [...nodes.slice(0, index), ...nodes.slice(index + 1)],
removed: node,
parentId,
index
};
}
const nested = removeNodeWithLocation(node.children, todoId, node.id);
if (nested.removed) {
return {
nodes: [...nodes.slice(0, index), { ...node, children: nested.nodes }, ...nodes.slice(index + 1)],
removed: nested.removed,
parentId: nested.parentId,
index: nested.index
};
}
}
return {
nodes,
removed: null,
parentId: null,
index: -1
};
}
function insertNode(
nodes: TodoItem[],
parentId: string | null,
index: number,
todo: TodoItem,
now: string
): { nodes: TodoItem[]; inserted: boolean } {
const movedTodo = {
...todo,
updatedAt: now
};
if (!parentId) {
const nextNodes = [...nodes];
nextNodes.splice(clampIndex(index, nextNodes.length), 0, movedTodo);
return { nodes: nextNodes, inserted: true };
}
let inserted = false;
const nextNodes = nodes.map((node) => {
if (node.id === parentId) {
inserted = true;
const nextChildren = [...node.children];
nextChildren.splice(clampIndex(index, nextChildren.length), 0, movedTodo);
return {
...node,
expanded: true,
updatedAt: now,
children: nextChildren
};
}
const nested = insertNode(node.children, parentId, index, todo, now);
if (nested.inserted) {
inserted = true;
return {
...node,
children: nested.nodes
};
}
return node;
});
return { nodes: nextNodes, inserted };
}
function setExpandedState(nodes: TodoItem[], expanded: boolean, now: string): TodoItem[] {
return nodes.map((node) => {
if (node.children.length === 0) {
return node;
}
const nextChildren = setExpandedState(node.children, expanded, now);
return {
...node,
expanded,
updatedAt: now,
children: nextChildren
};
});
}
export function countTodos(nodes: TodoItem[]): { total: number; completed: number } {
return nodes.reduce(
(accumulator, node) => {
const nested = countTodos(node.children);
return {
total: accumulator.total + 1 + nested.total,
completed: accumulator.completed + (node.completed ? 1 : 0) + nested.completed
};
},
{ total: 0, completed: 0 }
);
}
export function summarizeInstance(instance: TodoInstance): InstanceSummary {
const counts = countTodos(instance.todos);
return {
id: instance.id,
name: instance.name,
filePath: instance.filePath,
historyPath: instance.historyPath,
createdAt: instance.createdAt,
updatedAt: instance.updatedAt,
todoCount: counts.total,
completedCount: counts.completed
};
}
export function cloneInstance(instance: TodoInstance): TodoInstance {
return {
...instance,
todos: instance.todos.map(cloneTodo)
};
}
export function applyInstanceAction(instance: TodoInstance, action: TodoAction, now: string): TodoInstance {
switch (action.type) {
case "addTodo": {
const todo = createTodo(action.title, now);
if (!action.parentId) {
return {
...instance,
updatedAt: now,
todos: [...instance.todos, todo]
};
}
const appended = appendNode(instance.todos, action.parentId, todo);
if (!appended.found) {
throw new Error(`Todo not found: ${action.parentId}`);
}
return {
...instance,
updatedAt: now,
todos: appended.nodes
};
}
case "updateTodoTitle": {
const updated = updateNodeList(instance.todos, action.todoId, (todo) => ({
...todo,
title: action.title,
updatedAt: now
}));
if (!updated.found) {
throw new Error(`Todo not found: ${action.todoId}`);
}
return {
...instance,
updatedAt: now,
todos: updated.nodes
};
}
case "toggleTodoCompleted": {
const updated = updateNodeList(instance.todos, action.todoId, (todo) => ({
...todo,
completed: !todo.completed,
updatedAt: now
}));
if (!updated.found) {
throw new Error(`Todo not found: ${action.todoId}`);
}
return {
...instance,
updatedAt: now,
todos: updated.nodes
};
}
case "toggleTodoExpanded": {
const updated = updateNodeList(instance.todos, action.todoId, (todo) => ({
...todo,
expanded: !todo.expanded,
updatedAt: now
}));
if (!updated.found) {
throw new Error(`Todo not found: ${action.todoId}`);
}
return {
...instance,
updatedAt: now,
todos: updated.nodes
};
}
case "setAllTodosExpanded":
return {
...instance,
updatedAt: now,
todos: setExpandedState(instance.todos, action.expanded, now)
};
case "setTodoReminder": {
const updated = updateNodeList(instance.todos, action.todoId, (todo) => ({
...todo,
reminderAt: action.reminderAt,
updatedAt: now
}));
if (!updated.found) {
throw new Error(`Todo not found: ${action.todoId}`);
}
return {
...instance,
updatedAt: now,
todos: updated.nodes
};
}
case "moveTodo": {
const removed = removeNodeWithLocation(instance.todos, action.todoId);
if (!removed.removed) {
throw new Error(`Todo not found: ${action.todoId}`);
}
const targetParentId = action.targetParentId ?? null;
if (targetParentId && (targetParentId === action.todoId || containsTodo(removed.removed.children, targetParentId))) {
throw new Error(`Cannot move todo ${action.todoId} into its own descendant`);
}
const nextTargetIndex =
removed.parentId === targetParentId && removed.index >= 0 && removed.index < action.targetIndex ? action.targetIndex - 1 : action.targetIndex;
const inserted = insertNode(removed.nodes, targetParentId, nextTargetIndex, removed.removed, now);
if (!inserted.inserted) {
throw new Error(`Target parent not found: ${targetParentId}`);
}
return {
...instance,
updatedAt: now,
todos: inserted.nodes
};
}
case "deleteTodo": {
const updated = removeNode(instance.todos, action.todoId);
if (!updated.found) {
throw new Error(`Todo not found: ${action.todoId}`);
}
return {
...instance,
updatedAt: now,
todos: updated.nodes
};
}
case "renameInstance":
return {
...instance,
name: action.name,
updatedAt: now
};
default:
return instance;
}
}
@@ -0,0 +1,316 @@
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { spawnSync } from "node:child_process";
import { normalizeTodoSnapshot, type TodoSnapshot } from "./repository";
interface GitResult {
ok: boolean;
stdout: string;
stderr: string;
exitCode: number;
}
export interface TodoGitStorageConfig {
primary: "postgres" | "github-repo";
repo: string;
repoSshUrl: string;
branch: string;
basePath: string;
worktreePath: string;
sshCommand: string;
authorName: string;
authorEmail: string;
commitMessagePrefix: string;
}
export class GitStorageError extends Error {
readonly detail: Record<string, unknown>;
constructor(message: string, detail: Record<string, unknown> = {}) {
super(message);
this.name = "GitStorageError";
this.detail = detail;
}
}
function envString(name: string, fallback: string): string {
const value = process.env[name];
return value === undefined || value.length === 0 ? fallback : value;
}
export function gitStorageConfigFromEnv(): TodoGitStorageConfig {
const primary = envString("TODO_NOTE_STORAGE_PRIMARY", "postgres");
if (primary !== "postgres" && primary !== "github-repo") throw new Error("TODO_NOTE_STORAGE_PRIMARY must be postgres or github-repo");
const basePath = envString("TODO_NOTE_GIT_BASE_PATH", "todo-note").replace(/^\/+|\/+$/gu, "");
if (!basePath || basePath === "." || basePath.split("/").includes("..")) {
throw new Error("TODO_NOTE_GIT_BASE_PATH must be a non-root repository path");
}
return {
primary,
repo: envString("TODO_NOTE_GIT_REPO", "pikasTech/decision-center-data"),
repoSshUrl: envString("TODO_NOTE_GIT_REPO_SSH_URL", ""),
branch: envString("TODO_NOTE_GIT_BRANCH", "main"),
basePath,
worktreePath: envString("TODO_NOTE_GIT_WORKTREE", "/var/lib/unidesk/todo-note/repo"),
sshCommand: envString("GIT_SSH_COMMAND", ""),
authorName: envString("TODO_NOTE_GIT_AUTHOR_NAME", "UniDesk Todo Note"),
authorEmail: envString("TODO_NOTE_GIT_AUTHOR_EMAIL", "todo-note@unidesk.local"),
commitMessagePrefix: envString("TODO_NOTE_GIT_COMMIT_PREFIX", "todo-note:"),
};
}
function stableValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableValue);
if (typeof value === "object" && value !== null) {
return Object.fromEntries(Object.entries(value as Record<string, unknown>).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, stableValue(item)]));
}
return value;
}
export function snapshotHash(snapshot: TodoSnapshot): string {
const comparable = { schemaVersion: snapshot.schemaVersion, instances: snapshot.instances, reminderNotifications: snapshot.reminderNotifications };
return createHash("sha256").update(JSON.stringify(stableValue(comparable))).digest("hex");
}
function safeId(value: string): string {
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new GitStorageError("Todo instance id is not safe for Git storage", { instanceId: value });
return value;
}
export class TodoGitStorage {
private queue: Promise<unknown> = Promise.resolve();
constructor(readonly config: TodoGitStorageConfig) {
if (config.primary === "github-repo" && !config.repoSshUrl) throw new Error("TODO_NOTE_GIT_REPO_SSH_URL is required for github-repo storage");
}
enabled(): boolean {
return this.config.primary === "github-repo";
}
summary(): Record<string, unknown> {
return {
primary: this.config.primary,
repo: this.config.repo,
branch: this.config.branch,
basePath: this.config.basePath,
worktreePath: this.config.worktreePath,
sshConfigured: this.config.sshCommand.length > 0,
head: this.headSummary(),
};
}
async readSnapshot(): Promise<TodoSnapshot | null> {
if (!this.enabled()) return null;
return await this.enqueue(async () => {
this.syncWorktree();
const indexPath = this.path("indexes", "instances.json");
if (!existsSync(indexPath)) return null;
const index = JSON.parse(readFileSync(indexPath, "utf8")) as { schemaVersion?: unknown; instances?: unknown; reminderNotificationsPath?: unknown };
if (index.schemaVersion !== 1 || !Array.isArray(index.instances)) throw new GitStorageError("Todo Git index has an unsupported schema");
const instances = index.instances.map((raw) => {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new GitStorageError("Todo Git index entry is invalid");
const entry = raw as { id?: unknown; instancePath?: unknown; historyPath?: unknown; source?: unknown };
if (typeof entry.id !== "string" || typeof entry.instancePath !== "string" || typeof entry.historyPath !== "string") {
throw new GitStorageError("Todo Git index entry is missing paths");
}
const id = safeId(entry.id);
const instancePath = join(this.config.basePath, "instances", `${id}.json`);
const historyPath = join(this.config.basePath, "history", `${id}.json`);
if (entry.instancePath !== instancePath || entry.historyPath !== historyPath) {
throw new GitStorageError("Todo Git index entry points outside its managed instance paths", {
instanceId: id,
instancePath: entry.instancePath,
historyPath: entry.historyPath,
});
}
const instance = JSON.parse(readFileSync(join(this.config.worktreePath, instancePath), "utf8"));
const historyFile = JSON.parse(readFileSync(join(this.config.worktreePath, historyPath), "utf8")) as { history?: unknown };
return { instance, history: historyFile.history, source: entry.source };
});
const reminderNotificationsPath = join(this.config.basePath, "reminders", "notifications.json");
let reminderNotifications: unknown[] = [];
if (index.reminderNotificationsPath !== undefined) {
if (index.reminderNotificationsPath !== reminderNotificationsPath) {
throw new GitStorageError("Todo Git index points outside its managed reminder notification path", { reminderNotificationsPath: index.reminderNotificationsPath });
}
const reminderFile = JSON.parse(readFileSync(join(this.config.worktreePath, reminderNotificationsPath), "utf8")) as { notifications?: unknown };
if (!Array.isArray(reminderFile.notifications)) throw new GitStorageError("Todo Git reminder notification file is invalid");
reminderNotifications = reminderFile.notifications;
}
return normalizeTodoSnapshot({
schemaVersion: 1,
generatedAt: typeof (index as { generatedAt?: unknown }).generatedAt === "string" ? (index as { generatedAt: string }).generatedAt : new Date().toISOString(),
instances,
reminderNotifications,
});
});
}
async writeSnapshot(snapshot: TodoSnapshot, reason: string): Promise<Record<string, unknown>> {
if (!this.enabled()) return { ok: true, changed: false, primary: "postgres" };
const normalized = normalizeTodoSnapshot(snapshot);
return await this.enqueue(async () => {
this.syncWorktree();
const root = this.path();
rmSync(root, { recursive: true, force: true });
mkdirSync(root, { recursive: true });
const indexEntries = normalized.instances.map((entry) => {
const id = safeId(entry.instance.id);
const instancePath = join(this.config.basePath, "instances", `${id}.json`);
const historyPath = join(this.config.basePath, "history", `${id}.json`);
this.writeJson(instancePath, entry.instance);
this.writeJson(historyPath, { schemaVersion: 1, instanceId: id, history: entry.history });
return {
id,
name: entry.instance.name,
updatedAt: entry.instance.updatedAt,
historyPointer: entry.instance.historyPointer,
instancePath,
historyPath,
source: entry.source,
};
});
this.writeJson(join(this.config.basePath, ".todo-note", "schema.json"), {
schemaVersion: 1,
generatedBy: "unidesk-todo-note",
});
const reminderNotificationsPath = join(this.config.basePath, "reminders", "notifications.json");
this.writeJson(reminderNotificationsPath, {
schemaVersion: 1,
notifications: normalized.reminderNotifications,
});
this.writeJson(join(this.config.basePath, "indexes", "instances.json"), {
schemaVersion: 1,
generatedAt: normalized.generatedAt,
hash: snapshotHash(normalized),
reminderNotificationsPath,
instances: indexEntries,
});
this.runGit(["add", "--all", "--", this.config.basePath]);
const diff = this.runGit(["diff", "--cached", "--quiet"], { allowFailure: true });
if (diff.exitCode === 0) return { ok: true, changed: false, hash: snapshotHash(normalized), head: this.headSummary() };
const commit = this.runGit(["commit", "-m", `${this.config.commitMessagePrefix} ${reason}`], {
env: {
GIT_AUTHOR_NAME: this.config.authorName,
GIT_AUTHOR_EMAIL: this.config.authorEmail,
GIT_COMMITTER_NAME: this.config.authorName,
GIT_COMMITTER_EMAIL: this.config.authorEmail,
},
});
if (!commit.ok) throw new GitStorageError("Todo Git commit failed", { stderr: commit.stderr.slice(-2000) });
for (let attempt = 1; attempt <= 3; attempt += 1) {
const pushed = this.runGit(["push", "origin", this.config.branch], { allowFailure: true });
if (pushed.ok) return { ok: true, changed: true, hash: snapshotHash(normalized), attempts: attempt, head: this.headSummary() };
const fetched = this.runGit(["fetch", "origin", this.config.branch], { allowFailure: true });
if (!fetched.ok) continue;
const rebased = this.runGit(["rebase", `origin/${this.config.branch}`], { allowFailure: true });
if (!rebased.ok) {
this.runGit(["rebase", "--abort"], { allowFailure: true });
throw new GitStorageError("Todo Git rebase failed", { attempt, stderr: rebased.stderr.slice(-2000) });
}
}
throw new GitStorageError("Todo Git push failed after retries", { branch: this.config.branch });
});
}
async verify(databaseSnapshot: TodoSnapshot): Promise<Record<string, unknown>> {
if (!this.enabled()) return { ok: true, primary: "postgres", databaseHash: snapshotHash(databaseSnapshot), gitHash: null };
const gitSnapshot = await this.readSnapshot();
const databaseHash = snapshotHash(normalizeTodoSnapshot(databaseSnapshot));
const gitHash = gitSnapshot === null ? null : snapshotHash(gitSnapshot);
return {
ok: gitHash === databaseHash,
primary: "github-repo",
instances: databaseSnapshot.instances.length,
reminderNotifications: databaseSnapshot.reminderNotifications.length,
databaseHash,
gitHash,
head: this.headSummary(),
};
}
private async enqueue<T>(operation: () => Promise<T>): Promise<T> {
const next = this.queue.then(operation, operation);
this.queue = next.catch(() => undefined);
return await next;
}
private syncWorktree(): void {
mkdirSync(dirname(this.config.worktreePath), { recursive: true });
if (!existsSync(join(this.config.worktreePath, ".git"))) {
if (existsSync(this.config.worktreePath)) rmSync(this.config.worktreePath, { recursive: true, force: true });
this.runGit(["clone", this.config.repoSshUrl, this.config.worktreePath], { cwd: dirname(this.config.worktreePath) });
}
this.runGit(["remote", "set-url", "origin", this.config.repoSshUrl]);
const status = this.runGit(["status", "--porcelain"]);
if (status.stdout.trim()) throw new GitStorageError("Todo Git worktree contains uncommitted changes", { paths: status.stdout.trim().split("\n").slice(0, 20) });
const fetched = this.runGit(["fetch", "origin", this.config.branch], { allowFailure: true });
const localBranch = this.runGit(["show-ref", "--verify", `refs/heads/${this.config.branch}`], { allowFailure: true });
if (fetched.ok) {
if (localBranch.ok) {
this.runGit(["checkout", this.config.branch]);
this.runGit(["rebase", `origin/${this.config.branch}`]);
} else {
this.runGit(["checkout", "-b", this.config.branch, `origin/${this.config.branch}`]);
}
return;
}
const head = this.runGit(["rev-parse", "--verify", "HEAD"], { allowFailure: true });
if (localBranch.ok) this.runGit(["checkout", this.config.branch]);
else if (head.ok) this.runGit(["checkout", "-b", this.config.branch]);
else this.runGit(["symbolic-ref", "HEAD", `refs/heads/${this.config.branch}`]);
}
private runGit(args: string[], options: { cwd?: string; allowFailure?: boolean; env?: Record<string, string> } = {}): GitResult {
const result = spawnSync("git", args, {
cwd: options.cwd ?? this.config.worktreePath,
env: {
...process.env,
...(this.config.sshCommand ? { GIT_SSH_COMMAND: this.config.sshCommand } : {}),
...options.env,
},
encoding: "utf8",
maxBuffer: 8 * 1024 * 1024,
});
const exitCode = result.status ?? 1;
const output = {
ok: exitCode === 0,
stdout: typeof result.stdout === "string" ? result.stdout : "",
stderr: typeof result.stderr === "string" ? result.stderr : "",
exitCode,
};
if (!output.ok && options.allowFailure !== true) {
throw new GitStorageError("Todo Git command failed", {
command: ["git", ...args].join(" "),
exitCode,
stderr: output.stderr.slice(-2000),
});
}
return output;
}
private path(...parts: string[]): string {
return join(this.config.worktreePath, this.config.basePath, ...parts);
}
private writeJson(relativePath: string, value: unknown): void {
const target = join(this.config.worktreePath, relativePath);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
private headSummary(): Record<string, unknown> {
if (!existsSync(join(this.config.worktreePath, ".git"))) return { present: false };
const commit = this.runGit(["rev-parse", "HEAD"], { allowFailure: true });
const branch = this.runGit(["rev-parse", "--abbrev-ref", "HEAD"], { allowFailure: true });
return {
present: commit.ok,
branch: branch.stdout.trim(),
commit: commit.stdout.trim().slice(0, 40),
shortCommit: commit.stdout.trim().slice(0, 12),
};
}
}
@@ -0,0 +1,403 @@
import express from "express";
import { gunzipSync } from "node:zlib";
import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../../shared/src/rotating-jsonl";
import { GitStorageError, TodoGitStorage, gitStorageConfigFromEnv, snapshotHash } from "./git-storage";
import { loadReminderNotifierConfig, TodoReminderNotifier } from "./reminder-notifier";
import { PostgresTodoRepository, normalizeTodoSnapshot, type TodoReminderNotification, type TodoSnapshot } from "./repository";
import type { TodoAction } from "./types";
type LogLevel = "info" | "warn" | "error";
class HttpError extends Error {
constructor(readonly status: number, message: string, readonly detail: Record<string, unknown> = {}) {
super(message);
this.name = "HttpError";
}
}
function envString(name: string, fallback: string): string {
const value = process.env[name];
return value === undefined || value.length === 0 ? fallback : value;
}
function envPort(name: string, fallback: number): number {
const value = Number(process.env[name] || fallback);
if (!Number.isInteger(value) || value < 1 || value > 65535) throw new Error(`${name} must be a valid TCP port`);
return value;
}
function errorDetail(error: unknown): Record<string, unknown> {
if (error instanceof GitStorageError) return { name: error.name, message: error.message, ...error.detail };
if (error instanceof HttpError) return { name: error.name, message: error.message, ...error.detail };
if (error instanceof Error) return { name: error.name, message: error.message };
return { name: "Error", message: String(error) };
}
const host = envString("HOST", "0.0.0.0");
const port = envPort("PORT", 4288);
const isCheckMode = process.execArgv.includes("--check");
const databaseUrl = envString("DATABASE_URL", isCheckMode ? "postgres://syntax-check@127.0.0.1:1/syntax-check" : "");
if (!databaseUrl) throw new Error("DATABASE_URL is required");
const logFile = envString("LOG_FILE", "/var/log/unidesk/todo-note.jsonl");
const repository = new PostgresTodoRepository(databaseUrl);
const gitStorage = new TodoGitStorage(gitStorageConfigFromEnv());
const startedAt = new Date().toISOString();
const recentLogs: Record<string, unknown>[] = [];
const logWriter = createHourlyJsonlWriter({
baseLogFile: logFile,
service: "todo-note",
maxBytes: logRetentionBytesForService("todo-note"),
});
let storageState: Record<string, unknown> = { ok: false, initialized: false };
let mutationQueue: Promise<unknown> = Promise.resolve();
function log(level: LogLevel, event: string, detail: Record<string, unknown> = {}): void {
const record = { at: new Date().toISOString(), service: "todo-note", level, event, ...detail };
recentLogs.push(record);
while (recentLogs.length > 300) recentLogs.shift();
process.stdout.write(`${JSON.stringify(record)}\n`);
logWriter.appendJson(record);
}
function deployInfo(): Record<string, unknown> {
return {
serviceId: envString("UNIDESK_DEPLOY_SERVICE_ID", "todo-note"),
repo: envString("UNIDESK_DEPLOY_REPO", ""),
commit: envString("UNIDESK_DEPLOY_COMMIT", ""),
requestedCommit: envString("UNIDESK_DEPLOY_REQUESTED_COMMIT", ""),
};
}
async function refreshStorageState(): Promise<Record<string, unknown>> {
const snapshot = await repository.exportSnapshot();
const verification = await gitStorage.verify(snapshot);
storageState = {
ok: verification.ok === true,
initialized: true,
checkedAt: new Date().toISOString(),
config: gitStorage.summary(),
verification,
};
return storageState;
}
async function initializeStorage(): Promise<void> {
await repository.ensureReady();
if (!gitStorage.enabled()) {
storageState = {
ok: true,
initialized: true,
checkedAt: new Date().toISOString(),
config: gitStorage.summary(),
};
return;
}
const databaseSnapshot = await repository.exportSnapshot();
const gitSnapshot = await gitStorage.readSnapshot();
if (gitSnapshot === null) {
const result = await gitStorage.writeSnapshot(databaseSnapshot, "initialize storage");
log("info", "github_storage_initialized", { instances: databaseSnapshot.instances.length, result });
} else {
const mergedSnapshot = {
...gitSnapshot,
generatedAt: new Date().toISOString(),
reminderNotifications: mergeReminderNotifications(gitSnapshot.reminderNotifications, databaseSnapshot.reminderNotifications),
};
await repository.replaceSnapshot(mergedSnapshot);
if (snapshotHash(mergedSnapshot) !== snapshotHash(gitSnapshot)) await gitStorage.writeSnapshot(mergedSnapshot, "merge reminder notifications");
log("info", "github_storage_hydrated", {
instances: mergedSnapshot.instances.length,
reminderNotifications: mergedSnapshot.reminderNotifications.length,
hash: snapshotHash(mergedSnapshot),
});
}
await refreshStorageState();
}
function mergeReminderNotifications(gitValues: TodoReminderNotification[], databaseValues: TodoReminderNotification[]): TodoReminderNotification[] {
const merged = new Map<string, TodoReminderNotification>();
for (const value of [...gitValues, ...databaseValues]) merged.set(`${value.instanceId}\0${value.todoId}\0${value.reminderAt}`, value);
return [...merged.values()].sort((left, right) => left.sentAt.localeCompare(right.sentAt));
}
async function enqueueMutation<T>(operation: () => Promise<T>): Promise<T> {
const next = mutationQueue.then(operation, operation);
mutationQueue = next.catch(() => undefined);
return await next;
}
function recordSuccessfulWrite(snapshot: TodoSnapshot, persisted: Record<string, unknown>): void {
const hash = snapshotHash(snapshot);
storageState = {
ok: true,
initialized: true,
checkedAt: new Date().toISOString(),
config: gitStorage.summary(),
verification: {
ok: true,
databaseHash: hash,
gitHash: gitStorage.enabled() ? hash : null,
},
lastWrite: persisted,
};
}
async function mutate<T>(reason: string, operation: () => Promise<T>): Promise<T> {
return await enqueueMutation(async () => {
if (!gitStorage.enabled()) return await operation();
const before = await repository.exportSnapshot();
const result = await operation();
try {
const after = await repository.exportSnapshot();
const persisted = await gitStorage.writeSnapshot(after, reason);
recordSuccessfulWrite(after, persisted);
return result;
} catch (error) {
try {
await repository.replaceSnapshot(before, {}, { preserveReminderNotifications: true });
} catch (rollbackError) {
log("error", "database_rollback_failed", { reason, error: errorDetail(rollbackError) });
}
storageState = {
ok: false,
initialized: true,
checkedAt: new Date().toISOString(),
config: gitStorage.summary(),
error: errorDetail(error),
};
throw error;
}
});
}
async function replaceFromImport(snapshotValue: unknown): Promise<Record<string, unknown>> {
const snapshot = normalizeTodoSnapshot(snapshotValue);
return await enqueueMutation(async () => {
const before = await repository.exportSnapshot();
let imported: TodoSnapshot;
let persisted: Record<string, unknown>;
try {
imported = await repository.replaceSnapshot(snapshot);
persisted = await gitStorage.writeSnapshot(imported, "migrate from CC01");
} catch (error) {
try {
await repository.replaceSnapshot(before);
} catch (rollbackError) {
log("error", "database_rollback_failed", { reason: "migrate from CC01", error: errorDetail(rollbackError) });
}
throw error;
}
recordSuccessfulWrite(imported, persisted);
return {
ok: true,
instances: imported.instances.length,
historyEntries: imported.instances.reduce((total, entry) => total + entry.history.length, 0),
reminderNotifications: imported.reminderNotifications.length,
hash: snapshotHash(imported),
persisted,
};
});
}
function importSnapshotValue(body: unknown): unknown {
if (typeof body !== "object" || body === null || Array.isArray(body)) throw new HttpError(400, "storage import body must be an object");
const record = body as Record<string, unknown>;
const hasSnapshot = record.snapshot !== undefined;
const hasCompressed = record.snapshotGzipBase64 !== undefined;
if (hasSnapshot === hasCompressed) throw new HttpError(400, "storage import requires exactly one of snapshot or snapshotGzipBase64");
if (hasSnapshot) return record.snapshot;
if (typeof record.snapshotGzipBase64 !== "string" || record.snapshotGzipBase64.length === 0) {
throw new HttpError(400, "snapshotGzipBase64 must be a non-empty string");
}
if (record.snapshotGzipBase64.length > 2 * 1024 * 1024) throw new HttpError(413, "compressed storage import is too large");
try {
const compressed = Buffer.from(record.snapshotGzipBase64, "base64");
if (compressed.length > 1536 * 1024) throw new HttpError(413, "compressed storage import is too large");
const decoded = gunzipSync(compressed, { maxOutputLength: 20 * 1024 * 1024 });
return JSON.parse(decoded.toString("utf8")) as unknown;
} catch (error) {
if (error instanceof HttpError) throw error;
throw new HttpError(400, "snapshotGzipBase64 must contain valid gzip-compressed JSON");
}
}
function healthPayload(): Record<string, unknown> {
const ok = storageState.ok === true;
return {
ok,
service: "todo-note",
status: ok ? "ready" : "not-ready",
startedAt,
storage: storageState,
deploy: deployInfo(),
};
}
async function main(): Promise<void> {
await initializeStorage();
const reminderNotifier = new TodoReminderNotifier(repository, { log }, loadReminderNotifierConfig(), databaseUrl, async (writeNotification) => {
await enqueueMutation(async () => {
await writeNotification();
const snapshot = await repository.exportSnapshot();
try {
const persisted = await gitStorage.writeSnapshot(snapshot, "record reminder notification");
recordSuccessfulWrite(snapshot, persisted);
} catch (error) {
storageState = { ok: false, initialized: true, checkedAt: new Date().toISOString(), config: gitStorage.summary(), error: errorDetail(error) };
throw error;
}
});
});
reminderNotifier.start();
logWriter.prune();
const app = express();
app.use(express.json({ limit: "20mb" }));
app.use((request, _response, next) => {
log("info", "http_request", { method: request.method, path: request.path });
next();
});
app.get("/live", (_request, response) => response.json({ ok: true, service: "todo-note", startedAt }));
app.get(["/health", "/api/health"], async (_request, response, next) => {
try {
await repository.ping();
response.status(storageState.ok === true ? 200 : 503).json({ ...healthPayload(), reminders: reminderNotifier.status() });
} catch (error) {
next(error);
}
});
app.get(["/logs", "/api/logs"], (_request, response) => {
response.type("application/x-ndjson").send(`${recentLogs.map((entry) => JSON.stringify(entry)).join("\n")}\n`);
});
app.get("/api/reminders/status", (_request, response) => response.json({ ok: true, reminders: reminderNotifier.status() }));
app.post("/api/reminders/scan", async (_request, response, next) => {
try {
response.json({ ok: true, reminders: await reminderNotifier.scanNow() });
} catch (error) {
next(error);
}
});
app.get("/api/storage/status", (_request, response) => response.json(storageState));
app.get("/api/storage/verify", async (_request, response, next) => {
try {
const state = await enqueueMutation(refreshStorageState);
response.status(state.ok === true ? 200 : 409).json(state);
} catch (error) {
next(error);
}
});
app.post("/api/storage/migrate", async (request, response, next) => {
try {
const result = await enqueueMutation(async () => {
const snapshot = await repository.exportSnapshot();
if (request.body?.dryRun === true) return {
ok: true,
dryRun: true,
instances: snapshot.instances.length,
historyEntries: snapshot.instances.reduce((total, entry) => total + entry.history.length, 0),
reminderNotifications: snapshot.reminderNotifications.length,
hash: snapshotHash(snapshot),
};
if (request.body?.confirm !== true) throw new HttpError(400, "storage migration requires confirm=true");
const persisted = await gitStorage.writeSnapshot(snapshot, "migrate runtime index");
recordSuccessfulWrite(snapshot, persisted);
return { ok: true, dryRun: false, result: persisted };
});
response.json(result);
} catch (error) {
next(error);
}
});
app.post("/api/storage/import", async (request, response, next) => {
try {
if (request.body?.confirm !== true) throw new HttpError(400, "storage import requires confirm=true");
response.json(await replaceFromImport(importSnapshotValue(request.body)));
} catch (error) {
next(error);
}
});
app.get("/api/instances", async (_request, response, next) => {
try {
response.json(await repository.listInstances());
} catch (error) {
next(error);
}
});
app.post("/api/instances", async (request, response, next) => {
try {
const name = String(request.body?.name ?? "").trim() || "New List";
response.status(201).json(await mutate(`create ${name}`, () => repository.createInstance(name)));
} catch (error) {
next(error);
}
});
app.get("/api/instances/:instanceId", async (request, response, next) => {
try {
response.json(await repository.getInstance(request.params.instanceId));
} catch (error) {
next(error);
}
});
app.delete("/api/instances/:instanceId", async (request, response, next) => {
try {
await mutate(`delete ${request.params.instanceId}`, () => repository.deleteInstance(request.params.instanceId));
response.json({ ok: true });
} catch (error) {
next(error);
}
});
app.post("/api/instances/:instanceId/actions", async (request, response, next) => {
try {
const action = request.body?.action as TodoAction;
if (!action || typeof action.type !== "string") throw new HttpError(400, "action.type is required");
response.json(await mutate(`${action.type} ${request.params.instanceId}`, () => repository.applyAction(request.params.instanceId, action)));
} catch (error) {
next(error);
}
});
app.post("/api/instances/:instanceId/undo", async (request, response, next) => {
try {
response.json(await mutate(`undo ${request.params.instanceId}`, () => repository.undo(request.params.instanceId)));
} catch (error) {
next(error);
}
});
app.post("/api/instances/:instanceId/redo", async (request, response, next) => {
try {
response.json(await mutate(`redo ${request.params.instanceId}`, () => repository.redo(request.params.instanceId)));
} catch (error) {
next(error);
}
});
app.use((_request, response) => response.status(404).json({ ok: false, error: "route not found" }));
app.use((error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
const detail = errorDetail(error);
const message = String(detail.message ?? "request failed");
const status = error instanceof HttpError ? error.status : error instanceof GitStorageError ? 503 : /not found/iu.test(message) ? 404 : 500;
log("error", "request_failed", { status, error: detail });
response.status(status).json({ ok: false, error: message, detail });
});
const server = app.listen(port, host, () => {
log("info", "server_started", { host, port, storage: gitStorage.summary() });
});
const shutdown = async (signal: string): Promise<void> => {
log("info", "server_stopping", { signal });
server.close();
await reminderNotifier.stop();
await repository.close();
process.exit(0);
};
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
}
if (import.meta.main && !isCheckMode) {
main().catch((error) => {
log("error", "server_start_failed", { error: errorDetail(error) });
process.exitCode = 1;
});
}
@@ -0,0 +1,375 @@
import postgres from "postgres";
import type { Registry, TodoInstance, TodoItem } from "./types";
interface ReminderRepository {
listInstances(): Promise<Registry>;
getInstance(instanceId: string): Promise<TodoInstance>;
}
interface ReminderLogger {
log(level: "info" | "warn" | "error", event: string, detail?: Record<string, unknown>): void;
}
type ReminderTargetType = "private" | "group";
export interface ReminderNotifierConfig {
enabled: boolean;
baseUrl: string;
targetType: ReminderTargetType;
userId: string;
groupId: string;
leadMs: number;
scanIntervalMs: number;
timeoutMs: number;
sendAttempts: number;
}
interface ReminderCandidate {
instanceId: string;
instanceName: string;
todoId: string;
title: string;
titlePath: string[];
reminderAt: string;
reminderAtMs: number;
}
interface ReminderStatus {
enabled: boolean;
target: string;
baseUrl: string;
leadMinutes: number;
scanIntervalMs: number;
timeoutMs: number;
sendAttempts: number;
running: boolean;
lastScanAt: string | null;
nextScanAt: string | null;
lastError: string | null;
sentCount: number;
inFlightCount: number;
}
function envValue(name: string): string {
const value = process.env[name];
if (value === undefined) throw new Error(`${name} is required`);
return value.trim();
}
function envBoolean(name: string): boolean {
const value = envValue(name).toLowerCase();
if (value === "true") return true;
if (value === "false") return false;
throw new Error(`${name} must be true or false`);
}
function envInteger(name: string, min: number, max: number): number {
const value = Number(envValue(name));
if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${name} must be an integer from ${min} to ${max}`);
return value;
}
function nowIso(): string {
return new Date().toISOString();
}
function safePreview(value: string, maxChars: number): string {
return value.length <= maxChars ? value : `${value.slice(0, maxChars)}...`;
}
function errorDetail(error: unknown): Record<string, unknown> {
if (error instanceof Error) return { name: error.name, message: error.message };
return { name: "Error", message: String(error) };
}
function targetLabel(config: ReminderNotifierConfig): string {
return config.targetType === "group" ? `group:${config.groupId || "-"}` : `private:${config.userId || "-"}`;
}
function targetConfigured(config: ReminderNotifierConfig): boolean {
if (!config.enabled || !config.baseUrl) return false;
return config.targetType === "group" ? config.groupId.length > 0 : config.userId.length > 0;
}
function collectReminderCandidates(instance: TodoInstance, nowMs: number, leadMs: number): ReminderCandidate[] {
const candidates: ReminderCandidate[] = [];
const visit = (todo: TodoItem, titlePath: string[]): void => {
const nextPath = [...titlePath, todo.title || "Untitled"];
if (!todo.completed && todo.reminderAt) {
const reminderAtMs = Date.parse(todo.reminderAt);
if (Number.isFinite(reminderAtMs) && nowMs >= reminderAtMs - leadMs && nowMs <= reminderAtMs) {
candidates.push({
instanceId: instance.id,
instanceName: instance.name,
todoId: todo.id,
title: todo.title || "Untitled",
titlePath: nextPath,
reminderAt: todo.reminderAt,
reminderAtMs,
});
}
}
for (const child of todo.children) visit(child, nextPath);
};
for (const todo of instance.todos) visit(todo, []);
return candidates;
}
function messageForCandidate(candidate: ReminderCandidate, nowMs: number, leadMs: number): string {
const remainingMinutes = Math.max(0, Math.ceil((candidate.reminderAtMs - nowMs) / 60_000));
return [
"Todo Note 时间提醒",
`清单: ${candidate.instanceName}`,
`任务: ${candidate.titlePath.join(" / ")}`,
`提醒时间: ${new Date(candidate.reminderAtMs).toISOString().replace("T", " ").replace(/\.\d{3}Z$/u, " UTC")}`,
`剩余: ${remainingMinutes} 分钟`,
`默认提前: ${Math.ceil(leadMs / 60_000)} 分钟`,
].join("\n");
}
function claudeQqPayload(config: ReminderNotifierConfig, message: string): Record<string, string> {
return config.targetType === "group"
? { targetType: "group", groupId: config.groupId, message }
: { targetType: "private", userId: config.userId, message };
}
async function delay(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
class ReminderNotificationStore {
private readonly memorySent = new Set<string>();
private readonly sql: postgres.Sql;
private ready = false;
constructor(databaseUrl: string) {
this.sql = postgres(databaseUrl, { max: 2, idle_timeout: 20, connect_timeout: 10, connection: { application_name: "unidesk-todo-note-reminders" } });
}
async ensureReady(): Promise<void> {
if (this.ready) return;
await this.sql`
CREATE TABLE IF NOT EXISTS todo_note_reminder_notifications (
id BIGSERIAL PRIMARY KEY,
instance_id TEXT NOT NULL,
todo_id TEXT NOT NULL,
reminder_at TEXT NOT NULL,
target TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
response_preview TEXT NOT NULL DEFAULT '',
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (instance_id, todo_id, reminder_at)
)
`;
await this.sql`CREATE INDEX IF NOT EXISTS idx_todo_note_reminder_notifications_sent_at ON todo_note_reminder_notifications(sent_at DESC)`;
this.ready = true;
}
async hasSent(candidate: ReminderCandidate): Promise<boolean> {
const key = `${candidate.instanceId}:${candidate.todoId}:${candidate.reminderAt}`;
if (this.memorySent.has(key)) return true;
const rows = await this.sql<Array<{ exists: boolean }>>`
SELECT EXISTS (
SELECT 1 FROM todo_note_reminder_notifications
WHERE instance_id = ${candidate.instanceId} AND todo_id = ${candidate.todoId} AND reminder_at = ${candidate.reminderAt}
) AS exists
`;
if (Boolean(rows[0]?.exists)) this.memorySent.add(key);
return Boolean(rows[0]?.exists);
}
async markSent(candidate: ReminderCandidate, target: string, message: string, responsePreview: string): Promise<void> {
this.memorySent.add(`${candidate.instanceId}:${candidate.todoId}:${candidate.reminderAt}`);
await this.sql`
INSERT INTO todo_note_reminder_notifications (instance_id, todo_id, reminder_at, target, title, message, response_preview)
VALUES (${candidate.instanceId}, ${candidate.todoId}, ${candidate.reminderAt}, ${target}, ${candidate.title}, ${message}, ${responsePreview})
ON CONFLICT (instance_id, todo_id, reminder_at) DO NOTHING
`;
}
async close(): Promise<void> {
await this.sql.end({ timeout: 5 });
}
}
export function loadReminderNotifierConfig(): ReminderNotifierConfig {
const targetType = envValue("TODO_NOTE_REMINDER_CLAUDEQQ_TARGET_TYPE").toLowerCase();
if (targetType !== "private" && targetType !== "group") throw new Error("TODO_NOTE_REMINDER_CLAUDEQQ_TARGET_TYPE must be private or group");
const config: ReminderNotifierConfig = {
enabled: envBoolean("TODO_NOTE_REMINDER_CLAUDEQQ_ENABLED"),
baseUrl: envValue("TODO_NOTE_REMINDER_CLAUDEQQ_BASE_URL").replace(/\/+$/u, ""),
targetType,
userId: envValue("TODO_NOTE_REMINDER_CLAUDEQQ_USER_ID"),
groupId: envValue("TODO_NOTE_REMINDER_CLAUDEQQ_GROUP_ID"),
leadMs: envInteger("TODO_NOTE_REMINDER_LEAD_MINUTES", 1, 24 * 60) * 60_000,
scanIntervalMs: envInteger("TODO_NOTE_REMINDER_SCAN_INTERVAL_MS", 5_000, 10 * 60_000),
timeoutMs: envInteger("TODO_NOTE_REMINDER_CLAUDEQQ_TIMEOUT_MS", 1_000, 60_000),
sendAttempts: envInteger("TODO_NOTE_REMINDER_CLAUDEQQ_SEND_ATTEMPTS", 1, 10),
};
if (config.enabled && !targetConfigured(config)) throw new Error("Todo Note reminder target is not configured");
return config;
}
export class TodoReminderNotifier {
private readonly store: ReminderNotificationStore;
private readonly inFlight = new Set<string>();
private readonly failureRetryAfter = new Map<string, number>();
private timer: ReturnType<typeof setTimeout> | null = null;
private running = false;
private scanning = false;
private lastScanAt: string | null = null;
private nextScanAt: string | null = null;
private lastError: string | null = null;
private sentCount = 0;
constructor(
private readonly repository: ReminderRepository,
private readonly logger: ReminderLogger,
private readonly config: ReminderNotifierConfig,
databaseUrl: string,
private readonly persistNotification: (writeNotification: () => Promise<void>) => Promise<void>,
) {
this.store = new ReminderNotificationStore(databaseUrl);
}
start(): void {
if (!targetConfigured(this.config)) {
this.logger.log("info", "todo_reminder_notifier_disabled", { target: targetLabel(this.config), baseUrl: this.config.baseUrl });
return;
}
this.running = true;
this.logger.log("info", "todo_reminder_notifier_started", this.status());
this.schedule(1_000);
}
async stop(): Promise<void> {
this.running = false;
if (this.timer !== null) clearTimeout(this.timer);
this.timer = null;
this.nextScanAt = null;
await this.store.close();
}
status(): ReminderStatus {
return {
enabled: targetConfigured(this.config),
target: targetLabel(this.config),
baseUrl: this.config.baseUrl,
leadMinutes: this.config.leadMs / 60_000,
scanIntervalMs: this.config.scanIntervalMs,
timeoutMs: this.config.timeoutMs,
sendAttempts: this.config.sendAttempts,
running: this.running,
lastScanAt: this.lastScanAt,
nextScanAt: this.nextScanAt,
lastError: this.lastError,
sentCount: this.sentCount,
inFlightCount: this.inFlight.size,
};
}
async scanNow(): Promise<ReminderStatus> {
if (targetConfigured(this.config)) await this.scanOnce();
return this.status();
}
private schedule(delayMs: number): void {
if (!this.running) return;
this.nextScanAt = new Date(Date.now() + delayMs).toISOString();
this.timer = setTimeout(() => void this.tick(), delayMs);
this.timer.unref?.();
}
private async tick(): Promise<void> {
try {
await this.scanOnce();
} finally {
this.schedule(this.config.scanIntervalMs);
}
}
private async scanOnce(): Promise<void> {
if (this.scanning) return;
this.scanning = true;
const nowMs = Date.now();
this.lastScanAt = nowIso();
this.nextScanAt = null;
try {
await this.store.ensureReady();
const registry = await this.repository.listInstances();
let candidates = 0;
for (const summary of registry.instances) {
const instance = await this.repository.getInstance(summary.id);
for (const candidate of collectReminderCandidates(instance, nowMs, this.config.leadMs)) {
candidates += 1;
await this.maybeSend(candidate, nowMs);
}
}
this.lastError = null;
this.logger.log("info", "todo_reminder_scan_complete", { checked: registry.instances.length, candidates, sentCount: this.sentCount });
} catch (error) {
this.lastError = error instanceof Error ? error.message : String(error);
this.logger.log("warn", "todo_reminder_scan_failed", errorDetail(error));
} finally {
this.scanning = false;
}
}
private async maybeSend(candidate: ReminderCandidate, nowMs: number): Promise<void> {
const key = `${candidate.instanceId}:${candidate.todoId}:${candidate.reminderAt}`;
if (this.inFlight.has(key) || (this.failureRetryAfter.get(key) ?? 0) > nowMs || await this.store.hasSent(candidate)) return;
this.inFlight.add(key);
try {
const message = messageForCandidate(candidate, nowMs, this.config.leadMs);
const responsePreview = await this.postClaudeQqText(message);
try {
await this.persistNotification(() => this.store.markSent(candidate, targetLabel(this.config), message, responsePreview));
} catch (error) {
this.logger.log("warn", "todo_reminder_persist_failed", { instanceId: candidate.instanceId, todoId: candidate.todoId, reminderAt: candidate.reminderAt, error: errorDetail(error) });
return;
}
this.sentCount += 1;
this.failureRetryAfter.delete(key);
this.logger.log("info", "todo_reminder_sent", { instanceId: candidate.instanceId, todoId: candidate.todoId, reminderAt: candidate.reminderAt, target: targetLabel(this.config) });
} catch (error) {
this.failureRetryAfter.set(key, Date.now() + Math.min(60_000, this.config.scanIntervalMs * 2));
this.logger.log("warn", "todo_reminder_send_failed", { instanceId: candidate.instanceId, todoId: candidate.todoId, reminderAt: candidate.reminderAt, error: errorDetail(error) });
} finally {
this.inFlight.delete(key);
}
}
private async postClaudeQqText(message: string): Promise<string> {
let lastError: unknown = null;
for (let attempt = 1; attempt <= this.config.sendAttempts; attempt += 1) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
try {
const response = await fetch(`${this.config.baseUrl}/api/push/text`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(claudeQqPayload(this.config, message)),
signal: controller.signal,
});
const responseText = await response.text();
const responsePreview = safePreview(responseText, 500);
if (!response.ok) throw new Error(`ClaudeQQ proxy returned HTTP ${response.status}: ${responsePreview}`);
try {
const parsed = JSON.parse(responseText) as Record<string, unknown>;
if (parsed.ok === false || parsed.success === false || parsed.status === "napcat_offline") throw new Error(`ClaudeQQ push failed: ${responsePreview}`);
} catch (error) {
if (!(error instanceof SyntaxError)) throw error;
}
return responsePreview;
} catch (error) {
lastError = error;
if (attempt < this.config.sendAttempts) await delay(Math.min(30_000, 1_000 * (2 ** (attempt - 1))));
} finally {
clearTimeout(timer);
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "ClaudeQQ push failed"));
}
}
@@ -0,0 +1,451 @@
import { readFile } from "node:fs/promises";
import postgres from "postgres";
import { applyInstanceAction, cloneInstance, createInstance, summarizeInstance } from "./domain";
import type { HistoryEntry, Registry, TodoAction, TodoInstance } from "./types";
export interface TodoSnapshotEntry {
instance: TodoInstance;
history: HistoryEntry[];
source: Record<string, unknown>;
}
export interface TodoReminderNotification {
instanceId: string;
todoId: string;
reminderAt: string;
target: string;
title: string;
message: string;
responsePreview: string;
sentAt: string;
}
export interface TodoSnapshot {
schemaVersion: 1;
generatedAt: string;
instances: TodoSnapshotEntry[];
reminderNotifications: TodoReminderNotification[];
}
function nowIso(): string {
return new Date().toISOString();
}
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function storageBasePath(): string {
return (process.env.TODO_NOTE_GIT_BASE_PATH || "todo-note").replace(/^\/+|\/+$/gu, "");
}
function storageRepo(): string {
return process.env.TODO_NOTE_GIT_REPO || "pikasTech/decision-center-data";
}
function instancePath(instanceId: string): string {
if (process.env.TODO_NOTE_STORAGE_PRIMARY === "github-repo") {
return `github://${storageRepo()}/${storageBasePath()}/instances/${instanceId}.json`;
}
return `pg://todo-note/instances/${instanceId}`;
}
function historyPath(instanceId: string): string {
if (process.env.TODO_NOTE_STORAGE_PRIMARY === "github-repo") {
return `github://${storageRepo()}/${storageBasePath()}/history/${instanceId}.json`;
}
return `pg://todo-note/history/${instanceId}`;
}
function normalizeInstance(value: unknown): TodoInstance {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("Todo instance is not an object");
}
const record = value as Partial<TodoInstance>;
if (typeof record.id !== "string" || !record.id || typeof record.name !== "string" || !Array.isArray(record.todos)) {
throw new Error("Todo instance is missing required fields");
}
return {
id: record.id,
name: record.name,
filePath: instancePath(record.id),
historyPath: historyPath(record.id),
createdAt: typeof record.createdAt === "string" ? record.createdAt : nowIso(),
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : nowIso(),
historyPointer: typeof record.historyPointer === "number" && Number.isInteger(record.historyPointer) ? record.historyPointer : 0,
todos: record.todos,
};
}
function normalizeHistoryEntry(value: unknown): HistoryEntry {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Todo history entry is not an object");
const record = value as Partial<HistoryEntry>;
if (!Number.isInteger(record.sequence) || typeof record.timestamp !== "string" || (record.kind !== "snapshot" && record.kind !== "command") || typeof record.action !== "string") {
throw new Error("Todo history entry is missing required fields");
}
return {
sequence: Number(record.sequence),
timestamp: record.timestamp,
kind: record.kind,
action: record.action,
...(record.snapshot === undefined ? {} : { snapshot: normalizeInstance(record.snapshot) }),
...(record.detail === undefined ? {} : { detail: asRecord(record.detail) }),
};
}
function normalizeReminderNotification(value: unknown): TodoReminderNotification {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Todo reminder notification is not an object");
const record = value as Partial<TodoReminderNotification>;
for (const key of ["instanceId", "todoId", "reminderAt", "target", "title", "message", "responsePreview", "sentAt"] as const) {
if (typeof record[key] !== "string") throw new Error(`Todo reminder notification ${key} must be a string`);
}
if (!record.instanceId || !record.todoId || !record.reminderAt || !record.target || !record.sentAt) {
throw new Error("Todo reminder notification is missing required fields");
}
return record as TodoReminderNotification;
}
export function normalizeTodoSnapshot(value: unknown): TodoSnapshot {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Todo snapshot is not an object");
const record = value as Partial<TodoSnapshot>;
if (record.schemaVersion !== 1 || !Array.isArray(record.instances)) throw new Error("Todo snapshot schemaVersion must be 1");
const seen = new Set<string>();
const instances = record.instances.map((rawEntry) => {
if (typeof rawEntry !== "object" || rawEntry === null || Array.isArray(rawEntry)) throw new Error("Todo snapshot entry is not an object");
const entry = rawEntry as Partial<TodoSnapshotEntry>;
const instance = normalizeInstance(entry.instance);
if (seen.has(instance.id)) throw new Error(`Duplicate Todo instance: ${instance.id}`);
seen.add(instance.id);
return {
instance,
history: Array.isArray(entry.history) ? entry.history.map(normalizeHistoryEntry) : [],
source: asRecord(entry.source),
};
});
return {
schemaVersion: 1,
generatedAt: typeof record.generatedAt === "string" ? record.generatedAt : nowIso(),
instances,
reminderNotifications: Array.isArray(record.reminderNotifications) ? record.reminderNotifications.map(normalizeReminderNotification) : [],
};
}
export class PostgresTodoRepository {
private readonly sql: postgres.Sql;
constructor(databaseUrl: string) {
this.sql = postgres(databaseUrl, {
max: 4,
idle_timeout: 20,
connect_timeout: 10,
connection: { application_name: "unidesk-todo-note" },
});
}
async close(): Promise<void> {
await this.sql.end({ timeout: 5 });
}
async ensureReady(): Promise<void> {
await this.sql`
CREATE TABLE IF NOT EXISTS todo_note_instances (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
file_path TEXT NOT NULL,
history_path TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
history_pointer INTEGER NOT NULL DEFAULT 0,
todos JSONB NOT NULL DEFAULT '[]'::jsonb,
source JSONB NOT NULL DEFAULT '{}'::jsonb
)
`;
await this.sql`
CREATE TABLE IF NOT EXISTS todo_note_history (
id BIGSERIAL PRIMARY KEY,
instance_id TEXT NOT NULL REFERENCES todo_note_instances(id) ON DELETE CASCADE,
sequence INTEGER NOT NULL,
timestamp TEXT NOT NULL,
kind TEXT NOT NULL,
action TEXT NOT NULL,
snapshot JSONB,
detail JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`;
await this.sql`CREATE INDEX IF NOT EXISTS idx_todo_note_history_instance_sequence ON todo_note_history(instance_id, sequence)`;
await this.sql`CREATE INDEX IF NOT EXISTS idx_todo_note_instances_updated_at ON todo_note_instances(updated_at DESC)`;
await this.sql`
CREATE TABLE IF NOT EXISTS todo_note_reminder_notifications (
id BIGSERIAL PRIMARY KEY,
instance_id TEXT NOT NULL,
todo_id TEXT NOT NULL,
reminder_at TEXT NOT NULL,
target TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
response_preview TEXT NOT NULL DEFAULT '',
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (instance_id, todo_id, reminder_at)
)
`;
await this.sql`CREATE INDEX IF NOT EXISTS idx_todo_note_reminder_notifications_sent_at ON todo_note_reminder_notifications(sent_at DESC)`;
}
async ping(): Promise<void> {
await this.sql`SELECT 1`;
}
async listInstances(): Promise<Registry> {
const rows = await this.sql<Array<Record<string, unknown>>>`
SELECT id, name, file_path, history_path, created_at, updated_at, history_pointer, todos
FROM todo_note_instances
ORDER BY created_at ASC, id ASC
`;
return { instances: rows.map((row) => summarizeInstance(this.instanceFromRow(row))) };
}
async createInstance(name: string): Promise<TodoInstance> {
const createdAt = nowIso();
const instance = createInstance(name, "", "", createdAt);
instance.filePath = instancePath(instance.id);
instance.historyPath = historyPath(instance.id);
await this.sql.begin(async (tx) => {
await this.upsertInstance(tx, instance, { createdBy: "unidesk-todo-note" });
await this.insertHistory(tx, instance.id, {
sequence: 0,
timestamp: createdAt,
kind: "snapshot",
action: "create-instance",
snapshot: cloneInstance(instance),
});
});
return instance;
}
async getInstance(instanceId: string): Promise<TodoInstance> {
const rows = await this.sql<Array<Record<string, unknown>>>`
SELECT id, name, file_path, history_path, created_at, updated_at, history_pointer, todos
FROM todo_note_instances
WHERE id = ${instanceId}
LIMIT 1
`;
if (!rows[0]) throw new Error(`Instance not found: ${instanceId}`);
return this.instanceFromRow(rows[0]);
}
async deleteInstance(instanceId: string): Promise<void> {
const result = await this.sql`DELETE FROM todo_note_instances WHERE id = ${instanceId}`;
if (result.count === 0) throw new Error(`Instance not found: ${instanceId}`);
}
async applyAction(instanceId: string, action: TodoAction): Promise<TodoInstance> {
const instance = await this.getInstance(instanceId);
const updatedAt = nowIso();
const updated = applyInstanceAction(instance, action, updatedAt);
updated.historyPointer = instance.historyPointer + 1;
updated.filePath = instancePath(updated.id);
updated.historyPath = historyPath(updated.id);
await this.sql.begin(async (tx) => {
await this.upsertInstance(tx, updated, { updatedBy: "action", action: action.type });
await this.insertHistory(tx, updated.id, {
sequence: updated.historyPointer,
timestamp: updatedAt,
kind: "snapshot",
action: action.type,
snapshot: cloneInstance(updated),
});
});
return updated;
}
async undo(instanceId: string): Promise<TodoInstance> {
const instance = await this.getInstance(instanceId);
if (instance.historyPointer === 0) return instance;
const snapshot = await this.snapshotAt(instanceId, instance.historyPointer - 1);
if (!snapshot) throw new Error(`Undo snapshot missing for ${instanceId}`);
snapshot.historyPointer = instance.historyPointer - 1;
snapshot.filePath = instancePath(snapshot.id);
snapshot.historyPath = historyPath(snapshot.id);
await this.sql.begin(async (tx) => {
await this.upsertInstance(tx, snapshot, { updatedBy: "undo" });
await this.insertHistory(tx, snapshot.id, {
sequence: instance.historyPointer,
timestamp: nowIso(),
kind: "command",
action: "undo",
detail: { from: instance.historyPointer, to: snapshot.historyPointer },
});
});
return snapshot;
}
async redo(instanceId: string): Promise<TodoInstance> {
const instance = await this.getInstance(instanceId);
const snapshot = await this.snapshotAt(instanceId, instance.historyPointer + 1);
if (!snapshot) return instance;
snapshot.historyPointer = instance.historyPointer + 1;
snapshot.filePath = instancePath(snapshot.id);
snapshot.historyPath = historyPath(snapshot.id);
await this.sql.begin(async (tx) => {
await this.upsertInstance(tx, snapshot, { updatedBy: "redo" });
await this.insertHistory(tx, snapshot.id, {
sequence: snapshot.historyPointer,
timestamp: nowIso(),
kind: "command",
action: "redo",
detail: { from: instance.historyPointer, to: snapshot.historyPointer },
});
});
return snapshot;
}
async exportSnapshot(): Promise<TodoSnapshot> {
const instanceRows = await this.sql<Array<Record<string, unknown>>>`
SELECT id, name, file_path, history_path, created_at, updated_at, history_pointer, todos, source
FROM todo_note_instances
ORDER BY created_at ASC, id ASC
`;
const historyRows = await this.sql<Array<Record<string, unknown>>>`
SELECT instance_id, sequence, timestamp, kind, action, snapshot, detail
FROM todo_note_history
ORDER BY instance_id ASC, id ASC
`;
const reminderRows = await this.sql<Array<Record<string, unknown>>>`
SELECT instance_id, todo_id, reminder_at, target, title, message, response_preview, sent_at::text AS sent_at
FROM todo_note_reminder_notifications
ORDER BY id ASC
`;
const historyByInstance = new Map<string, HistoryEntry[]>();
for (const row of historyRows) {
const instanceId = String(row.instance_id);
const entries = historyByInstance.get(instanceId) ?? [];
entries.push(normalizeHistoryEntry({
sequence: Number(row.sequence),
timestamp: String(row.timestamp),
kind: row.kind,
action: row.action,
...(row.snapshot === null ? {} : { snapshot: row.snapshot }),
...(row.detail === null ? {} : { detail: row.detail }),
}));
historyByInstance.set(instanceId, entries);
}
return {
schemaVersion: 1,
generatedAt: nowIso(),
instances: instanceRows.map((row) => ({
instance: this.instanceFromRow(row),
history: historyByInstance.get(String(row.id)) ?? [],
source: asRecord(row.source),
})),
reminderNotifications: reminderRows.map((row) => normalizeReminderNotification({
instanceId: row.instance_id,
todoId: row.todo_id,
reminderAt: row.reminder_at,
target: row.target,
title: row.title,
message: row.message,
responsePreview: row.response_preview,
sentAt: row.sent_at,
})),
};
}
async replaceSnapshot(snapshot: unknown, source: Record<string, unknown> = {}, options: { preserveReminderNotifications?: boolean } = {}): Promise<TodoSnapshot> {
const normalized = normalizeTodoSnapshot(snapshot);
await this.sql.begin(async (tx) => {
await tx`DELETE FROM todo_note_history`;
await tx`DELETE FROM todo_note_instances`;
for (const entry of normalized.instances) {
await this.upsertInstance(tx, entry.instance, { ...entry.source, ...source });
for (const historyEntry of entry.history) await this.insertHistory(tx, entry.instance.id, historyEntry);
if (!entry.history.some((item) => item.kind === "snapshot" && item.sequence === entry.instance.historyPointer)) {
await this.insertHistory(tx, entry.instance.id, {
sequence: entry.instance.historyPointer,
timestamp: entry.instance.updatedAt,
kind: "snapshot",
action: "migration-latest",
snapshot: cloneInstance(entry.instance),
});
}
}
if (options.preserveReminderNotifications !== true) {
await tx`DELETE FROM todo_note_reminder_notifications`;
for (const notification of normalized.reminderNotifications) await this.insertReminderNotification(tx, notification);
}
});
return await this.exportSnapshot();
}
async readLogs(): Promise<string> {
const logPath = process.env.TODO_NOTE_LOG_PATH || process.env.LOG_FILE;
if (!logPath) return "";
try {
return await readFile(logPath, "utf8");
} catch {
return "";
}
}
private instanceFromRow(row: Record<string, unknown>): TodoInstance {
return normalizeInstance({
id: row.id,
name: row.name,
createdAt: row.created_at,
updatedAt: row.updated_at,
historyPointer: Number(row.history_pointer),
todos: row.todos,
});
}
private async snapshotAt(instanceId: string, sequence: number): Promise<TodoInstance | null> {
const rows = await this.sql<Array<{ snapshot: unknown }>>`
SELECT snapshot
FROM todo_note_history
WHERE instance_id = ${instanceId} AND sequence = ${sequence} AND kind = 'snapshot' AND snapshot IS NOT NULL
ORDER BY id DESC
LIMIT 1
`;
return rows[0]?.snapshot ? normalizeInstance(rows[0].snapshot) : null;
}
private async upsertInstance(tx: postgres.TransactionSql, instance: TodoInstance, source: Record<string, unknown>): Promise<void> {
const normalized = normalizeInstance(instance);
await tx`
INSERT INTO todo_note_instances (id, name, file_path, history_path, created_at, updated_at, history_pointer, todos, source)
VALUES (${normalized.id}, ${normalized.name}, ${normalized.filePath}, ${normalized.historyPath}, ${normalized.createdAt}, ${normalized.updatedAt}, ${normalized.historyPointer}, ${tx.json(normalized.todos as never)}, ${tx.json(source as never)})
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
file_path = EXCLUDED.file_path,
history_path = EXCLUDED.history_path,
created_at = EXCLUDED.created_at,
updated_at = EXCLUDED.updated_at,
history_pointer = EXCLUDED.history_pointer,
todos = EXCLUDED.todos,
source = EXCLUDED.source
`;
}
private async insertHistory(tx: postgres.TransactionSql, instanceId: string, entry: HistoryEntry): Promise<void> {
const normalized = normalizeHistoryEntry(entry);
await tx`
INSERT INTO todo_note_history (instance_id, sequence, timestamp, kind, action, snapshot, detail)
VALUES (${instanceId}, ${normalized.sequence}, ${normalized.timestamp}, ${normalized.kind}, ${normalized.action}, ${normalized.snapshot === undefined ? null : tx.json(normalized.snapshot as never)}, ${normalized.detail === undefined ? null : tx.json(normalized.detail as never)})
`;
}
private async insertReminderNotification(tx: postgres.TransactionSql, value: TodoReminderNotification): Promise<void> {
const notification = normalizeReminderNotification(value);
await tx`
INSERT INTO todo_note_reminder_notifications (instance_id, todo_id, reminder_at, target, title, message, response_preview, sent_at)
VALUES (${notification.instanceId}, ${notification.todoId}, ${notification.reminderAt}, ${notification.target}, ${notification.title}, ${notification.message}, ${notification.responsePreview}, ${notification.sentAt})
ON CONFLICT (instance_id, todo_id, reminder_at) DO UPDATE SET
target = EXCLUDED.target,
title = EXCLUDED.title,
message = EXCLUDED.message,
response_preview = EXCLUDED.response_preview,
sent_at = EXCLUDED.sent_at
`;
}
}
@@ -0,0 +1,58 @@
export type TodoFilter = "all" | "active" | "completed";
export interface TodoItem {
id: string;
title: string;
completed: boolean;
expanded: boolean;
reminderAt: string | null;
createdAt: string;
updatedAt: string;
children: TodoItem[];
}
export interface TodoInstance {
id: string;
name: string;
filePath: string;
historyPath: string;
createdAt: string;
updatedAt: string;
historyPointer: number;
todos: TodoItem[];
}
export interface InstanceSummary {
id: string;
name: string;
filePath: string;
historyPath: string;
createdAt: string;
updatedAt: string;
todoCount: number;
completedCount: number;
}
export interface Registry {
instances: InstanceSummary[];
}
export type TodoAction =
| { type: "addTodo"; title: string; parentId?: string }
| { type: "updateTodoTitle"; todoId: string; title: string }
| { type: "toggleTodoCompleted"; todoId: string }
| { type: "toggleTodoExpanded"; todoId: string }
| { type: "setAllTodosExpanded"; expanded: boolean }
| { type: "setTodoReminder"; todoId: string; reminderAt: string | null }
| { type: "moveTodo"; todoId: string; targetParentId?: string; targetIndex: number }
| { type: "deleteTodo"; todoId: string }
| { type: "renameInstance"; name: string };
export interface HistoryEntry {
sequence: number;
timestamp: string;
kind: "snapshot" | "command";
action: string;
snapshot?: TodoInstance;
detail?: Record<string, unknown>;
}
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun", "node"]
},
"include": ["src/**/*.ts", "scripts/**/*.ts"]
}