diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e55d1c1..c121e8f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,10 +17,7 @@ jobs: name: Lint and build steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm + - uses: jdx/mise-action@v3 - run: npm ci - run: npm run build - run: npm run format:check @@ -38,10 +35,7 @@ jobs: postgres: [13, 15, 17, 18] steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm + - uses: jdx/mise-action@v3 - uses: ankane/setup-postgres@v1 with: postgres-version: ${{ matrix.postgres }} diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..9707c37 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,2 @@ +[tools] +node = "24.12.0" diff --git a/package-lock.json b/package-lock.json index 9ec0920..d13102d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -913,6 +913,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1253,6 +1254,7 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/pg/-/pg-8.17.1.tgz", "integrity": "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ==", + "peer": true, "dependencies": { "pg-connection-string": "^2.10.0", "pg-pool": "^3.11.0", @@ -1428,6 +1430,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -1626,6 +1629,7 @@ "version": "46.8.0", "resolved": "https://registry.npmjs.org/slonik/-/slonik-46.8.0.tgz", "integrity": "sha512-1sBzz4k5eowrzaGvP0gdZ41p62K1FxC7tDpA/IWvyGpf7A51eGNoAVfrg/mL9+OifWnKuru9opHPrdE15UHpfg==", + "peer": true, "dependencies": { "@opentelemetry/api": "^1.9.0", "@slonik/driver": "^46.8.0", @@ -1799,6 +1803,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src/table.test.ts b/src/table.test.ts index 4169380..ee683a1 100644 --- a/src/table.test.ts +++ b/src/table.test.ts @@ -250,6 +250,85 @@ describe("Table.minId", () => { expect(minId).toBe(2n); }); + + test("respects time filter for timestamptz column", async ({ + transaction, + }) => { + await transaction.query(sql.unsafe` + CREATE TABLE test_table ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + name TEXT + ) + `); + await transaction.query(sql.unsafe` + INSERT INTO test_table (created_at, name) VALUES + ('2024-01-01 00:00:00 UTC', 'old'), + ('2024-06-01 00:00:00 UTC', 'mid'), + ('2024-12-01 00:00:00 UTC', 'new') + `); + + const table = Table.parse("test_table"); + const minId = await table.minId(transaction, { + column: "created_at", + cast: "timestamptz", + startingTime: new Date("2024-06-01T00:00:00Z"), + }); + + expect(minId).toBe(2n); + }); + + test("returns null when time filter excludes all rows", async ({ + transaction, + }) => { + await transaction.query(sql.unsafe` + CREATE TABLE test_table ( + id BIGSERIAL PRIMARY KEY, + created_at DATE NOT NULL, + name TEXT + ) + `); + await transaction.query(sql.unsafe` + INSERT INTO test_table (created_at, name) VALUES + ('2024-01-01', 'old'), + ('2024-03-01', 'mid'), + ('2024-06-01', 'new') + `); + + const table = Table.parse("test_table"); + const minId = await table.minId(transaction, { + column: "created_at", + cast: "date", + startingTime: new Date("2025-01-01"), + }); + + expect(minId).toBeNull(); + }); + + test("respects time filter with string IDs", async ({ transaction }) => { + await transaction.query(sql.unsafe` + CREATE TABLE test_table ( + id TEXT PRIMARY KEY, + created_at DATE NOT NULL, + name TEXT + ) + `); + await transaction.query(sql.unsafe` + INSERT INTO test_table (id, created_at, name) VALUES + ('01ARZ3NDEKTSV4RRFFQ69G5FAA', '2024-01-01', 'old'), + ('01ARZ3NDEKTSV4RRFFQ69G5FAM', '2024-06-01', 'mid'), + ('01ARZ3NDEKTSV4RRFFQ69G5FAV', '2024-12-01', 'new') + `); + + const table = Table.parse("test_table"); + const minId = await table.minId(transaction, { + column: "created_at", + cast: "date", + startingTime: new Date("2024-06-01"), + }); + + expect(minId).toBe("01ARZ3NDEKTSV4RRFFQ69G5FAM"); + }); }); describe("Table.columns", () => { diff --git a/src/table.ts b/src/table.ts index 892cede..8213a4d 100644 --- a/src/table.ts +++ b/src/table.ts @@ -437,17 +437,23 @@ export class Table { const col = sql.identifier([await this.primaryKey(tx)]); let whereClause = sql.fragment`1 = 1`; + const orderByClauses = [sql.fragment`${col} ASC`]; + if (options?.column && options.cast && options.startingTime) { const timeCol = sql.identifier([options.column]); const startDate = formatDateForSql(options.startingTime, options.cast); + whereClause = sql.fragment`${timeCol} >= ${startDate}`; + orderByClauses.unshift(sql.fragment`${timeCol} ASC`); } const result = await tx.maybeOne( sql.type(z.object({ min_id: idValueSchema }))` - SELECT MIN(${col}) AS min_id + SELECT ${col} AS min_id FROM ${this.sqlIdentifier} WHERE ${whereClause} + ORDER BY ${sql.join(orderByClauses, sql.fragment`, `)} + LIMIT 1 `, );