diff --git a/sdks/typescript/package-lock.json b/sdks/typescript/package-lock.json index 683a7bb..da0b321 100644 --- a/sdks/typescript/package-lock.json +++ b/sdks/typescript/package-lock.json @@ -8,6 +8,9 @@ "name": "@absurd-sqlite/sdk", "version": "0.3.0-alpha.0", "license": "Apache-2.0", + "dependencies": { + "temporal-polyfill": "^0.3.0" + }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.18.0", @@ -1925,6 +1928,21 @@ "node": ">=6" } }, + "node_modules/temporal-polyfill": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.0.tgz", + "integrity": "sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g==", + "license": "MIT", + "dependencies": { + "temporal-spec": "0.3.0" + } + }, + "node_modules/temporal-spec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.0.tgz", + "integrity": "sha512-n+noVpIqz4hYgFSMOSiINNOUOMFtV5cZQNCmmszA6GiVFVRt3G7AqVyhXjhCSmowvQn+NsGn+jMDMKJYHd3bSQ==", + "license": "ISC" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 6959037..27af37a 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -36,6 +36,9 @@ "peerDependencies": { "better-sqlite3": "^12.5.0" }, + "dependencies": { + "temporal-polyfill": "^0.3.0" + }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.18.0", diff --git a/sdks/typescript/src/absurd.ts b/sdks/typescript/src/absurd.ts index aafc05b..0af8b54 100644 --- a/sdks/typescript/src/absurd.ts +++ b/sdks/typescript/src/absurd.ts @@ -8,6 +8,7 @@ * Modifications Copyright (c) absurd-sqlite contributors. */ import * as os from "node:os"; +import { Temporal } from "temporal-polyfill"; /** * Minimal query interface compatible with Absurd's database operations. @@ -39,8 +40,8 @@ export interface RetryStrategy { } export interface CancellationPolicy { - maxDuration?: number; - maxDelay?: number; + maxDuration?: Temporal.Duration; + maxDelay?: Temporal.Duration; } export interface SpawnOptions { @@ -315,12 +316,14 @@ export class TaskContext { } /** - * Suspends the task until the given duration (seconds) elapses. + * Suspends the task until the given duration elapses. * @param stepName Checkpoint name for this wait. - * @param duration Duration to wait in seconds. + * @param duration Duration to wait. */ - async sleepFor(stepName: string, duration: number): Promise { - return await this.sleepUntil(stepName, new Date(Date.now() + duration * 1000)); + async sleepFor(stepName: string, duration: Temporal.Duration): Promise { + const now = Temporal.Now.instant(); + const wakeAt = now.add(duration); + return await this.sleepUntil(stepName, wakeAt); } /** @@ -328,14 +331,14 @@ export class TaskContext { * @param stepName Checkpoint name for this wait. * @param wakeAt Absolute time when the task should resume. */ - async sleepUntil(stepName: string, wakeAt: Date): Promise { + async sleepUntil(stepName: string, wakeAt: Temporal.Instant): Promise { const checkpointName = this.getCheckpointName(stepName); const state = await this.lookupCheckpoint(checkpointName); - const actualWakeAt = typeof state === "string" ? new Date(state) : wakeAt; + const actualWakeAt = typeof state === "string" ? Temporal.Instant.from(state) : wakeAt; if (!state) { - await this.persistCheckpoint(checkpointName, wakeAt.toISOString()); + await this.persistCheckpoint(checkpointName, wakeAt.toString()); } - if (Date.now() < actualWakeAt.getTime()) { + if (Temporal.Instant.compare(Temporal.Now.instant(), actualWakeAt) < 0) { await this.scheduleRun(actualWakeAt); throw new SuspendTask(); } @@ -389,7 +392,7 @@ export class TaskContext { this.recordLeaseExtension(this.claimTimeout); } - private async scheduleRun(wakeAt: Date): Promise { + private async scheduleRun(wakeAt: Temporal.Instant): Promise { await this.con.query(`SELECT absurd.schedule_run($1, $2, $3)`, [ this.queueName, this.task.run_id, @@ -398,24 +401,20 @@ export class TaskContext { } /** - * Waits for an event by name and returns its payload; optionally sets a custom step name and timeout (seconds). + * Waits for an event by name and returns its payload; optionally sets a custom step name and timeout. * @param eventName Event identifier to wait for. * @param options.stepName Optional checkpoint name (defaults to $awaitEvent:). - * @param options.timeout Optional timeout in seconds. + * @param options.timeout Optional timeout duration. * @throws TimeoutError If the event is not received before the timeout. */ async awaitEvent( eventName: string, - options?: { stepName?: string; timeout?: number } + options?: { stepName?: string; timeout?: Temporal.Duration } ): Promise { const stepName = options?.stepName || `$awaitEvent:${eventName}`; let timeout: number | null = null; - if ( - options?.timeout !== undefined && - Number.isFinite(options?.timeout) && - options?.timeout >= 0 - ) { - timeout = Math.floor(options?.timeout); + if (options?.timeout !== undefined) { + timeout = Math.floor(options.timeout.total("seconds")); } const checkpointName = this.getCheckpointName(stepName); const cached = await this.lookupCheckpoint(checkpointName); @@ -1022,10 +1021,10 @@ function normalizeCancellation( } const normalized: JsonObject = {}; if (policy.maxDuration !== undefined) { - normalized.max_duration = policy.maxDuration; + normalized.max_duration = Math.floor(policy.maxDuration.total("seconds")); } if (policy.maxDelay !== undefined) { - normalized.max_delay = policy.maxDelay; + normalized.max_delay = Math.floor(policy.maxDelay.total("seconds")); } return Object.keys(normalized).length > 0 ? normalized : undefined; } diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index a92f5f9..bb8f8c3 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -11,6 +11,9 @@ import { } from "./absurd"; import { SQLiteConnection } from "./sqlite-connection"; +// Re-export Temporal from temporal-polyfill +export { Temporal } from "temporal-polyfill"; + export type { Queryable } from "./absurd"; export { CancelledTask, diff --git a/sdks/typescript/src/sqlite-connection.ts b/sdks/typescript/src/sqlite-connection.ts index 68236d4..3778d50 100644 --- a/sdks/typescript/src/sqlite-connection.ts +++ b/sdks/typescript/src/sqlite-connection.ts @@ -1,3 +1,4 @@ +import { Temporal } from "temporal-polyfill"; import type { Queryable } from "./absurd"; import type { SQLiteColumnDefinition, @@ -228,12 +229,21 @@ function decodeColumnValue(args: { } if (columnTypeName === "datetime") { - if (typeof value !== "number") { - throw new Error( - `Expected datetime column ${columnName} to be a number, got ${typeof value}` - ); + // SQLite stores datetimes as strings but may return them in different formats + // depending on how they were inserted. Support both ISO strings (from + // Temporal.Instant.toString() or Date.toISOString()) and epoch milliseconds + // (from numeric timestamps). + if (typeof value === "string") { + // Handle ISO string format (e.g., "2024-01-01T00:00:00Z") + return Temporal.Instant.from(value) as V; } - return new Date(value) as V; + if (typeof value === "number") { + // Handle epoch milliseconds format + return Temporal.Instant.fromEpochMilliseconds(value) as V; + } + throw new Error( + `Expected datetime column ${columnName} to be a string or number, got ${typeof value}` + ); } // For other types, return as is @@ -241,6 +251,14 @@ function decodeColumnValue(args: { } function encodeColumnValue(value: any): any { + // Encode Temporal types to ISO string format for SQLite storage + if (value instanceof Temporal.Instant) { + return value.toString(); + } + if (value instanceof Temporal.Duration) { + return value.toString(); + } + // Legacy support for Date objects if (value instanceof Date) { return value.toISOString(); } diff --git a/sdks/typescript/test/basic.test.ts b/sdks/typescript/test/basic.test.ts index 961d134..c30d85f 100644 --- a/sdks/typescript/test/basic.test.ts +++ b/sdks/typescript/test/basic.test.ts @@ -8,7 +8,7 @@ import { vi, } from "vitest"; import { createTestAbsurd, randomName, type TestContext } from "./setup.js"; -import type { Absurd } from "../src/index.js"; +import { Temporal, type Absurd } from "../src/index.js"; import { EventEmitter, once } from "events"; describe("Basic SDK Operations", () => { @@ -170,7 +170,7 @@ describe("Basic SDK Operations", () => { const scheduledRun = await ctx.getRun(runID); expect(scheduledRun).toMatchObject({ state: "sleeping", - available_at: wakeAt, + available_at: Temporal.Instant.fromEpochMilliseconds(wakeAt.getTime()), wake_event: null, }); @@ -188,7 +188,7 @@ describe("Basic SDK Operations", () => { const resumedRun = await ctx.getRun(runID); expect(resumedRun).toMatchObject({ state: "running", - started_at: wakeAt, + started_at: Temporal.Instant.fromEpochMilliseconds(wakeAt.getTime()), }); }); @@ -215,7 +215,7 @@ describe("Basic SDK Operations", () => { expect(running).toMatchObject({ state: "running", claimed_by: "worker-a", - claim_expires_at: new Date(baseTime.getTime() + 30 * 1000), + claim_expires_at: Temporal.Instant.fromEpochMilliseconds(baseTime.getTime() + 30 * 1000), }); await ctx.setFakeNow(new Date(baseTime.getTime() + 5 * 60 * 1000)); @@ -274,7 +274,7 @@ describe("Basic SDK Operations", () => { const runRow = await ctx.getRun(runID); expect(runRow).toMatchObject({ claimed_by: "worker-clean", - claim_expires_at: new Date(base.getTime() + 60 * 1000), + claim_expires_at: Temporal.Instant.fromEpochMilliseconds(base.getTime() + 60 * 1000), }); const beforeTTL = new Date(finishTime.getTime() + 30 * 60 * 1000); @@ -481,7 +481,7 @@ describe("Basic SDK Operations", () => { const getExpiresAt = async (runID: string) => { const run = await ctx.getRun(runID); - return run?.claim_expires_at ? run.claim_expires_at.getTime() : 0; + return run?.claim_expires_at ? run.claim_expires_at.epochMilliseconds : 0; }; absurd.workBatch("test-worker", claimTimeout); diff --git a/sdks/typescript/test/events.test.ts b/sdks/typescript/test/events.test.ts index 2bc6247..9e60978 100644 --- a/sdks/typescript/test/events.test.ts +++ b/sdks/typescript/test/events.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, beforeAll, afterEach } from "vitest"; import { createTestAbsurd, randomName, type TestContext } from "./setup.js"; import type { Absurd } from "../src/index.js"; -import { TimeoutError } from "../src/index.js"; +import { TimeoutError, Temporal } from "../src/index.js"; describe("Event system", () => { let ctx: TestContext; @@ -21,7 +21,7 @@ describe("Event system", () => { const eventName = randomName("test_event"); absurd.registerTask({ name: "waiter" }, async (params, ctx) => { - const payload = await ctx.awaitEvent(eventName, { timeout: 60 }); + const payload = await ctx.awaitEvent(eventName, { timeout: Temporal.Duration.from({ seconds: 60 }) }); return { received: payload }; }); @@ -86,7 +86,7 @@ describe("Event system", () => { absurd.registerTask({ name: "timeout-waiter" }, async (_params, ctx) => { try { const payload = await ctx.awaitEvent(eventName, { - timeout: timeoutSeconds, + timeout: Temporal.Duration.from({ seconds: timeoutSeconds }), }); return { timedOut: false, result: payload }; } catch (err) { @@ -109,7 +109,7 @@ describe("Event system", () => { wake_event: eventName, }); const expectedWake = new Date(baseTime.getTime() + timeoutSeconds * 1000); - expect(sleepingRun?.available_at?.getTime()).toBe(expectedWake.getTime()); + expect(sleepingRun?.available_at?.epochMilliseconds).toBe(expectedWake.getTime()); await ctx.setFakeNow(new Date(expectedWake.getTime() + 1000)); await absurd.workBatch("worker1", 120, 1); @@ -170,13 +170,13 @@ describe("Event system", () => { absurd.registerTask({ name: "timeout-no-loop" }, async (_params, ctx) => { try { - await ctx.awaitEvent(eventName, { stepName: "wait", timeout: 10 }); + await ctx.awaitEvent(eventName, { stepName: "wait", timeout: Temporal.Duration.from({ seconds: 10 }) }); return { stage: "unexpected" }; } catch (err) { if (err instanceof TimeoutError) { const payload = await ctx.awaitEvent(eventName, { stepName: "wait", - timeout: 10, + timeout: Temporal.Duration.from({ seconds: 10 }), }); return { stage: "resumed", payload }; } diff --git a/sdks/typescript/test/retry.test.ts b/sdks/typescript/test/retry.test.ts index 1abfdbf..8b8374c 100644 --- a/sdks/typescript/test/retry.test.ts +++ b/sdks/typescript/test/retry.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeAll, afterEach } from "vitest"; import { createTestAbsurd, randomName, type TestContext } from "./setup.js"; -import type { Absurd } from "../src/index.js"; +import { Temporal, type Absurd } from "../src/index.js"; describe("Retry and cancellation", () => { let ctx: TestContext; @@ -159,7 +159,7 @@ describe("Retry and cancellation", () => { const { taskID } = await absurd.spawn("duration-cancel", undefined, { maxAttempts: 4, retryStrategy: { kind: "fixed", baseSeconds: 30 }, - cancellation: { maxDuration: 90 }, + cancellation: { maxDuration: Temporal.Duration.from({ seconds: 90 }) }, }); await absurd.workBatch("worker1", 60, 1); @@ -185,7 +185,7 @@ describe("Retry and cancellation", () => { }); const { taskID } = await absurd.spawn("delay-cancel", undefined, { - cancellation: { maxDelay: 60 }, + cancellation: { maxDelay: Temporal.Duration.from({ seconds: 60 }) }, }); await ctx.setFakeNow(new Date(baseTime.getTime() + 61 * 1000)); @@ -312,8 +312,8 @@ describe("Retry and cancellation", () => { await absurd.cancelTask(taskID); const second = await ctx.getTask(taskID); - expect(second?.cancelled_at?.getTime()).toBe( - first?.cancelled_at?.getTime(), + expect(second?.cancelled_at?.epochMilliseconds).toBe( + first?.cancelled_at?.epochMilliseconds, ); }); diff --git a/sdks/typescript/test/setup.ts b/sdks/typescript/test/setup.ts index 58d4ddc..9bae542 100644 --- a/sdks/typescript/test/setup.ts +++ b/sdks/typescript/test/setup.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { Absurd, + Temporal, type AbsurdHooks, type JsonValue, type SQLiteDatabase, @@ -22,8 +23,8 @@ export interface TaskRow { retry_strategy: JsonValue | null; max_attempts: number | null; cancellation: JsonValue | null; - enqueue_at: Date; - first_started_at: Date | null; + enqueue_at: Temporal.Instant; + first_started_at: Temporal.Instant | null; state: | "pending" | "running" @@ -34,7 +35,7 @@ export interface TaskRow { attempts: number; last_attempt_run: string | null; completed_payload: JsonValue | null; - cancelled_at: Date | null; + cancelled_at: Temporal.Instant | null; } export interface RunRow { @@ -49,16 +50,16 @@ export interface RunRow { | "failed" | "cancelled"; claimed_by: string | null; - claim_expires_at: Date | null; - available_at: Date; + claim_expires_at: Temporal.Instant | null; + available_at: Temporal.Instant; wake_event: string | null; event_payload: JsonValue | null; - started_at: Date | null; - completed_at: Date | null; - failed_at: Date | null; + started_at: Temporal.Instant | null; + completed_at: Temporal.Instant | null; + failed_at: Temporal.Instant | null; result: JsonValue | null; failure_reason: JsonValue | null; - created_at: Date; + created_at: Temporal.Instant; } interface SqliteFixture { diff --git a/sdks/typescript/test/sqlite.test.ts b/sdks/typescript/test/sqlite.test.ts index 42ad6d8..c5aad92 100644 --- a/sdks/typescript/test/sqlite.test.ts +++ b/sdks/typescript/test/sqlite.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { SQLiteConnection } from "../src/sqlite-connection"; import type { SQLiteDatabase } from "../src/sqlite-types"; +import { Temporal } from "../src/index"; describe("SQLiteConnection", () => { it("rewrites postgres-style params and absurd schema names", async () => { @@ -69,7 +70,7 @@ describe("SQLiteConnection", () => { db.close(); }); - it("decodes datetime columns into Date objects", async () => { + it("decodes datetime columns into Temporal.Instant objects", async () => { const db = new sqlite(":memory:") as SQLiteDatabase; const conn = new SQLiteConnection(db); const now = Date.now(); @@ -77,12 +78,12 @@ describe("SQLiteConnection", () => { await conn.exec("CREATE TABLE t_date (created_at DATETIME)"); await conn.exec("INSERT INTO t_date (created_at) VALUES ($1)", [now]); - const { rows } = await conn.query<{ created_at: Date }>( + const { rows } = await conn.query<{ created_at: Temporal.Instant }>( "SELECT created_at FROM t_date" ); - expect(rows[0]?.created_at).toBeInstanceOf(Date); - expect(rows[0]?.created_at.getTime()).toBe(now); + expect(rows[0]?.created_at).toBeInstanceOf(Temporal.Instant); + expect(rows[0]?.created_at.epochMilliseconds).toBe(now); db.close(); }); diff --git a/sdks/typescript/test/step.test.ts b/sdks/typescript/test/step.test.ts index 9125be5..72026c1 100644 --- a/sdks/typescript/test/step.test.ts +++ b/sdks/typescript/test/step.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeAll, afterEach, vi } from "vitest"; import { createTestAbsurd, randomName, type TestContext } from "./setup.js"; -import type { Absurd } from "../src/index.js"; +import { Temporal, type Absurd } from "../src/index.js"; describe("Step functionality", () => { let ctx: TestContext; @@ -193,7 +193,7 @@ describe("Step functionality", () => { const durationSeconds = 60; absurd.registerTask({ name: "sleep-for" }, async (_params, ctx) => { - await ctx.sleepFor("wait-for", durationSeconds); + await ctx.sleepFor("wait-for", Temporal.Duration.from({ seconds: durationSeconds })); return { resumed: true }; }); @@ -205,7 +205,7 @@ describe("Step functionality", () => { state: "sleeping", }); const wakeTime = new Date(base.getTime() + durationSeconds * 1000); - expect(sleepingRun?.available_at?.getTime()).toBe(wakeTime.getTime()); + expect(sleepingRun?.available_at?.epochMilliseconds).toBe(wakeTime.getTime()); const resumeTime = new Date(wakeTime.getTime() + 5 * 1000); vi.setSystemTime(resumeTime); @@ -225,11 +225,12 @@ describe("Step functionality", () => { await ctx.setFakeNow(base); const wakeTime = new Date(base.getTime() + 5 * 60 * 1000); + const wakeInstant = Temporal.Instant.fromEpochMilliseconds(wakeTime.getTime()); let executions = 0; absurd.registerTask({ name: "sleep-until" }, async (_params, ctx) => { executions++; - await ctx.sleepUntil("sleep-step", wakeTime); + await ctx.sleepUntil("sleep-step", wakeInstant); return { executions }; }); @@ -240,7 +241,7 @@ describe("Step functionality", () => { expect(checkpointRow).toMatchObject({ checkpoint_name: "sleep-step", owner_run_id: runID, - state: wakeTime.toISOString(), + state: wakeInstant.toString(), }); const sleepingRun = await ctx.getRun(runID);