diff --git a/.agents/skills/verify-interface/features/README.md b/.agents/skills/verify-interface/features/README.md index 20ba0ce..8856eb5 100644 --- a/.agents/skills/verify-interface/features/README.md +++ b/.agents/skills/verify-interface/features/README.md @@ -20,10 +20,6 @@ One file per feature. `covers:` lists every source file that computes something | [metadata](metadata.md) | A module author reads metadata and declares controllers. | effective-modes, declaration-errors, exports-runtime, consumer-compiles, derived-own-model, foreign-target-meta | | [smoke](smoke.md) | The fixed journey every verdict runs. | every-function, smoke-roundtrip | -## Known issues, driven on purpose - -One journey fails on head and cites its issue: `issue-90-edit-index` ([#90](https://github.com/AntelopeJS/interface-data-api/issues/90)). A verdict that runs its feature is FAIL until the issue is fixed; the fix turns the journey green. - ## Not driven Every documented behaviour of the README, `docs/` and the TSDoc has a journey above, or a row here with the reason. diff --git a/.agents/skills/verify-interface/features/route-options.md b/.agents/skills/verify-interface/features/route-options.md index 625f15a..1fb6a4c 100644 --- a/.agents/skills/verify-interface/features/route-options.md +++ b/.agents/skills/verify-interface/features/route-options.md @@ -38,7 +38,7 @@ A route is customized with `DefaultRoutes.WithOptions`; its options win over the - `option-overrides`: `maxPage` caps the `limit` of a query and is the page size without one, `noMandatory` as a route option lets a mandatory field be cleared while the plain route refuses it, any non-empty string (`"false"` included) turns the option on, `index` reads through a secondary index, `noPluck` still answers only readable fields, and a `pluckMode` no field declares answers `No fields found for pluckMode 'nothing'`. - `list-unknown`: a list route with `noPluck` answers 200 for a `pluckMode` no field declares, and a URL no route matches answers 404. -- `issue-90-edit-index`: fails on purpose until [#90](https://github.com/AntelopeJS/interface-data-api/issues/90) is fixed: an `edit` with the `index` option updates the row the index found. +- `issue-90-edit-index`: guards [#90](https://github.com/AntelopeJS/interface-data-api/issues/90): an `edit` with the `index` option updates the row the index found. - `no-mandatory-strings`: `noMandatory` skips the mandatory check for any non-empty string, `"false"` included, on `edit` and on `new`, and not for `""` or when absent. - `no-pluck-strings`: `noPluck` skips the pluck-mode check only when true. - `no-model-500`: a controller whose model reference holds no model answers 500. diff --git a/.agents/skills/verify-interface/journeys-probe-1.ts b/.agents/skills/verify-interface/journeys-probe-1.ts index c086916..a2786a2 100644 --- a/.agents/skills/verify-interface/journeys-probe-1.ts +++ b/.agents/skills/verify-interface/journeys-probe-1.ts @@ -128,7 +128,7 @@ export const readOverrides = shopJourney("a per-action read override hides or gr context.expect(row.folder, [equal("a write-only field is written on new", pick(row.rows[0] ?? null, "nickname"), "nn")]); }); -export const issue90 = shopJourney("fails on purpose until #90 is fixed: edit with the index option updates the row the index found", async (shop) => { +export const issue90 = shopJourney("#90: edit with the index option updates the row the index found", async (shop) => { const { context } = shop; await seed(shop, PROBES); const edited = await shop.request("edit p1 by its tag", "PUT", "/probes/slugEdit?id=A", { body: '{"title":"New"}' }); diff --git a/docs/3.routes.md b/docs/3.routes.md index 3709d91..b6bbf9c 100644 --- a/docs/3.routes.md +++ b/docs/3.routes.md @@ -263,10 +263,10 @@ The New route takes this option. The Edit route takes these options. -| Option | Type | Description | -| ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `index` | `string` | Find the record and read the response through a secondary index. The update itself targets the record whose primary key equals `id`. | -| `noMandatory` | `string` | Skip the mandatory field check | +| Option | Type | Description | +| ------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- | +| `index` | `string` | Find the record through a secondary index instead of the primary key. The update and the response target the record found. | +| `noMandatory` | `string` | Skip the mandatory field check | ## Amending the body of `new` and `edit` diff --git a/src/index.ts b/src/index.ts index 611d146..7bcbfcf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,7 @@ import { import type { WriteDocument } from "@antelopejs/interface-database/common"; import { Parameters, Query, Validation } from "./components"; -import { isWriteDocument, isWriteValue } from "./internal/guards"; +import { isString, isWriteDocument, isWriteValue } from "./internal/guards"; import { prototypeOf } from "./internal/prototypes"; import { ReadProperties as readProperties } from "./internal/read-properties"; import { @@ -308,6 +308,8 @@ export namespace DefaultRoutes { const stored = await Query.Get(model.table, params.id, params.index); assert(stored, 404, "Not Found"); + const { _id: primaryKey } = stored; + assert(isString(primaryKey), 500, "Stored row has no primary key"); const dbData = await Query.WriteProperties( this, @@ -319,11 +321,9 @@ export namespace DefaultRoutes { Validation.Lock(this, meta, dbData); triggerEvent(dbData, "update"); - await model.table.get(params.id).update(writtenRow(dbData)); + await model.table.get(primaryKey).update(writtenRow(dbData)); - return readRow(this, meta, { - params: { id: params.id, index: params.index }, - }); + return readRow(this, meta, { params: { id: primaryKey } }); } async function deleteRows( diff --git a/src/tests/index/edit_index.test.ts b/src/tests/index/edit_index.test.ts new file mode 100644 index 0000000..c314558 --- /dev/null +++ b/src/tests/index/edit_index.test.ts @@ -0,0 +1,97 @@ +import { expect } from "chai"; +import { Controller } from "@antelopejs/interface-api"; +import { Access, AccessMode, ModelReference } from "../../metadata"; +import { + DataController, + DefaultRoutes, + RegisterDataController, +} from "../../index"; +import { + BasicDataModel, + Field, + Index, + Model, + RegisterSchema, + RegisterTable, + Table, +} from "@antelopejs/interface-database-decorators"; + +import { editRequest, getSchemaInstance } from "../utils"; + +const TABLE = "edit-index-articles"; +const SCHEMA = "default"; +const LOCATION = "edit-index"; + +@RegisterTable(TABLE, SCHEMA) +class Article extends Table { + declare _id: string; + + @Index() + @Field("string") + declare slug: string; + + @Field("string") + declare title: string; +} +class ArticleModel extends BasicDataModel(Article, TABLE) {} + +@RegisterDataController() +class _ArticleAPI extends DataController( + Article, + { edit: DefaultRoutes.WithOptions(DefaultRoutes.Edit, { index: "slug" }) }, + Controller(`/${LOCATION}`), +) { + @ModelReference() + @Model(ArticleModel) + declare model: ArticleModel; + + @Access(AccessMode.ReadWrite) + declare slug: string; + + @Access(AccessMode.ReadWrite) + declare title: string; +} + +function articles() { + return getSchemaInstance(SCHEMA).table(TABLE); +} + +describe("Edit with the index option", () => { + before(async () => { + await RegisterSchema(SCHEMA); + await articles().insert([ + { _id: "a1", slug: "hello", title: "Old" }, + { _id: "hello", slug: "decoy", title: "Decoy" }, + { _id: "a2", slug: "rename-me", title: "Before" }, + ]); + }); + + it("updates the row the index finds, not the row whose primary key is the id", async () => { + const response = await editRequest( + LOCATION, + { title: "New" }, + { id: "hello" }, + ); + expect(response.status).to.equal(200); + expect(await response.json()).to.deep.include({ title: "New" }); + expect(await articles().get("a1")).to.deep.include({ title: "New" }); + expect(await articles().get("hello")).to.deep.include({ title: "Decoy" }); + }); + + it("answers with the edited row when the edit changes the indexed field", async () => { + const response = await editRequest( + LOCATION, + { slug: "renamed", title: "After" }, + { id: "rename-me" }, + ); + expect(response.status).to.equal(200); + expect(await response.json()).to.deep.include({ + slug: "renamed", + title: "After", + }); + expect(await articles().get("a2")).to.deep.include({ + slug: "renamed", + title: "After", + }); + }); +});