From 2c22bb66404d4c510c1ce5446201193b65115605 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:39:56 -0400 Subject: [PATCH 1/4] test(ts): assert queryOrdered reports the projection order --- .../integration/binding/query-ordered.test.ts | 59 +++++++++++++++++++ .../tests/integration/hermetic/index.test.ts | 17 ++++++ 2 files changed, 76 insertions(+) create mode 100644 packages/ts/tests/integration/binding/query-ordered.test.ts diff --git a/packages/ts/tests/integration/binding/query-ordered.test.ts b/packages/ts/tests/integration/binding/query-ordered.test.ts new file mode 100644 index 000000000..290f69faa --- /dev/null +++ b/packages/ts/tests/integration/binding/query-ordered.test.ts @@ -0,0 +1,59 @@ +import { readFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DirSQL } from "dirsql"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +describe("queryOrdered", () => { + let dir: string; + let db: DirSQL; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "dirsql-query-ordered-")); + await writeFile( + join(dir, "users.json"), + JSON.stringify([ + { name: "Alice", age: 30 }, + { name: "Bob", age: 25 }, + ]), + ); + db = new DirSQL({ + root: dir, + tables: [ + { + name: "users", + ddl: "CREATE TABLE users (age INTEGER, name TEXT)", + glob: "users.json", + onFile: (filePath: string) => + JSON.parse(readFileSync(filePath, "utf8")), + }, + ], + }); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("reports the columns in the order the SELECT list names them", async () => { + const result = await db.queryOrdered( + "SELECT name, age, 1 AS a FROM users ORDER BY name", + ); + expect(result.columns).toEqual(["name", "age", "a"]); + }); + + it("carries the same rows query returns", async () => { + const sql = "SELECT name, age FROM users ORDER BY name"; + const result = await db.queryOrdered(sql); + expect(result.rows).toEqual(await db.query(sql)); + expect(result.rows[0]).toEqual({ name: "Alice", age: 30 }); + }); + + it("reports the columns of a query that matches no rows", async () => { + const result = await db.queryOrdered( + "SELECT name, age FROM users WHERE age > 100", + ); + expect(result).toEqual({ columns: ["name", "age"], rows: [] }); + }); +}); diff --git a/packages/ts/tests/integration/hermetic/index.test.ts b/packages/ts/tests/integration/hermetic/index.test.ts index 8365db839..98d7c0071 100644 --- a/packages/ts/tests/integration/hermetic/index.test.ts +++ b/packages/ts/tests/integration/hermetic/index.test.ts @@ -36,6 +36,7 @@ vi.mock("node:module", async (importOriginal) => { type FakeInner = { query: ReturnType; + queryOrdered: ReturnType; startWatcher: ReturnType; pollEvents: ReturnType; }; @@ -43,6 +44,7 @@ type FakeInner = { function makeInner(overrides: Partial = {}): FakeInner { return { query: vi.fn().mockResolvedValue([]), + queryOrdered: vi.fn().mockResolvedValue({ columns: [], rows: [] }), startWatcher: vi.fn().mockResolvedValue(undefined), pollEvents: vi.fn().mockResolvedValue([]), ...overrides, @@ -174,6 +176,21 @@ describe("DirSQL delegation", () => { expect(inner.query).toHaveBeenCalledWith(sql); }); + it("queryOrdered awaits ready then forwards the SQL untouched", async () => { + const result = { + columns: ["name", "age"], + rows: [{ name: "ada", age: 1 }], + }; + const inner = makeInner({ + queryOrdered: vi.fn().mockResolvedValue(result), + }); + openAsync.mockResolvedValue(inner); + const db = new DirSQL({ root: "/data" }); + const sql = "SELECT name, age FROM users"; + expect(await db.queryOrdered(sql)).toEqual(result); + expect(inner.queryOrdered).toHaveBeenCalledWith(sql); + }); + it("forwards a path-table name without rewriting it", async () => { // Path-table resolution lives in the core; the SDK must not rewrite, // quote, or normalize the name on the way down. From 42282d85744861cc8ffbebb66dbb97bcfe31988e Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:44:38 -0400 Subject: [PATCH 2/4] feat(ts): add queryOrdered, returning the projection order with the rows Forward the core's query_ordered through napi as { columns, rows }, so a caller can render columns in the order the SELECT list names them. --- PARITY.md | 3 +- docs/reference/sdk.md | 19 +++++ .../changelog.d/2026-09-24-query-ordered.md | 6 ++ packages/ts/napi/src/lib.rs | 81 ++++++++++++++++++- packages/ts/src/core.test.ts | 8 ++ packages/ts/src/core.ts | 8 +- packages/ts/src/dirsql.test.ts | 10 +++ packages/ts/src/dirsql.ts | 25 ++++++ packages/ts/src/index.test.ts | 8 +- packages/ts/src/index.ts | 1 + 10 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 packages/ts/changelog.d/2026-09-24-query-ordered.md diff --git a/PARITY.md b/PARITY.md index 98df17d03..752892b4c 100644 --- a/PARITY.md +++ b/PARITY.md @@ -88,7 +88,7 @@ core, so all three SDKs change at once. **No drift.** |----------------------------|------------------------------------------------|------------------------------------------------------|---------------------------------------------------------| | Constructor | `DirSQL(root=None, *, tables=None, ignore=None, no_ignore=False, config=None, persist=False, persist_path=None, extensions=None)` | `DirSQL::builder().root(..).tables(..).ignore(..).config(..).persist(Option).extensions(..).build()` (also `DirSQL::new`/`with_ignore` shortcuts) | `new DirSQL(configPath)` or `new DirSQL({ root?, tables?, ignore?, config?, persist?, persistPath?, extensions?, noIgnore? })` + `await db.ready` | | Query (read-only; rejects non-SELECT) | `db.query(sql) -> list[dict]` | `db.query(sql) -> Result>` | `await db.query(sql) -> Record[]` (runs on libuv threadpool) | -| Query carrying the projection order | — (drift, #1173) | `db.query_ordered(sql) -> Result` (`columns` + `rows`) | — (drift, #1174) | +| Query carrying the projection order | — (drift, #1173) | `db.query_ordered(sql) -> Result` (`columns` + `rows`) | `await db.queryOrdered(sql) -> QueryResult` (`columns` + `rows`) | | Start watcher | `db._start_watcher()` | `db.start_watching()` | `await db.startWatcher()` (runs on libuv threadpool) | | Poll events | `db._poll_events(ms)` | `db.poll_events(duration)` | `await db.pollEvents(ms)` (runs on libuv threadpool) | | Watch (channel/stream) | `async for event in db.watch()` (via `_async.py`) | `db.watch() -> WatchStream` (channel) | `for await (const ev of db.watch())` | @@ -504,6 +504,7 @@ incl. #313). | `ddl` batch SQLite rejects: rolled back, error prefixed with the entry (#956) | core | Y (`ddl_batch.rs`, `sdk.rs`) | core | | Declared `name` resolving to a virtual table errors (#956) | core | Y (`ddl_batch.rs`) | core | | Query rejects writes | Y | Y | Y | +| Query reports the projection order | N (#1173) | Y (`column_order.rs`) | Y (`query-ordered.test.ts`) | | Write-rejection edge matrix (leading comments, mixed case, CTE/whitespace allowed) | core | Y (`readonly_query.rs`) | core | | Internal `_dirsql_*` columns hidden from `SELECT *` | Y | Y | Y | | `_dirsql_*` filter robustness (comment/string-literal bypass) | core | Y (`code_review_findings.rs`) | core | diff --git a/docs/reference/sdk.md b/docs/reference/sdk.md index b940775a2..234445fef 100644 --- a/docs/reference/sdk.md +++ b/docs/reference/sdk.md @@ -239,6 +239,25 @@ than `Number.MAX_SAFE_INTEGER`, 2^53 − 1) cannot be represented as a JS than return a rounded value. Python's `int` is unbounded, so it always round-trips. +### `queryOrdered` + +::: code-group + +```typescript [TypeScript] +await db.queryOrdered(sql: string) -> { columns: string[]; rows: Record[] } +``` + +```rust [Rust] +db.query_ordered(sql: &str) -> Result // QueryResult { columns, rows } +``` + +::: + +Runs `query` and also returns the column names in the order the SELECT list +names them. Use it to render columns in the order the caller asked for: a +row's keys do not carry that order. In TypeScript, object keys that look like +integers always enumerate first. + ### `watch` ::: code-group diff --git a/packages/ts/changelog.d/2026-09-24-query-ordered.md b/packages/ts/changelog.d/2026-09-24-query-ordered.md new file mode 100644 index 000000000..44f343a60 --- /dev/null +++ b/packages/ts/changelog.d/2026-09-24-query-ordered.md @@ -0,0 +1,6 @@ +**Added** + +- `db.queryOrdered(sql)` resolves to `{ columns, rows }`: the rows `query` + returns, plus the column names in the order the SELECT list names them. A + row object cannot carry that order, since integer-like keys always enumerate + first. The `QueryResult` type is exported from the package root. (#1174) diff --git a/packages/ts/napi/src/lib.rs b/packages/ts/napi/src/lib.rs index b9fd7e6f8..76f8b3071 100644 --- a/packages/ts/napi/src/lib.rs +++ b/packages/ts/napi/src/lib.rs @@ -20,8 +20,8 @@ use dirsql::extension_resolution::{self, ConfigSource as CoreConfigSource, PlanEntry}; use dirsql::{ - DirSQL as CoreDirSQL, Extension, PreparedBuild, RawFileEvent, Row, RowEvent as CoreRowEvent, - Table, Value, flatten_row_event, + DirSQL as CoreDirSQL, Extension, PreparedBuild, QueryResult as CoreQueryResult, RawFileEvent, + Row, RowEvent as CoreRowEvent, Table, Value, flatten_row_event, }; use napi::Task; use napi::bindgen_prelude::*; @@ -327,6 +327,13 @@ impl DirSQL { AsyncTask::new(QueryTask { inner, sql }) } + /// [`query`](Self::query), plus the column order the SELECT list names. + #[napi(js_name = "queryOrdered", ts_return_type = "Promise")] + pub fn query_ordered(&self, sql: String) -> AsyncTask { + let inner = self.inner.borrow().as_ref().cloned(); + AsyncTask::new(QueryOrderedTask { inner, sql }) + } + /// Start the file watcher. Must be called before pollEvents. /// /// Runs on the libuv threadpool so the JS event loop stays responsive @@ -469,6 +476,53 @@ impl Task for QueryTask { } } +/// One query's rows plus the column order the SELECT list names. A row is a +/// JS object, which cannot carry that order: integer-like keys enumerate +/// first, in ascending order, whatever order they were inserted in. +/// +/// Output-only (`object_from_js = false`): JS never constructs one. +#[napi(object, object_from_js = false)] +pub struct QueryResult { + pub columns: Vec, + #[napi(ts_type = "Record[]")] + pub rows: Vec>, +} + +fn query_result_to_js(result: &CoreQueryResult) -> Result { + Ok(QueryResult { + columns: result.columns.clone(), + rows: result + .rows + .iter() + .map(value_row_to_js) + .collect::>()?, + }) +} + +/// [`QueryTask`], for `DirSQL::query_ordered`. +pub struct QueryOrderedTask { + inner: Option, + sql: String, +} + +impl Task for QueryOrderedTask { + type Output = QueryResult; + type JsValue = QueryResult; + + fn compute(&mut self) -> Result { + let inner = self + .inner + .as_ref() + .ok_or_else(|| Error::new(Status::GenericFailure, "DirSQL instance closed"))?; + let result = inner.query_ordered(&self.sql).map_err(to_napi_err)?; + query_result_to_js(&result) + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { + Ok(output) + } +} + /// Runs `DirSQL::start_watching` on the libuv threadpool. Idempotent on the /// core side, so repeated calls from JS are still safe. pub struct StartWatcherTask { @@ -1027,6 +1081,29 @@ mod tests { assert!(value_row_to_js(&row).is_err()); } + #[test] + fn query_result_to_js_keeps_the_column_order_and_rows() { + let js = query_result_to_js(&CoreQueryResult { + columns: vec!["z".into(), "k".into()], + rows: vec![one_row()], + }) + .unwrap(); + assert_eq!(js.columns, vec!["z".to_string(), "k".to_string()]); + assert!(matches!(js.rows[0].get("k"), Some(JsRowValue::Integer(7)))); + } + + #[test] + fn query_result_to_js_propagates_unsafe_integer() { + let row = HashMap::from([("k".to_string(), Value::Integer(JS_MAX_SAFE_INTEGER + 1))]); + assert!( + query_result_to_js(&CoreQueryResult { + columns: vec!["k".into()], + rows: vec![row], + }) + .is_err() + ); + } + #[test] fn row_event_to_js_insert() { let ev = CoreRowEvent::Insert { diff --git a/packages/ts/src/core.test.ts b/packages/ts/src/core.test.ts index f5a1a7a62..d18827d2a 100644 --- a/packages/ts/src/core.test.ts +++ b/packages/ts/src/core.test.ts @@ -133,6 +133,14 @@ describe("NativeDirSQL interface", () => { expect(result).not.toBeInstanceOf(Promise); }); + it("includes queryOrdered() resolving to columns plus rows", async () => { + const result = { columns: ["b", "a"], rows: [{ a: 1, b: 2 }] }; + const queryOrdered: NativeDirSQL["queryOrdered"] = vi + .fn() + .mockResolvedValue(result); + expect(await queryOrdered("SELECT b, a")).toEqual(result); + }); + it("includes close() method for cleanup (#598)", () => { const instance = { query: vi.fn().mockResolvedValue([]), diff --git a/packages/ts/src/core.ts b/packages/ts/src/core.ts index 091f9f5c4..e29cf3506 100644 --- a/packages/ts/src/core.ts +++ b/packages/ts/src/core.ts @@ -1,12 +1,18 @@ // Lazy access to the napi-rs core module. -import type { ExtensionSpec, RowEvent, ScanFailure } from "./dirsql.js"; +import type { + ExtensionSpec, + QueryResult, + RowEvent, + ScanFailure, +} from "./dirsql.js"; import { loadNativeCore as defaultLoadNativeCore } from "./load-native-core.js"; import type { TableDef } from "./table.js"; // Shape of the napi-rs-exposed class. The `DirSQL` wrapper drives this. export interface NativeDirSQL { query(sql: string): Promise[]>; + queryOrdered(sql: string): Promise; startWatcher(): Promise; pollEvents(timeoutMs: number): Promise; // Synchronous: reads a list the scan already produced, no threadpool hop. diff --git a/packages/ts/src/dirsql.test.ts b/packages/ts/src/dirsql.test.ts index 53849970d..9cf23d5f9 100644 --- a/packages/ts/src/dirsql.test.ts +++ b/packages/ts/src/dirsql.test.ts @@ -20,6 +20,7 @@ vi.mock("./resolve-config-extensions.js", async () => ({ type FakeInner = { query: ReturnType; + queryOrdered: ReturnType; startWatcher: ReturnType; pollEvents: ReturnType; scanFailures: ReturnType; @@ -37,6 +38,7 @@ function installFakeCore(inner: FakeInner) { function makeInner(overrides: Partial = {}): FakeInner { return { query: vi.fn().mockResolvedValue([]), + queryOrdered: vi.fn().mockResolvedValue({ columns: [], rows: [] }), startWatcher: vi.fn().mockResolvedValue(undefined), pollEvents: vi.fn().mockResolvedValue([]), scanFailures: vi.fn(() => []), @@ -317,6 +319,14 @@ describe("DirSQL", () => { expect(inner.query).toHaveBeenCalledWith("SELECT 1"); }); + it("queryOrdered awaits ready then forwards to the inner instance", async () => { + const result = { columns: ["b", "a"], rows: [{ a: 1, b: 2 }] }; + inner.queryOrdered.mockResolvedValue(result); + const db = new DirSQL({ root: "/d" }); + expect(await db.queryOrdered("SELECT b, a")).toEqual(result); + expect(inner.queryOrdered).toHaveBeenCalledWith("SELECT b, a"); + }); + it("scanFailures awaits ready then forwards to the inner instance", async () => { const failures = [{ path: "bad.json", message: "boom" }]; const withFailures = makeInner({ scanFailures: vi.fn(() => failures) }); diff --git a/packages/ts/src/dirsql.ts b/packages/ts/src/dirsql.ts index 304948994..b8c64194e 100644 --- a/packages/ts/src/dirsql.ts +++ b/packages/ts/src/dirsql.ts @@ -89,6 +89,17 @@ export interface RowEvent { filePath?: string | null; } +/** The result of {@link DirSQL.queryOrdered}. */ +export interface QueryResult { + /** + * Column names in the order the SELECT list names them. Iterate this, not + * a row's keys: object keys that look like integers enumerate first. + */ + columns: string[]; + /** The rows, exactly as {@link DirSQL.query} returns them. */ + rows: Record[]; +} + /** * One file the initial scan could not index. * @@ -223,6 +234,20 @@ export class DirSQL { return this._inner.query(sql); } + /** + * {@link query}, plus the column order the SELECT list names. Use it to + * render columns in the order the caller asked for. + * + * ```ts + * const { columns, rows } = await db.queryOrdered("SELECT path, size FROM './'"); + * // columns: ["path", "size"] + * ``` + */ + async queryOrdered(sql: string): Promise { + await this.ready; + return this._inner.queryOrdered(sql); + } + /** * The files the initial scan could not index, each with its root-relative * `path` and the hook's own `message`. diff --git a/packages/ts/src/index.test.ts b/packages/ts/src/index.test.ts index 4d10a4b4e..f6720a687 100644 --- a/packages/ts/src/index.test.ts +++ b/packages/ts/src/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import * as api from "./index.js"; -import type { ScanFailure } from "./index.js"; +import type { QueryResult, ScanFailure } from "./index.js"; describe("public barrel", () => { it("re-exports the runtime values", () => { @@ -17,6 +17,12 @@ describe("public barrel", () => { expect(failure.message).toBe("boom"); }); + it("re-exports the QueryResult type with its documented shape", () => { + const result: QueryResult = { columns: ["a"], rows: [{ a: 1 }] }; + expect(result.columns).toEqual(["a"]); + expect(result.rows).toEqual([{ a: 1 }]); + }); + it("exposes exactly the public runtime exports", () => { expect(Object.keys(api).sort()).toEqual(["DirSQL", "Table"].sort()); }); diff --git a/packages/ts/src/index.ts b/packages/ts/src/index.ts index 3a209118e..a4954ff64 100644 --- a/packages/ts/src/index.ts +++ b/packages/ts/src/index.ts @@ -4,6 +4,7 @@ export { DirSQL } from "./dirsql.js"; export type { DirSQLOptions, ExtensionSpec, + QueryResult, RowEvent, ScanFailure, } from "./dirsql.js"; From fb2f0f20b27bc67977d0fe27459e40854b991fdf Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:44:46 -0400 Subject: [PATCH 3/4] e2e attestation for claude/1174-ts-projection-order --- .../e2e-attestations/claude-1174-ts-projection-order.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/ts/e2e-attestations/claude-1174-ts-projection-order.json diff --git a/packages/ts/e2e-attestations/claude-1174-ts-projection-order.json b/packages/ts/e2e-attestations/claude-1174-ts-projection-order.json new file mode 100644 index 000000000..7a4a839c5 --- /dev/null +++ b/packages/ts/e2e-attestations/claude-1174-ts-projection-order.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1790250286, + "exit_code": 0, + "commit": "42282d85744861cc8ffbebb66dbb97bcfe31988e", + "branch": "claude/1174-ts-projection-order" +} From 25ee975027497a6236a7540adf8652ceca3b4869 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:57:16 -0400 Subject: [PATCH 4/4] docs(ts): migration fragment for queryOrdered --- .../migrations.d/2026-09-24-query-ordered.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 packages/ts/migrations.d/2026-09-24-query-ordered.md diff --git a/packages/ts/migrations.d/2026-09-24-query-ordered.md b/packages/ts/migrations.d/2026-09-24-query-ordered.md new file mode 100644 index 000000000..13a1dd6bd --- /dev/null +++ b/packages/ts/migrations.d/2026-09-24-query-ordered.md @@ -0,0 +1,26 @@ +### `queryOrdered` reports the projection order + +**Summary** + +New `db.queryOrdered(sql)` resolves to `{ columns, rows }`, where `columns` +lists the result's column names in the order the SELECT list names them. +Additive: `query` is unchanged. + +**Required changes** + +_None._ + +**Deprecations removed** + +_None._ + +**Behavior changes without code changes** + +_None._ + +**Verification** + +```ts +const { columns } = await db.queryOrdered('SELECT name, 1 AS "2" FROM users'); +// columns: ["name", "2"] -- a row's keys enumerate "2" first +```