From af7259235e91579aa315facfbc06fc18d1e0e310 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sun, 8 Nov 2026 04:25:14 +0000 Subject: [PATCH 1/3] test(list): list getters per pluck mode A list route evaluates every readable getter, whatever its pluck mode, so a getter only the default mode lists answers with the fields that mode plucked missing. --- src/tests/index/list_getter_modes.test.ts | 130 ++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 src/tests/index/list_getter_modes.test.ts diff --git a/src/tests/index/list_getter_modes.test.ts b/src/tests/index/list_getter_modes.test.ts new file mode 100644 index 0000000..0c01c04 --- /dev/null +++ b/src/tests/index/list_getter_modes.test.ts @@ -0,0 +1,130 @@ +import { expect } from "chai"; +import { z } from "zod"; +import { Controller } from "@antelopejs/interface-api"; +import { Access, AccessMode, Listable, ModelReference } from "../../metadata"; +import { + DataController, + DefaultRoutes, + RegisterDataController, +} from "../../index"; +import { + BasicDataModel, + Field, + Model, + RegisterSchema, + RegisterTable, + Table, +} from "@antelopejs/interface-database-decorators"; + +import { listResponseSchema, readJson } from "../internal/http"; +import { getSchemaInstance, request } from "../utils"; + +const TABLE = "list-getter-modes-people"; +const SCHEMA = "default"; +const LOCATION = "list-getter-modes"; +let fullNameReads = 0; + +@RegisterTable(TABLE, SCHEMA) +class Person extends Table { + @Field("string") + declare name: string; + @Field("string") + declare email: string; +} +class PersonModel extends BasicDataModel(Person, TABLE) {} + +@RegisterDataController() +class _PersonAPI extends DataController( + Person, + { + list: DefaultRoutes.List, + detailed: DefaultRoutes.WithOptions(DefaultRoutes.List, { + pluckMode: "detailed", + }), + everything: DefaultRoutes.WithOptions(DefaultRoutes.List, { + pluckMode: "detailed", + noPluck: true, + }), + }, + Controller(`/${LOCATION}`), +) { + @ModelReference() + @Model(PersonModel) + declare model: PersonModel; + + @Listable() + @Listable(true, "detailed") + @Access(AccessMode.ReadOnly) + declare name: string; + + @Listable() + @Access(AccessMode.ReadOnly) + declare email: string; + + @Listable(["name", "email"]) + @Access(AccessMode.ReadOnly) + get fullName(): string { + fullNameReads++; + return `${this.name} <${this.email}>`; + } + + @Listable([]) + @Access(AccessMode.ReadOnly) + get kind(): string { + return "person"; + } + + @Listable(["name"], "detailed") + @Access(AccessMode.ReadOnly) + get initial(): string { + return this.name.charAt(0); + } +} + +async function listRows(uri: string) { + const response = await request(LOCATION, uri, "GET"); + expect(response.status).to.equal(200); + const body = await readJson(listResponseSchema(z.looseObject({})), response); + return body.results; +} + +describe("List getters per pluck mode", () => { + before(async () => { + await RegisterSchema(SCHEMA); + await new PersonModel(getSchemaInstance(SCHEMA)).insert({ + name: "Alice", + email: "alice@example.com", + }); + }); + beforeEach(() => { + fullNameReads = 0; + }); + + it("evaluates a getter in the mode that lists it", async () => { + const rows = await listRows("list"); + expect(rows).to.deep.equal([ + { + name: "Alice", + email: "alice@example.com", + fullName: "Alice ", + kind: "person", + }, + ]); + expect(fullNameReads).to.equal(1); + }); + + it("leaves out a getter the route's mode does not list", async () => { + const rows = await listRows("detailed"); + expect(rows).to.deep.equal([{ name: "Alice", initial: "A" }]); + expect(fullNameReads).to.equal(0); + }); + + it("evaluates every getter on a route that does not pluck", async () => { + const [row] = await listRows("everything"); + expect(row).to.include({ + fullName: "Alice ", + kind: "person", + initial: "A", + }); + }); +}); From caeb4c6333b480b60a84951523f67ebc7713c04f Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sun, 8 Nov 2026 04:25:14 +0000 Subject: [PATCH 2/3] fix(list): leave out getters the route's pluck mode does not list Fixes #92 --- src/components.ts | 2 +- src/index.ts | 11 +++++++---- src/internal/read-properties.ts | 21 ++++++++++++++++++++- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/components.ts b/src/components.ts index 96c65d8..9d8cd76 100644 --- a/src/components.ts +++ b/src/components.ts @@ -351,7 +351,7 @@ export namespace Query { action?: string, onlyList?: boolean, ) { - return readProperties(obj, meta, dbData, action, onlyList); + return readProperties(obj, meta, dbData, action, { onlyList }); } /** diff --git a/src/index.ts b/src/index.ts index f38741d..611d146 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ import type { WriteDocument } from "@antelopejs/interface-database/common"; import { Parameters, Query, Validation } from "./components"; import { isWriteDocument, isWriteValue } from "./internal/guards"; import { prototypeOf } from "./internal/prototypes"; +import { ReadProperties as readProperties } from "./internal/read-properties"; import { DataAPIMeta, type DataControllerCallback, @@ -221,12 +222,12 @@ export namespace DefaultRoutes { { exposeDeferredJoined: true }, ); - const pluck: Set | undefined = - meta.pluck[params.pluckMode ?? "list"]; + const pluckMode = params.pluckMode ?? "list"; + const pluck: Set | undefined = meta.pluck[pluckMode]; assert( params.noPluck || pluck, 400, - `No fields found for pluckMode '${params.pluckMode ?? "list"}'`, + `No fields found for pluckMode '${pluckMode}'`, ); const offset = params.offset || 0; @@ -248,7 +249,9 @@ export namespace DefaultRoutes { const entryInstance = model.constructor.fromDatabase(entry); assert(entryInstance, 500, "The database answered an empty row."); Validation.Unlock(this, meta, entryInstance); - return Query.ReadProperties(this, meta, entryInstance, "list"); + return readProperties(this, meta, entryInstance, "list", { + listMode: params.noPluck ? undefined : pluckMode, + }); }), ); diff --git a/src/internal/read-properties.ts b/src/internal/read-properties.ts index ba3ad5e..b353cf7 100644 --- a/src/internal/read-properties.ts +++ b/src/internal/read-properties.ts @@ -5,15 +5,31 @@ import type { DataControllerInstance, RowFields, } from "../metadata"; +import type { FieldData } from "./field-types"; import { isFunction } from "./guards"; +import { ownEntry } from "./meta-records"; import { fieldsOf, prototypeOf } from "./prototypes"; +/** Which fields a read leaves out. */ +interface ReadFilter { + /** Leaves out the fields that no list mode lists. */ + onlyList?: boolean; + /** Leaves out the getters that this list mode does not list. */ + listMode?: string; +} + +function listedIn(field: FieldData, mode: string) { + return ( + field.listable !== undefined && ownEntry(field.listable, mode) !== undefined + ); +} + export async function ReadProperties( obj: DataControllerInstance, meta: DataAPIMeta, dbData: RowFields, action?: string, - onlyList?: boolean, + { onlyList, listMode }: ReadFilter = {}, ) { const readable = meta.readable[action ?? "_default"] ?? meta.readable._default; @@ -32,6 +48,9 @@ export async function ReadProperties( if (onlyList && !field.listable) { continue; } + if (listMode !== undefined && !listedIn(field, listMode)) { + continue; + } const val: unknown = field.desc?.get?.apply(instance); res[key] = await (isFunction(val) ? val() : val); } From 568eb144798775e459b64f67ccb0e1e2c26ca91c Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sun, 8 Nov 2026 05:09:40 +0000 Subject: [PATCH 3/3] docs(verify): drive issue-92-getter-mode as a passing journey --- .agents/skills/verify-interface/features/README.md | 2 +- .agents/skills/verify-interface/features/listing.md | 6 +----- .agents/skills/verify-interface/journeys-probe-1.ts | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.agents/skills/verify-interface/features/README.md b/.agents/skills/verify-interface/features/README.md index bc71a02..73c8cd1 100644 --- a/.agents/skills/verify-interface/features/README.md +++ b/.agents/skills/verify-interface/features/README.md @@ -22,7 +22,7 @@ One file per feature. `covers:` lists every source file that computes something ## Known issues, driven on purpose -Three journeys fail on head and cite their issue: `issue-90-edit-index` ([#90](https://github.com/AntelopeJS/interface-data-api/issues/90)), `issue-92-getter-mode` ([#92](https://github.com/AntelopeJS/interface-data-api/issues/92)) and `issue-93-multi-foreign` ([#93](https://github.com/AntelopeJS/interface-data-api/issues/93)). A verdict that runs their features is FAIL until the issue is fixed; the fix turns the journey green. +Two journeys fail on head and cite their issue: `issue-90-edit-index` ([#90](https://github.com/AntelopeJS/interface-data-api/issues/90)) and `issue-93-multi-foreign` ([#93](https://github.com/AntelopeJS/interface-data-api/issues/93)). A verdict that runs their features is FAIL until the issue is fixed; the fix turns the journey green. ## Not driven diff --git a/.agents/skills/verify-interface/features/listing.md b/.agents/skills/verify-interface/features/listing.md index 6297bc5..781395d 100644 --- a/.agents/skills/verify-interface/features/listing.md +++ b/.agents/skills/verify-interface/features/listing.md @@ -35,7 +35,7 @@ A client pages, sorts and narrows the rows of a table through `GET /resource/lis - `pluck-modes`: the default list mode answers the listable fields with read access, a `detailed` mode answers its own fields and does not inherit the default ones, a field can be left out of a mode, a field that is not listable is still read by `get`, and a getter lists the database fields it reads. - `pagination`: a list answers `results`, `total`, `offset` and `limit`; `limit` and `offset` page the rows, the last page is short, the default is 10 and a route without `maxPage` caps `limit` at 100. - `sorting`: `sortKey` and `sortDirection` sort on indexed and non-indexed `@Sortable` fields, any other direction sorts ascending, and a field without `@Sortable` answers `Field is not sortable.`. -- `issue-92-getter-mode`: fails on purpose until [#92](https://github.com/AntelopeJS/interface-data-api/issues/92) is fixed: a getter that a list mode does not declare is left out of that mode. +- `issue-92-getter-mode`: a getter that a list mode does not declare is left out of that mode ([#92](https://github.com/AntelopeJS/interface-data-api/issues/92)). ## How to get to it (user POV) @@ -48,7 +48,3 @@ pnpm exec antelopejs-verify journey "$RUN" listing ``` Each journey compiles the consumer in `fixture/shop-1.ts.txt` and `fixture/shop-2.ts.txt`, joined in that order, starts `ajs project dev` with `@antelopejs/api` and `@antelopejs/mongodb` against a Docker MongoDB, drives the routes with curl and reads the writes back with `mongosh`. - -## Gotchas - -- `/people/brief` lists a getter that only the default mode declares: [#92](https://github.com/AntelopeJS/interface-data-api/issues/92), driven by `issue-92-getter-mode`. diff --git a/.agents/skills/verify-interface/journeys-probe-1.ts b/.agents/skills/verify-interface/journeys-probe-1.ts index f8de220..c26bc4b 100644 --- a/.agents/skills/verify-interface/journeys-probe-1.ts +++ b/.agents/skills/verify-interface/journeys-probe-1.ts @@ -137,7 +137,7 @@ export const issue90 = shopJourney("fails on purpose until #90 is fixed: edit wi context.expect(row.folder, [equal("#90: the row found through the index holds the new title", pick(row.rows[0] ?? null, "title"), "New")]); }); -export const issue92 = shopJourney("fails on purpose until #92 is fixed: a getter that a list mode does not declare is left out of it", async (shop) => { +export const issue92 = shopJourney("#92: a getter that a list mode does not declare is left out of it", async (shop) => { const { context } = shop; await seed(shop, USERS); const brief = await shop.request("people in the brief mode", "GET", "/people/brief");