From 61aeec91947d6d3ec1b3263b4034b1dd17c7234c Mon Sep 17 00:00:00 2001 From: Robert DeRienzo Date: Fri, 9 Oct 2026 20:26:57 +0000 Subject: [PATCH 1/2] fix: docs delete no longer fails input validation `libscope docs delete ` looked the document up with `get-document {maxLength: 0}` to show its title, but get-document requires a positive maxLength, so every delete failed with "Invalid input for get-document: maxLength: Too small". Look the document up without maxLength; getDocumentView reads the whole content either way. The CLI contract tests replace runOperation with a spy, so inputs were never validated and the test asserted maxLength: 0. invoke() now checks each call's input against the operation's schema. --- src/cli/commands/docs.ts | 2 +- tests/unit/cli-commands.test.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/docs.ts b/src/cli/commands/docs.ts index 1604b08..8cdeccb 100644 --- a/src/cli/commands/docs.ts +++ b/src/cli/commands/docs.ts @@ -170,7 +170,7 @@ function registerEditCommands(docs: Command): void { .description("Delete a document with its chunks, links and ratings") .option("-y, --yes", "Do not ask for confirmation") .action(async (documentId: string, flags: { yes?: boolean }) => { - const view = await call(getDocumentOperation, { documentId, maxLength: 0 }); + const view = await call(getDocumentOperation, { documentId }); const title = view.document.title; const question = `Delete "${title}" (${documentId})? This cannot be undone.`; if (!(await confirmOrCancel(question, flags.yes))) return; diff --git a/tests/unit/cli-commands.test.ts b/tests/unit/cli-commands.test.ts index 2c7d6cf..c211e18 100644 --- a/tests/unit/cli-commands.test.ts +++ b/tests/unit/cli-commands.test.ts @@ -48,12 +48,18 @@ vi.mock("../../src/core/operations/index.js", async (importOriginal) => { const { runOperation } = await import("../../src/core/operations/index.js"); const { main, program } = await import("../../src/cli/index.js"); -/** Run `libscope --json ` and return [operation name, input] of each operation call. */ +/** + * Run `libscope --json ` and return [operation name, input] of each operation call. + * Each input must pass the operation's own schema, which the runOperation spy skips. + */ async function invoke(args: string[]): Promise> { vi.mocked(runOperation).mockClear(); await main(["node", "libscope", "--json", ...args]); expect(process.exitCode ?? 0).toBe(0); - return vi.mocked(runOperation).mock.calls.map(([op, , input]) => [op.name, input]); + return vi.mocked(runOperation).mock.calls.map(([op, , input]) => { + expect(op.input.safeParse(input ?? {}).error, `${op.name} input`).toBeUndefined(); + return [op.name, input]; + }); } let home: string; @@ -332,7 +338,7 @@ describe("CLI command -> operation contract", () => { it("docs delete looks the document up, then deletes it", async () => { const calls = await invoke(["docs", "delete", "d1", "-y"]); expect(calls).toEqual([ - ["get-document", { documentId: "d1", maxLength: 0 }], + ["get-document", { documentId: "d1" }], ["delete-document", { documentId: "d1" }], ]); }); From 279f58868d49406b0d56838568b4a64c7a21d79f Mon Sep 17 00:00:00 2001 From: Robert DeRienzo Date: Fri, 9 Oct 2026 20:36:50 +0000 Subject: [PATCH 2/2] fix: list documents updated in the same second newest first listDocuments sorted only by updated_at, which has one-second resolution, so documents updated in the same second came back oldest first and in an order that depended on timing. That made the lite test "gives the same results as LibScope.create" flaky on Node 20: when one database's inserts crossed a second boundary and the other's did not, the two lists differed. Break ties by rowid, newest insert first. --- src/core/documents.ts | 8 ++++++-- tests/unit/documents.test.ts | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/core/documents.ts b/src/core/documents.ts index 7b07440..2581814 100644 --- a/src/core/documents.ts +++ b/src/core/documents.ts @@ -166,7 +166,11 @@ function documentFilterSql(filters: DocumentFilters | undefined): { return { where: where + tagFilter.clause, params: [...params, ...tagFilter.params] }; } -/** List documents with optional filters, most recently updated first. */ +/** + * List documents with optional filters, most recently updated first. updated_at has + * one-second resolution, so documents updated in the same second are ordered by rowid, + * newest insert first. + */ export function listDocuments(db: Database.Database, options?: ListDocumentsOptions): Document[] { const { where, params } = documentFilterSql(options); const columns = DOC_COLUMNS.split(", ") @@ -174,7 +178,7 @@ export function listDocuments(db: Database.Database, options?: ListDocumentsOpti .join(", "); const rows = db .prepare( - `SELECT ${columns} FROM documents d ${where} ORDER BY d.updated_at DESC LIMIT ? OFFSET ?`, + `SELECT ${columns} FROM documents d ${where} ORDER BY d.updated_at DESC, d.rowid DESC LIMIT ? OFFSET ?`, ) .all(...params, options?.limit ?? 50, options?.offset ?? 0) as DocumentRow[]; diff --git a/tests/unit/documents.test.ts b/tests/unit/documents.test.ts index 08f0166..db4fdd4 100644 --- a/tests/unit/documents.test.ts +++ b/tests/unit/documents.test.ts @@ -128,6 +128,11 @@ describe("documents", () => { const docs = listDocuments(db, { library: "nonexistent" }); expect(docs.length).toBe(0); }); + + it("lists documents updated in the same second newest insert first", () => { + db.prepare("UPDATE documents SET updated_at = '2026-01-01 00:00:00'").run(); + expect(listDocuments(db).map((d) => d.id)).toEqual(["doc-3", "doc-2", "doc-1"]); + }); }); describe("updateDocument", () => {