Skip to content

Commit d495a1b

Browse files
committed
feat: Refactor goal advancement logic and integrate cron scheduling
1 parent 655e812 commit d495a1b

8 files changed

Lines changed: 142 additions & 119 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dist/
1919

2020
# OS
2121
.DS_Store
22+
.history/
2223

2324
# playwright
2425
.playwright-cli/

Containerfile

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,20 +52,15 @@ COPY --from=builder --chown=nonroot:nonroot /app/deno.json /app/deno.lock ./
5252
# Copy migrations for runtime execution
5353
COPY --from=builder --chown=nonroot:nonroot /app/src/db/migrations ./src/db/migrations
5454

55-
# Copy the advance cron script for runtime execution
56-
COPY --from=builder --chown=nonroot:nonroot /app/src/server/advance.js ./src/server/advance.js
57-
5855
# Copy node_modules for native bindings compatibility
5956
COPY --from=builder --chown=nonroot:nonroot /app/node_modules ./node_modules
6057

61-
RUN mkdir -p /app/logs
62-
6358
ENV HOST=0.0.0.0
6459
ENV PORT=8080
6560

6661
EXPOSE 8080
6762

68-
# Run the Astro server with all permissions inside the container sandbox.
69-
# The advance cron runs in the background; nohup ensures it survives
70-
# if the main process restarts (container lifecycle events).
71-
CMD ["/bin/sh", "-c", "nohup /bin/deno run -A /app/src/server/advance.js > /app/logs/advance.log 2>&1 & /bin/deno run -A /app/server/entry.mjs"]
63+
# The daily advance cron is bootstrapped from src/middleware.js inside the
64+
# Astro server process — no sidecar / shell wrapper needed (distroless/cc
65+
# has no /bin/sh).
66+
CMD ["/bin/deno", "run", "-A", "/app/server/entry.mjs"]

deno.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
"build": "deno run -A --env npm:astro build",
2323
"preview": "deno run -A --env npm:astro preview",
2424
"astro": "deno run -A --env npm:astro",
25-
"test": "deno test -A",
26-
"ci": "deno lint && deno fmt --check && deno check && deno test -A"
25+
"test": "DB_PATH=:memory: deno test -A",
26+
"ci": "deno lint && deno fmt --check && deno check --check-js --doc && deno run test"
2727
},
2828
"fmt": {
2929
"exclude": [

src/db/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ const { DatabaseSync } = await importDynamic("node:sqlite");
99
const dbPath = process.env.DB_PATH || "goaly.db";
1010
export const db = new DatabaseSync(dbPath);
1111

12+
// SQLite ships with foreign_keys disabled per-connection; without this the
13+
// ON DELETE CASCADE clauses in 001_initial.sql are silently ignored and
14+
// orphan rows can be inserted via bad joins.
15+
db.exec("PRAGMA foreign_keys = ON;");
16+
1217
// Initialize migrations table
1318
db.exec(`
1419
CREATE TABLE IF NOT EXISTS migrations (

src/lib/advance.js

Lines changed: 55 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import { scheduleGoal } from "./scheduler.js";
77
* new instances for each affected goal.
88
*
99
* Called by the daily cron and by the instance update API.
10+
*
11+
* @param {(user: any, goal: any) => Promise<void>} [scheduleFn] - Optional
12+
* scheduler injection point. Defaults to the real Google-Calendar-backed
13+
* scheduler; tests pass a stub to avoid network I/O.
1014
*/
11-
export async function advanceGoalInstances() {
15+
export async function advanceGoalInstances(scheduleFn = scheduleGoal) {
1216
const nowIso = dayjs.utc().toISOString();
1317

1418
// Find all pending instances whose start_time has passed
@@ -60,46 +64,67 @@ export async function advanceGoalInstances() {
6064
WHERE id IN (${placeholders})
6165
`).run(...data.instances);
6266

63-
// Update last_advance_at tracking column right after marking as missed
64-
db.prepare(`
65-
UPDATE goals SET last_advance_at = ? WHERE id = ?
66-
`).run(nowIso, goalId);
67-
68-
// Fetch the full user and goal details for scheduling
69-
const goalRow = db.prepare(`
70-
SELECT g.*, u.*
67+
// Fetch the full user and goal details for scheduling.
68+
// Explicit aliases are required: goals.id and users.id would collide
69+
// under SELECT g.*, u.* and the second wins, corrupting goal.id.
70+
const row = db.prepare(`
71+
SELECT
72+
g.id AS goal_id,
73+
g.name AS goal_name,
74+
g.times_per_week,
75+
g.duration_minutes,
76+
g.time_preference,
77+
g.color,
78+
g.icon,
79+
u.id AS user_id,
80+
u.email,
81+
u.google_id,
82+
u.access_token,
83+
u.refresh_token,
84+
u.gotify_url,
85+
u.gotify_token,
86+
u.timezone,
87+
u.morning_start,
88+
u.afternoon_start,
89+
u.evening_start,
90+
u.night_start
7191
FROM goals g
7292
JOIN users u ON g.user_id = u.id
7393
WHERE g.id = ?
7494
`).get(goalId);
7595

76-
if (goalRow) {
96+
if (row) {
7797
const goal = {
78-
id: goalRow.id,
79-
name: goalRow.name,
80-
times_per_week: goalRow.times_per_week,
81-
duration_minutes: goalRow.duration_minutes,
82-
time_preference: goalRow.time_preference,
83-
color: goalRow.color,
84-
icon: goalRow.icon,
98+
id: row.goal_id,
99+
name: row.goal_name,
100+
times_per_week: row.times_per_week,
101+
duration_minutes: row.duration_minutes,
102+
time_preference: row.time_preference,
103+
color: row.color,
104+
icon: row.icon,
85105
};
86106
const user = {
87-
id: goalRow.user_id,
88-
email: goalRow.email,
89-
google_id: goalRow.google_id,
90-
access_token: goalRow.access_token,
91-
refresh_token: goalRow.refresh_token,
92-
gotify_url: goalRow.gotify_url,
93-
gotify_token: goalRow.gotify_token,
94-
timezone: goalRow.timezone,
95-
morning_start: goalRow.morning_start,
96-
afternoon_start: goalRow.afternoon_start,
97-
evening_start: goalRow.evening_start,
98-
night_start: goalRow.night_start,
107+
id: row.user_id,
108+
email: row.email,
109+
google_id: row.google_id,
110+
access_token: row.access_token,
111+
refresh_token: row.refresh_token,
112+
gotify_url: row.gotify_url,
113+
gotify_token: row.gotify_token,
114+
timezone: row.timezone,
115+
morning_start: row.morning_start,
116+
afternoon_start: row.afternoon_start,
117+
evening_start: row.evening_start,
118+
night_start: row.night_start,
99119
};
100120

101121
try {
102-
await scheduleGoal(user, goal);
122+
await scheduleFn(user, goal);
123+
// Only record advancement after scheduling succeeds — otherwise
124+
// the tracking column lies and a stalled goal looks healthy.
125+
db.prepare(
126+
`UPDATE goals SET last_advance_at = ? WHERE id = ?`,
127+
).run(nowIso, goalId);
103128
advancedGoalIds.push(goalId);
104129
} catch (err) {
105130
console.error(

src/lib/advance.test.js

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,27 @@
11
import { strict as assert } from "node:assert";
2-
import { db } from "../db/index.js";
3-
import { advanceGoalInstances } from "./advance.js";
4-
import dayjs from "./dateUtils.js";
2+
import process from "node:process";
3+
4+
// Safety guard: advanceGoalInstances() scans every user, marks their
5+
// pending instances as 'missed', and calls scheduleGoal against the real
6+
// Google Calendar API. Running these tests against goaly.db would corrupt
7+
// real user data. The `test` / `ci` tasks in deno.json set DB_PATH=:memory:
8+
// — this check refuses to run if someone invokes `deno test` directly
9+
// without overriding the default.
10+
if (!process.env.DB_PATH || process.env.DB_PATH === "goaly.db") {
11+
throw new Error(
12+
"advance.test.js refuses to run against the default DB_PATH. " +
13+
"Use `deno task test` (which sets DB_PATH=:memory:) or export DB_PATH yourself.",
14+
);
15+
}
16+
17+
const { db } = await import("../db/index.js");
18+
const { advanceGoalInstances } = await import("./advance.js");
19+
const dayjs = (await import("./dateUtils.js")).default;
20+
21+
// Stand-in for scheduleGoal so tests don't hit the real Google Calendar API
22+
// with fake refresh tokens. The unit under test here is the
23+
// mark-missed-and-tick-tracking logic, not the scheduler itself.
24+
const noopScheduler = async () => {};
525

626
/**
727
* @typedef {Object} TestUser
@@ -108,7 +128,7 @@ function createInstance(goal, startIso, endIso, status) {
108128
}
109129

110130
Deno.test("advanceGoalInstances - marks past-due instances as missed", async () => {
111-
const { user } = await setupUser();
131+
const { user } = setupUser();
112132
const goal = createGoal(user, "Reading Time", 3, "afternoon");
113133

114134
// Create 2 past-due pending instances
@@ -126,7 +146,7 @@ Deno.test("advanceGoalInstances - marks past-due instances as missed", async ()
126146
"Should have 2 pending instances before advancement",
127147
);
128148

129-
const result = await advanceGoalInstances();
149+
const result = await advanceGoalInstances(noopScheduler);
130150

131151
instances = db.prepare("SELECT * FROM goal_instances WHERE goal_id = ?").all(
132152
goal.id,
@@ -143,7 +163,7 @@ Deno.test("advanceGoalInstances - marks past-due instances as missed", async ()
143163
});
144164

145165
Deno.test("advanceGoalInstances - skips future pending instances", async () => {
146-
const { user } = await setupUser();
166+
const { user } = setupUser();
147167
const goal = createGoal(user, "Future Goal", 3, "afternoon");
148168

149169
const pastTime = dayjs.utc().subtract(1, "day").toISOString();
@@ -156,7 +176,7 @@ Deno.test("advanceGoalInstances - skips future pending instances", async () => {
156176
.toISOString();
157177
createInstance(goal, futureTime, futureEndTime);
158178

159-
const result = await advanceGoalInstances();
179+
const result = await advanceGoalInstances(noopScheduler);
160180

161181
const instances = db.prepare("SELECT * FROM goal_instances WHERE goal_id = ?")
162182
.all(goal.id);
@@ -181,16 +201,16 @@ Deno.test("advanceGoalInstances - skips future pending instances", async () => {
181201
});
182202

183203
Deno.test("advanceGoalInstances - handles goals with no pending instances", async () => {
184-
const { user } = await setupUser();
204+
const { user } = setupUser();
185205
createGoal(user, "No Pending Goal", 3, "afternoon");
186206

187-
const result = await advanceGoalInstances();
207+
const result = await advanceGoalInstances(noopScheduler);
188208
assert.equal(result.advanced, 0, "Should not have advanced any goals");
189209
assert.equal(result.missed, 0, "Should not have missed any instances");
190210
});
191211

192212
Deno.test("advanceGoalInstances - marks all past-due instances as missed", async () => {
193-
const { user } = await setupUser();
213+
const { user } = setupUser();
194214
const goal = createGoal(user, "Many Past Goal", 5, "afternoon");
195215

196216
const pastTime = dayjs.utc().subtract(2, "day").toISOString();
@@ -200,7 +220,7 @@ Deno.test("advanceGoalInstances - marks all past-due instances as missed", async
200220
createInstance(goal, pastTime, pastEndTime);
201221
}
202222

203-
const result = await advanceGoalInstances();
223+
const result = await advanceGoalInstances(noopScheduler);
204224

205225
const instances = db.prepare("SELECT * FROM goal_instances WHERE goal_id = ?")
206226
.all(goal.id);
@@ -212,7 +232,7 @@ Deno.test("advanceGoalInstances - marks all past-due instances as missed", async
212232
});
213233

214234
Deno.test("advanceGoalInstances - multiple goals for same user", async () => {
215-
const { user } = await setupUser();
235+
const { user } = setupUser();
216236
const goal1 = createGoal(user, "Goal 1", 2, "morning");
217237
const goal2 = createGoal(user, "Goal 2", 2, "afternoon");
218238

@@ -222,7 +242,7 @@ Deno.test("advanceGoalInstances - multiple goals for same user", async () => {
222242
createInstance(goal1, pastTime, pastEndTime);
223243
createInstance(goal2, pastTime, pastEndTime);
224244

225-
const result = await advanceGoalInstances();
245+
const result = await advanceGoalInstances(noopScheduler);
226246

227247
const instances1 = db.prepare(
228248
"SELECT * FROM goal_instances WHERE goal_id = ?",
@@ -243,7 +263,7 @@ Deno.test("advanceGoalInstances - multiple goals for same user", async () => {
243263
});
244264

245265
Deno.test("advanceGoalInstances - does not mark non-pending instances as missed", async () => {
246-
const { user } = await setupUser();
266+
const { user } = setupUser();
247267
const goal = createGoal(user, "Non-Pending Goal", 3, "afternoon");
248268

249269
const pastTime = dayjs.utc().subtract(1, "day").toISOString();
@@ -254,7 +274,7 @@ Deno.test("advanceGoalInstances - does not mark non-pending instances as missed"
254274
createInstance(goal, pastTime, pastEndTime, "completed");
255275
createInstance(goal, pastTime, pastEndTime, "skipped");
256276

257-
await advanceGoalInstances();
277+
await advanceGoalInstances(noopScheduler);
258278

259279
const instances = db.prepare("SELECT * FROM goal_instances WHERE goal_id = ?")
260280
.all(goal.id);
@@ -272,15 +292,15 @@ Deno.test("advanceGoalInstances - does not mark non-pending instances as missed"
272292
});
273293

274294
Deno.test("advanceGoalInstances - updates last_advance_at tracking column", async () => {
275-
const { user } = await setupUser();
295+
const { user } = setupUser();
276296
const goal = createGoal(user, "Tracking Goal", 3, "afternoon");
277297

278298
const pastTime = dayjs.utc().subtract(1, "day").toISOString();
279299
const pastEndTime = dayjs.utc().subtract(1, "day").add(30, "minute")
280300
.toISOString();
281301
createInstance(goal, pastTime, pastEndTime);
282302

283-
await advanceGoalInstances();
303+
await advanceGoalInstances(noopScheduler);
284304

285305
const updatedGoal = db.prepare(
286306
"SELECT last_advance_at FROM goals WHERE id = ?",

src/middleware.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { startAdvanceCron } from "./server/advance.js";
2+
3+
// Boot the daily advancement cron once when the server module is first
4+
// loaded. This replaces a sidecar process — keeping it in-process avoids
5+
// needing a shell wrapper in the (shell-less) distroless runtime image.
6+
startAdvanceCron();
7+
8+
/** @type {import('astro').MiddlewareHandler} */
9+
export const onRequest = (_context, next) => next();

0 commit comments

Comments
 (0)