feat: migrate todo note to nc01 github storage
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user