Skip to content

Commit 1a2e7f3

Browse files
Copilotbcho
andauthored
sdks/typescript: replace Date/number duration types with Temporal (#30)
* Initial plan * Add Temporal support: update type definitions and implementation Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Update test files to use Temporal types Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Add documentation for datetime encoding/decoding formats Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bcho <1975118+bcho@users.noreply.github.com>
1 parent 058b454 commit 1a2e7f3

11 files changed

Lines changed: 106 additions & 62 deletions

File tree

sdks/typescript/package-lock.json

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sdks/typescript/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
"peerDependencies": {
3737
"better-sqlite3": "^12.5.0"
3838
},
39+
"dependencies": {
40+
"temporal-polyfill": "^0.3.0"
41+
},
3942
"devDependencies": {
4043
"@types/better-sqlite3": "^7.6.13",
4144
"@types/node": "^22.18.0",

sdks/typescript/src/absurd.ts

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* Modifications Copyright (c) absurd-sqlite contributors.
99
*/
1010
import * as os from "node:os";
11+
import { Temporal } from "temporal-polyfill";
1112

1213
/**
1314
* Minimal query interface compatible with Absurd's database operations.
@@ -39,8 +40,8 @@ export interface RetryStrategy {
3940
}
4041

4142
export interface CancellationPolicy {
42-
maxDuration?: number;
43-
maxDelay?: number;
43+
maxDuration?: Temporal.Duration;
44+
maxDelay?: Temporal.Duration;
4445
}
4546

4647
export interface SpawnOptions {
@@ -315,27 +316,29 @@ export class TaskContext {
315316
}
316317

317318
/**
318-
* Suspends the task until the given duration (seconds) elapses.
319+
* Suspends the task until the given duration elapses.
319320
* @param stepName Checkpoint name for this wait.
320-
* @param duration Duration to wait in seconds.
321+
* @param duration Duration to wait.
321322
*/
322-
async sleepFor(stepName: string, duration: number): Promise<void> {
323-
return await this.sleepUntil(stepName, new Date(Date.now() + duration * 1000));
323+
async sleepFor(stepName: string, duration: Temporal.Duration): Promise<void> {
324+
const now = Temporal.Now.instant();
325+
const wakeAt = now.add(duration);
326+
return await this.sleepUntil(stepName, wakeAt);
324327
}
325328

326329
/**
327330
* Suspends the task until the specified time.
328331
* @param stepName Checkpoint name for this wait.
329332
* @param wakeAt Absolute time when the task should resume.
330333
*/
331-
async sleepUntil(stepName: string, wakeAt: Date): Promise<void> {
334+
async sleepUntil(stepName: string, wakeAt: Temporal.Instant): Promise<void> {
332335
const checkpointName = this.getCheckpointName(stepName);
333336
const state = await this.lookupCheckpoint(checkpointName);
334-
const actualWakeAt = typeof state === "string" ? new Date(state) : wakeAt;
337+
const actualWakeAt = typeof state === "string" ? Temporal.Instant.from(state) : wakeAt;
335338
if (!state) {
336-
await this.persistCheckpoint(checkpointName, wakeAt.toISOString());
339+
await this.persistCheckpoint(checkpointName, wakeAt.toString());
337340
}
338-
if (Date.now() < actualWakeAt.getTime()) {
341+
if (Temporal.Instant.compare(Temporal.Now.instant(), actualWakeAt) < 0) {
339342
await this.scheduleRun(actualWakeAt);
340343
throw new SuspendTask();
341344
}
@@ -389,7 +392,7 @@ export class TaskContext {
389392
this.recordLeaseExtension(this.claimTimeout);
390393
}
391394

392-
private async scheduleRun(wakeAt: Date): Promise<void> {
395+
private async scheduleRun(wakeAt: Temporal.Instant): Promise<void> {
393396
await this.con.query(`SELECT absurd.schedule_run($1, $2, $3)`, [
394397
this.queueName,
395398
this.task.run_id,
@@ -398,24 +401,20 @@ export class TaskContext {
398401
}
399402

400403
/**
401-
* Waits for an event by name and returns its payload; optionally sets a custom step name and timeout (seconds).
404+
* Waits for an event by name and returns its payload; optionally sets a custom step name and timeout.
402405
* @param eventName Event identifier to wait for.
403406
* @param options.stepName Optional checkpoint name (defaults to $awaitEvent:<eventName>).
404-
* @param options.timeout Optional timeout in seconds.
407+
* @param options.timeout Optional timeout duration.
405408
* @throws TimeoutError If the event is not received before the timeout.
406409
*/
407410
async awaitEvent(
408411
eventName: string,
409-
options?: { stepName?: string; timeout?: number }
412+
options?: { stepName?: string; timeout?: Temporal.Duration }
410413
): Promise<JsonValue> {
411414
const stepName = options?.stepName || `$awaitEvent:${eventName}`;
412415
let timeout: number | null = null;
413-
if (
414-
options?.timeout !== undefined &&
415-
Number.isFinite(options?.timeout) &&
416-
options?.timeout >= 0
417-
) {
418-
timeout = Math.floor(options?.timeout);
416+
if (options?.timeout !== undefined) {
417+
timeout = Math.floor(options.timeout.total("seconds"));
419418
}
420419
const checkpointName = this.getCheckpointName(stepName);
421420
const cached = await this.lookupCheckpoint(checkpointName);
@@ -1022,10 +1021,10 @@ function normalizeCancellation(
10221021
}
10231022
const normalized: JsonObject = {};
10241023
if (policy.maxDuration !== undefined) {
1025-
normalized.max_duration = policy.maxDuration;
1024+
normalized.max_duration = Math.floor(policy.maxDuration.total("seconds"));
10261025
}
10271026
if (policy.maxDelay !== undefined) {
1028-
normalized.max_delay = policy.maxDelay;
1027+
normalized.max_delay = Math.floor(policy.maxDelay.total("seconds"));
10291028
}
10301029
return Object.keys(normalized).length > 0 ? normalized : undefined;
10311030
}

sdks/typescript/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import {
1111
} from "./absurd";
1212
import { SQLiteConnection } from "./sqlite-connection";
1313

14+
// Re-export Temporal from temporal-polyfill
15+
export { Temporal } from "temporal-polyfill";
16+
1417
export type { Queryable } from "./absurd";
1518
export {
1619
CancelledTask,

sdks/typescript/src/sqlite-connection.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Temporal } from "temporal-polyfill";
12
import type { Queryable } from "./absurd";
23
import type {
34
SQLiteColumnDefinition,
@@ -228,19 +229,36 @@ function decodeColumnValue<V = any>(args: {
228229
}
229230

230231
if (columnTypeName === "datetime") {
231-
if (typeof value !== "number") {
232-
throw new Error(
233-
`Expected datetime column ${columnName} to be a number, got ${typeof value}`
234-
);
232+
// SQLite stores datetimes as strings but may return them in different formats
233+
// depending on how they were inserted. Support both ISO strings (from
234+
// Temporal.Instant.toString() or Date.toISOString()) and epoch milliseconds
235+
// (from numeric timestamps).
236+
if (typeof value === "string") {
237+
// Handle ISO string format (e.g., "2024-01-01T00:00:00Z")
238+
return Temporal.Instant.from(value) as V;
235239
}
236-
return new Date(value) as V;
240+
if (typeof value === "number") {
241+
// Handle epoch milliseconds format
242+
return Temporal.Instant.fromEpochMilliseconds(value) as V;
243+
}
244+
throw new Error(
245+
`Expected datetime column ${columnName} to be a string or number, got ${typeof value}`
246+
);
237247
}
238248

239249
// For other types, return as is
240250
return value as V;
241251
}
242252

243253
function encodeColumnValue(value: any): any {
254+
// Encode Temporal types to ISO string format for SQLite storage
255+
if (value instanceof Temporal.Instant) {
256+
return value.toString();
257+
}
258+
if (value instanceof Temporal.Duration) {
259+
return value.toString();
260+
}
261+
// Legacy support for Date objects
244262
if (value instanceof Date) {
245263
return value.toISOString();
246264
}

sdks/typescript/test/basic.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
vi,
99
} from "vitest";
1010
import { createTestAbsurd, randomName, type TestContext } from "./setup.js";
11-
import type { Absurd } from "../src/index.js";
11+
import { Temporal, type Absurd } from "../src/index.js";
1212
import { EventEmitter, once } from "events";
1313

1414
describe("Basic SDK Operations", () => {
@@ -170,7 +170,7 @@ describe("Basic SDK Operations", () => {
170170
const scheduledRun = await ctx.getRun(runID);
171171
expect(scheduledRun).toMatchObject({
172172
state: "sleeping",
173-
available_at: wakeAt,
173+
available_at: Temporal.Instant.fromEpochMilliseconds(wakeAt.getTime()),
174174
wake_event: null,
175175
});
176176

@@ -188,7 +188,7 @@ describe("Basic SDK Operations", () => {
188188
const resumedRun = await ctx.getRun(runID);
189189
expect(resumedRun).toMatchObject({
190190
state: "running",
191-
started_at: wakeAt,
191+
started_at: Temporal.Instant.fromEpochMilliseconds(wakeAt.getTime()),
192192
});
193193
});
194194

@@ -215,7 +215,7 @@ describe("Basic SDK Operations", () => {
215215
expect(running).toMatchObject({
216216
state: "running",
217217
claimed_by: "worker-a",
218-
claim_expires_at: new Date(baseTime.getTime() + 30 * 1000),
218+
claim_expires_at: Temporal.Instant.fromEpochMilliseconds(baseTime.getTime() + 30 * 1000),
219219
});
220220

221221
await ctx.setFakeNow(new Date(baseTime.getTime() + 5 * 60 * 1000));
@@ -274,7 +274,7 @@ describe("Basic SDK Operations", () => {
274274
const runRow = await ctx.getRun(runID);
275275
expect(runRow).toMatchObject({
276276
claimed_by: "worker-clean",
277-
claim_expires_at: new Date(base.getTime() + 60 * 1000),
277+
claim_expires_at: Temporal.Instant.fromEpochMilliseconds(base.getTime() + 60 * 1000),
278278
});
279279

280280
const beforeTTL = new Date(finishTime.getTime() + 30 * 60 * 1000);
@@ -481,7 +481,7 @@ describe("Basic SDK Operations", () => {
481481

482482
const getExpiresAt = async (runID: string) => {
483483
const run = await ctx.getRun(runID);
484-
return run?.claim_expires_at ? run.claim_expires_at.getTime() : 0;
484+
return run?.claim_expires_at ? run.claim_expires_at.epochMilliseconds : 0;
485485
};
486486

487487
absurd.workBatch("test-worker", claimTimeout);

sdks/typescript/test/events.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, test, expect, beforeAll, afterEach } from "vitest";
22
import { createTestAbsurd, randomName, type TestContext } from "./setup.js";
33
import type { Absurd } from "../src/index.js";
4-
import { TimeoutError } from "../src/index.js";
4+
import { TimeoutError, Temporal } from "../src/index.js";
55

66
describe("Event system", () => {
77
let ctx: TestContext;
@@ -21,7 +21,7 @@ describe("Event system", () => {
2121
const eventName = randomName("test_event");
2222

2323
absurd.registerTask({ name: "waiter" }, async (params, ctx) => {
24-
const payload = await ctx.awaitEvent(eventName, { timeout: 60 });
24+
const payload = await ctx.awaitEvent(eventName, { timeout: Temporal.Duration.from({ seconds: 60 }) });
2525
return { received: payload };
2626
});
2727

@@ -86,7 +86,7 @@ describe("Event system", () => {
8686
absurd.registerTask({ name: "timeout-waiter" }, async (_params, ctx) => {
8787
try {
8888
const payload = await ctx.awaitEvent(eventName, {
89-
timeout: timeoutSeconds,
89+
timeout: Temporal.Duration.from({ seconds: timeoutSeconds }),
9090
});
9191
return { timedOut: false, result: payload };
9292
} catch (err) {
@@ -109,7 +109,7 @@ describe("Event system", () => {
109109
wake_event: eventName,
110110
});
111111
const expectedWake = new Date(baseTime.getTime() + timeoutSeconds * 1000);
112-
expect(sleepingRun?.available_at?.getTime()).toBe(expectedWake.getTime());
112+
expect(sleepingRun?.available_at?.epochMilliseconds).toBe(expectedWake.getTime());
113113

114114
await ctx.setFakeNow(new Date(expectedWake.getTime() + 1000));
115115
await absurd.workBatch("worker1", 120, 1);
@@ -170,13 +170,13 @@ describe("Event system", () => {
170170

171171
absurd.registerTask({ name: "timeout-no-loop" }, async (_params, ctx) => {
172172
try {
173-
await ctx.awaitEvent(eventName, { stepName: "wait", timeout: 10 });
173+
await ctx.awaitEvent(eventName, { stepName: "wait", timeout: Temporal.Duration.from({ seconds: 10 }) });
174174
return { stage: "unexpected" };
175175
} catch (err) {
176176
if (err instanceof TimeoutError) {
177177
const payload = await ctx.awaitEvent(eventName, {
178178
stepName: "wait",
179-
timeout: 10,
179+
timeout: Temporal.Duration.from({ seconds: 10 }),
180180
});
181181
return { stage: "resumed", payload };
182182
}

sdks/typescript/test/retry.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, test, expect, beforeAll, afterEach } from "vitest";
22
import { createTestAbsurd, randomName, type TestContext } from "./setup.js";
3-
import type { Absurd } from "../src/index.js";
3+
import { Temporal, type Absurd } from "../src/index.js";
44

55
describe("Retry and cancellation", () => {
66
let ctx: TestContext;
@@ -159,7 +159,7 @@ describe("Retry and cancellation", () => {
159159
const { taskID } = await absurd.spawn("duration-cancel", undefined, {
160160
maxAttempts: 4,
161161
retryStrategy: { kind: "fixed", baseSeconds: 30 },
162-
cancellation: { maxDuration: 90 },
162+
cancellation: { maxDuration: Temporal.Duration.from({ seconds: 90 }) },
163163
});
164164

165165
await absurd.workBatch("worker1", 60, 1);
@@ -185,7 +185,7 @@ describe("Retry and cancellation", () => {
185185
});
186186

187187
const { taskID } = await absurd.spawn("delay-cancel", undefined, {
188-
cancellation: { maxDelay: 60 },
188+
cancellation: { maxDelay: Temporal.Duration.from({ seconds: 60 }) },
189189
});
190190

191191
await ctx.setFakeNow(new Date(baseTime.getTime() + 61 * 1000));
@@ -312,8 +312,8 @@ describe("Retry and cancellation", () => {
312312

313313
await absurd.cancelTask(taskID);
314314
const second = await ctx.getTask(taskID);
315-
expect(second?.cancelled_at?.getTime()).toBe(
316-
first?.cancelled_at?.getTime(),
315+
expect(second?.cancelled_at?.epochMilliseconds).toBe(
316+
first?.cancelled_at?.epochMilliseconds,
317317
);
318318
});
319319

0 commit comments

Comments
 (0)