From 330e9fef7462c5f9ece01e5acd85221f50139e77 Mon Sep 17 00:00:00 2001 From: Michael Hadley Date: Thu, 19 Feb 2026 13:36:56 -0800 Subject: [PATCH 1/5] Use `mise` for node management --- .github/workflows/test.yml | 10 ++-------- .mise.toml | 2 ++ 2 files changed, 4 insertions(+), 8 deletions(-) create mode 100644 .mise.toml 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" From 9bf69c5e0383a2d3dc71dcf32c307e84038952c3 Mon Sep 17 00:00:00 2001 From: Michael Hadley Date: Thu, 19 Feb 2026 13:39:48 -0800 Subject: [PATCH 2/5] Use ORDER BY + LIMIT 1 instead of MIN() in minId query MIN() performs a full scan on large tables and can time out. Switching to ORDER BY + LIMIT 1 leverages existing indexes on the primary key and time columns. Co-Authored-By: Claude Opus 4.6 --- src/table.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/table.ts b/src/table.ts index 892cede..80abff3 100644 --- a/src/table.ts +++ b/src/table.ts @@ -437,17 +437,22 @@ export class Table { const col = sql.identifier([await this.primaryKey(tx)]); let whereClause = sql.fragment`1 = 1`; + let orderByClause = 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}`; + orderByClause = sql.fragment`${timeCol} ASC, ${col} 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 ${orderByClause} + LIMIT 1 `, ); From 395b0537e3e7329068a010c71c3453519ab7fa06 Mon Sep 17 00:00:00 2001 From: Michael Hadley Date: Thu, 19 Feb 2026 13:40:15 -0800 Subject: [PATCH 3/5] Update package-lock.json after install Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 5 +++++ 1 file changed, 5 insertions(+) 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", From c91f3a328a46a6edf7f7e4659bf7e21102bb964d Mon Sep 17 00:00:00 2001 From: Michael Hadley Date: Thu, 19 Feb 2026 13:43:41 -0800 Subject: [PATCH 4/5] Use array instead --- src/table.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/table.ts b/src/table.ts index 80abff3..8213a4d 100644 --- a/src/table.ts +++ b/src/table.ts @@ -437,13 +437,14 @@ export class Table { const col = sql.identifier([await this.primaryKey(tx)]); let whereClause = sql.fragment`1 = 1`; - let orderByClause = sql.fragment`${col} ASC`; + 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}`; - orderByClause = sql.fragment`${timeCol} ASC, ${col} ASC`; + orderByClauses.unshift(sql.fragment`${timeCol} ASC`); } const result = await tx.maybeOne( @@ -451,7 +452,7 @@ export class Table { SELECT ${col} AS min_id FROM ${this.sqlIdentifier} WHERE ${whereClause} - ORDER BY ${orderByClause} + ORDER BY ${sql.join(orderByClauses, sql.fragment`, `)} LIMIT 1 `, ); From 6a1cfdb57b32cf5523cbf4d430378e40ad1a905f Mon Sep 17 00:00:00 2001 From: Michael Hadley Date: Thu, 19 Feb 2026 13:47:36 -0800 Subject: [PATCH 5/5] Add test coverage for minId time filter options Cover timestamptz cast, null result when all rows are excluded, and string (ULID) primary keys with time filtering. Co-Authored-By: Claude Opus 4.6 --- src/table.test.ts | 79 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) 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", () => {