Skip to content

Commit 980538d

Browse files
htdtkshiemdashbot[bot]ascorbic
authored
fix(core): normalize scheduledAt to UTC in content.schedule() (#2913)
* fix(core): normalize scheduledAt to UTC in content.schedule() content.schedule() stored the caller-supplied scheduledAt string verbatim, even though it already parses it into a Date for validation. findReadyToPublish() compares scheduled_at against new Date().toISOString() (always UTC/"Z") using plain string ordering, so a non-UTC offset (e.g. "+09:00") sorts incorrectly and the item publishes up to that many hours late (or early, for offsets ahead of UTC). Reuses the already-validated scheduledDate and stores scheduledDate.toISOString() instead of the raw input. Fixes #2896 * fix(core): normalize scheduledAt in update() and replaceDraftRevisionForUpdate() too Per review: schedule() normalized scheduledAt to UTC, but the sibling write paths in update() and replaceDraftRevisionForUpdate() still stored UpdateContentInput.scheduledAt verbatim, leaving the same late/early-publish bug reachable through any caller that reaches those methods with a non-UTC offset. Extracts a private normalizeScheduledAt() helper (validates and normalizes, passing null through for unschedule-via-update) and applies it at both remaining write sites. Also added regression tests for update() (offset normalization, null clears the field, invalid string still rejected) and reworded the changeset to describe the user-facing behavior rather than internal mechanics, per review. replaceDraftRevisionForUpdate() has no existing unit-test scaffold in this file (only reachable via updateDraftAware(), which needs a revisions-enabled collection fixture not currently set up in this suite) and this environment can't run the test suite to validate a new one, so no test was added for that path specifically — flagging this explicitly rather than guessing. * style: format * test(core): assert normalized schedule timestamps * test(core): cover UTC scheduling boundaries --------- Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com> Co-authored-by: Matt Kane <mkane@cloudflare.com>
1 parent 370ff8b commit 980538d

5 files changed

Lines changed: 85 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Fixes `content.schedule()` and content updates so offset dates are stored as canonical UTC ISO 8601 timestamps. Positive and negative offsets now publish at the represented instant instead of several hours late or early.

packages/core/src/database/repositories/content.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,15 @@ export class ContentRepository {
847847
return mappedResult;
848848
}
849849

850+
private normalizeScheduledAt(value: string | null): string | null {
851+
if (value === null) return null;
852+
const scheduledDate = new Date(value);
853+
if (isNaN(scheduledDate.getTime())) {
854+
throw new EmDashValidationError("Invalid scheduled date");
855+
}
856+
return scheduledDate.toISOString();
857+
}
858+
850859
/**
851860
* Update content
852861
*/
@@ -871,7 +880,7 @@ export class ContentRepository {
871880
}
872881

873882
if (input.scheduledAt !== undefined) {
874-
updates.scheduled_at = input.scheduledAt;
883+
updates.scheduled_at = this.normalizeScheduledAt(input.scheduledAt);
875884
}
876885

877886
if (input.authorId !== undefined) {
@@ -1030,7 +1039,7 @@ export class ContentRepository {
10301039
liveMetadataChanged = true;
10311040
}
10321041
if (input.scheduledAt !== undefined) {
1033-
assignments.push(sql`scheduled_at = ${input.scheduledAt}`);
1042+
assignments.push(sql`scheduled_at = ${this.normalizeScheduledAt(input.scheduledAt)}`);
10341043
liveMetadataChanged = true;
10351044
}
10361045
if (input.authorId !== undefined) {
@@ -1546,10 +1555,11 @@ export class ContentRepository {
15461555
// transition to 'scheduled' so they aren't visible before the time.
15471556
const newStatus = existing.status === "published" ? "published" : "scheduled";
15481557

1558+
// The due query compares ISO strings, so every stored schedule uses the same UTC form.
15491559
await sql`
15501560
UPDATE ${sql.ref(tableName)}
15511561
SET status = ${newStatus},
1552-
scheduled_at = ${scheduledAt},
1562+
scheduled_at = ${scheduledDate.toISOString()},
15531563
updated_at = ${now}
15541564
WHERE id = ${id}
15551565
AND deleted_at IS NULL

packages/core/tests/integration/cli/cli.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,11 +275,11 @@ describe("CLI Integration", () => {
275275
);
276276

277277
// Schedule does not produce JSON output, just a success message
278-
await cli("content", "schedule", "posts", item.id, "--at", "2027-06-01T09:00:00Z");
278+
await cli("content", "schedule", "posts", item.id, "--at", "2027-06-01T04:00:00-05:00");
279279

280280
// Verify via get
281281
const fetched = await cliJson<{ scheduledAt: string }>("content", "get", "posts", item.id);
282-
expect(fetched.scheduledAt).toBe("2027-06-01T09:00:00Z");
282+
expect(fetched.scheduledAt).toBe("2027-06-01T09:00:00.000Z");
283283

284284
// Clean up
285285
await cli("content", "delete", "posts", item.id);

packages/core/tests/integration/client/client.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,11 +322,11 @@ describe("EmDashClient Integration", () => {
322322
});
323323

324324
// Schedule for a future date
325-
await ctx.client.schedule("posts", item.id, { at: "2027-06-01T09:00:00Z" });
325+
await ctx.client.schedule("posts", item.id, { at: "2027-06-01T18:00:00+09:00" });
326326

327327
// Verify via get
328328
const fetched = await ctx.client.get("posts", item.id);
329-
expect(fetched.scheduledAt).toBe("2027-06-01T09:00:00Z");
329+
expect(fetched.scheduledAt).toBe("2027-06-01T09:00:00.000Z");
330330

331331
// Trash and restore
332332
await ctx.client.delete("posts", item.id);

packages/core/tests/unit/database/repositories/content.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Kysely } from "kysely";
2-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
33

44
import { ContentRepository } from "../../../../src/database/repositories/content.js";
55
import { RevisionRepository } from "../../../../src/database/repositories/revision.js";
@@ -21,6 +21,7 @@ describe("ContentRepository", () => {
2121
});
2222

2323
afterEach(async () => {
24+
vi.useRealTimers();
2425
await teardownTestDatabase(db);
2526
});
2627

@@ -319,6 +320,51 @@ describe("ContentRepository", () => {
319320
expect(updated.updatedAt).not.toBe(created.updatedAt);
320321
});
321322

323+
it("should normalize a non-UTC scheduledAt offset", async () => {
324+
const input = createPostFixture();
325+
const created = await repo.create(input);
326+
327+
const updated = await repo.update("post", created.id, {
328+
scheduledAt: "2099-01-01T21:00:00+09:00",
329+
});
330+
331+
expect(updated.scheduledAt).toBe("2099-01-01T12:00:00.000Z");
332+
});
333+
334+
it("should normalize scheduledAt when staging a draft-aware update", async () => {
335+
const created = await repo.create(createPostFixture());
336+
337+
const updated = await repo.updateDraftAware("post", created.id, {
338+
data: { title: "Scheduled revision" },
339+
scheduledAt: "2099-01-01T07:00:00-05:00",
340+
});
341+
342+
expect(updated.draftRevisionId).not.toBeNull();
343+
expect(updated.scheduledAt).toBe("2099-01-01T12:00:00.000Z");
344+
});
345+
346+
it("should clear scheduledAt when set to null", async () => {
347+
const input = createPostFixture();
348+
const created = await repo.create(input);
349+
const future = new Date(Date.now() + 86_400_000).toISOString();
350+
await repo.update("post", created.id, { scheduledAt: future });
351+
352+
const updated = await repo.update("post", created.id, {
353+
scheduledAt: null,
354+
});
355+
356+
expect(updated.scheduledAt).toBeNull();
357+
});
358+
359+
it("should reject an invalid scheduledAt string", async () => {
360+
const input = createPostFixture();
361+
const created = await repo.create(input);
362+
363+
await expect(repo.update("post", created.id, { scheduledAt: "not-a-date" })).rejects.toThrow(
364+
EmDashValidationError,
365+
);
366+
});
367+
322368
it("should throw error for non-existent content", async () => {
323369
await expect(repo.update("post", "01J9FAKE0000000000000000", { data: {} })).rejects.toThrow(
324370
"Content not found",
@@ -448,6 +494,22 @@ describe("ContentRepository", () => {
448494
EmDashValidationError,
449495
);
450496
});
497+
498+
it.each([
499+
["positive", "2030-01-01T21:00:00+09:00"],
500+
["negative", "2030-01-01T07:00:00-05:00"],
501+
])("should publish a %s offset at the represented instant", async (_offset, scheduledAt) => {
502+
vi.useFakeTimers({ now: new Date("2030-01-01T11:00:00.000Z") });
503+
const post = await repo.create(createPostFixture());
504+
const updated = await repo.schedule("post", post.id, scheduledAt);
505+
expect(updated.scheduledAt).toBe("2030-01-01T12:00:00.000Z");
506+
507+
vi.setSystemTime(new Date("2030-01-01T11:59:59.999Z"));
508+
expect(await repo.findReadyToPublish("post")).toEqual([]);
509+
510+
vi.setSystemTime(new Date("2030-01-01T12:00:00.000Z"));
511+
expect((await repo.findReadyToPublish("post")).map((item) => item.id)).toEqual([post.id]);
512+
});
451513
});
452514

453515
describe("unschedule()", () => {

0 commit comments

Comments
 (0)