From a6e133ceabb7d804483ee072eaf20077d89fcf2b Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Thu, 3 Sep 2026 08:57:28 +0200 Subject: [PATCH] DEV: Introduce warpdrive store (with compat layer) and migrate some models (#42147) Adds a WarpDrive store (`service:warp-store`, LegacyMode + JSON:API cache) alongside the existing `service:store`, routing requests through Discourse's `ajax()` helper. `RestCompatModel` bridges legacy `RestModel` callsites (`get`/`set`/`setProperties`, `store.createRecord`, `save`, `destroyRecord`) onto it. Converts **badge, user-badge, topic-details, bookmark, tag, tag-group, tag-info, tag-notification, tag-settings and archetype**, with per-model schemas, request builders and payload normalizers. Attributes outside a schema (plugin `add_to_serializer` fields, ad-hoc `create` keys) are retained separately so nothing the server sends is dropped. --- .skills/discourse-warpdrive-models/SKILL.md | 91 +++++ AI-AGENTS.md | 1 + .../admin/controllers/admin-user/badges.js | 12 +- .../admin/routes/admin-user/badges.js | 2 +- .../admin/templates/admin-user/badges.gjs | 2 +- frontend/discourse/app/app.js | 1 + .../app/components/modal/grant-badge.gjs | 7 +- .../discourse/app/data/builders/badges.js | 43 +++ .../discourse/app/data/builders/bookmarks.js | 21 + .../discourse/app/data/builders/helpers.js | 39 ++ .../app/data/builders/topic-details.js | 25 ++ .../app/data/builders/user-badges.js | 50 +++ .../discourse/app/data/extra-attributes.js | 58 +++ .../app/data/handlers/discourse-rest.js | 27 ++ frontend/discourse/app/data/jsonapi-utils.js | 65 ++++ frontend/discourse/app/data/normalize.js | 180 +++++++++ frontend/discourse/app/data/rest-compat.js | 255 +++++++++++++ .../discourse/app/data/schemas/archetype.js | 9 + .../app/data/schemas/badge-grouping.js | 16 + .../discourse/app/data/schemas/badge-type.js | 7 + frontend/discourse/app/data/schemas/badge.js | 38 ++ .../discourse/app/data/schemas/bookmark.js | 55 +++ .../discourse/app/data/schemas/helpers.js | 16 + frontend/discourse/app/data/schemas/index.js | 28 ++ .../discourse/app/data/schemas/tag-group.js | 13 + .../discourse/app/data/schemas/tag-info.js | 22 ++ .../app/data/schemas/tag-notification.js | 10 + .../app/data/schemas/tag-settings.js | 21 + frontend/discourse/app/data/schemas/tag.js | 24 ++ .../app/data/schemas/topic-details.js | 47 +++ .../discourse/app/data/schemas/user-badge.js | 27 ++ .../discourse/app/data/warp-rest-model.js | 198 ++++++++++ .../discourse/app/lib/grant-badge-utils.js | 41 +- .../discourse/app/lib/model-extensions.js | 43 ++- frontend/discourse/app/models/archetype.js | 14 +- .../discourse/app/models/badge-grouping.js | 8 +- frontend/discourse/app/models/badge.js | 137 ++----- frontend/discourse/app/models/bookmark.js | 142 ++++--- frontend/discourse/app/models/tag-group.js | 12 +- frontend/discourse/app/models/tag-info.js | 23 +- .../discourse/app/models/tag-notification.js | 9 +- frontend/discourse/app/models/tag-settings.js | 10 +- frontend/discourse/app/models/tag.js | 18 +- .../discourse/app/models/topic-details.js | 183 +++++---- frontend/discourse/app/models/user-badge.js | 235 +++++------- .../discourse/app/routes/topic/from-params.js | 45 ++- frontend/discourse/app/services/warp-store.js | 13 + frontend/discourse/babel.config.cjs | 7 + frontend/discourse/package.json | 5 + frontend/discourse/tests/setup-tests.js | 2 + .../unit/lib/warp-model-extensions-test.js | 358 ++++++++++++++++++ .../tests/unit/models/topic-details-test.js | 33 ++ pnpm-lock.yaml | 133 +++++++ 53 files changed, 2378 insertions(+), 503 deletions(-) create mode 100644 .skills/discourse-warpdrive-models/SKILL.md create mode 100644 frontend/discourse/app/data/builders/badges.js create mode 100644 frontend/discourse/app/data/builders/bookmarks.js create mode 100644 frontend/discourse/app/data/builders/helpers.js create mode 100644 frontend/discourse/app/data/builders/topic-details.js create mode 100644 frontend/discourse/app/data/builders/user-badges.js create mode 100644 frontend/discourse/app/data/extra-attributes.js create mode 100644 frontend/discourse/app/data/handlers/discourse-rest.js create mode 100644 frontend/discourse/app/data/jsonapi-utils.js create mode 100644 frontend/discourse/app/data/normalize.js create mode 100644 frontend/discourse/app/data/rest-compat.js create mode 100644 frontend/discourse/app/data/schemas/archetype.js create mode 100644 frontend/discourse/app/data/schemas/badge-grouping.js create mode 100644 frontend/discourse/app/data/schemas/badge-type.js create mode 100644 frontend/discourse/app/data/schemas/badge.js create mode 100644 frontend/discourse/app/data/schemas/bookmark.js create mode 100644 frontend/discourse/app/data/schemas/helpers.js create mode 100644 frontend/discourse/app/data/schemas/index.js create mode 100644 frontend/discourse/app/data/schemas/tag-group.js create mode 100644 frontend/discourse/app/data/schemas/tag-info.js create mode 100644 frontend/discourse/app/data/schemas/tag-notification.js create mode 100644 frontend/discourse/app/data/schemas/tag-settings.js create mode 100644 frontend/discourse/app/data/schemas/tag.js create mode 100644 frontend/discourse/app/data/schemas/topic-details.js create mode 100644 frontend/discourse/app/data/schemas/user-badge.js create mode 100644 frontend/discourse/app/data/warp-rest-model.js create mode 100644 frontend/discourse/app/services/warp-store.js create mode 100644 frontend/discourse/tests/unit/lib/warp-model-extensions-test.js diff --git a/.skills/discourse-warpdrive-models/SKILL.md b/.skills/discourse-warpdrive-models/SKILL.md new file mode 100644 index 00000000000..40d755ad551 --- /dev/null +++ b/.skills/discourse-warpdrive-models/SKILL.md @@ -0,0 +1,91 @@ +--- +name: discourse-warpdrive-models +description: Use when creating a new WarpDrive-backed frontend model, or when reading, using, or changing a model already converted to WarpDrive (anything under frontend/discourse/app/data or extending RestCompatModel/WarpRestModel) +--- + +# WarpDrive models + +`service:warp-store` (`services/warp-store.js`) is a second data store alongside the legacy `service:store`, built on `@warp-drive/*` in LegacyMode with a JSON:API cache. Migrated models live in `frontend/discourse/app/data/`: + +- `schemas/` — one resource schema per type, registered in `schemas/index.js` +- `normalize.js` + `jsonapi-utils.js` — Discourse REST payloads → JSON:API documents +- `builders/` — request objects, one file per resource +- `handlers/discourse-rest.js` — the sole network handler; routes through `ajax()` +- `warp-rest-model.js` — `WarpRestModel` wrapper base, plus `warpStore()`, `requestMany`/`requestOne`, `defineFieldForwarders` +- `rest-compat.js` — `RestCompatModel`, temporary legacy `RestModel` surface (`get`/`set`/`setProperties`, drafts, legacy adapter save path) +- `extra-attributes.js` — retains payload keys no schema declares (migration safety net) + +A model is converted if its class in `app/models/` extends `RestCompatModel` (or `WarpRestModel` directly) and its schema is listed in `schemas/index.js`. + +## Note: Schemas must be complete + +A record is a proxy over the cache; reading a field the schema doesn't declare **throws in dev/test and returns `undefined` in production**. `Object.keys`, `for...in`, and `toJSON` see only schema fields. Therefore: + +- Declare more attributes than seems necessary — a field only one endpoint sends still needs a line. +- To find every field, read the Ruby serializers (`app/serializers/`, including subclasses and `add_to_serializer` calls) and the API JSON schemas (`spec/requests/api/schemas/json/`) — not just one sample payload. +- Anything still arriving via `extra-attributes.js` is unfinished work, not a pattern to rely on. + +## Creating a new model + +1. **Schema** — `data/schemas/.js`, using `withDefaults` + `attrs`/`belongsTo` from `schemas/helpers.js`. Annotate with `/** @type {import("@warp-drive/core/types/schema/fields").LegacyResourceSchema} */` (avoids a TS2883 d.ts issue). Register it in `schemas/index.js`. +2. **Normalizer** — in `data/normalize.js` (or a per-resource file), build `{ data, included, meta }`. Use `resourceFrom(type, Schema, raw)` for the resource object (it also feeds the extras registry; pass an explicit `id` for sub-resources), `indexIncluded` + `maybeRelate` for relationships — `maybeRelate` drops pointers with no matching `included` entry, on purpose. Absent payload → `{ data: null }`, never `{ data: [] }` for single-record ops (wrap with the `recordOnly` pattern). +3. **Builders** — `data/builders/.js` using `readMany`/`readOne`/`createOne`/`updateOne`/`deleteOne` from `builders/helpers.js`; they carry the `op` and the `data: { type, id }` the cache needs. RPC-style endpoints (toggles, bulk ops) are plain inline `{ url, method, options }` objects with no `op`. +4. **Model class** — in `app/models/`, extend `RestCompatModel` (only extend `WarpRestModel` directly if no caller uses the legacy store/`get`/`set` API): + + ```js + export default class Badge extends RestCompatModel { + static type = "badge"; + static normalize = normalizeBadgesPayload; + static builders = { + list: findBadges, + one: findBadge, + save: saveBadge, + delete: deleteBadge, + }; + // custom getters here take precedence over forwarders + } + defineFieldForwarders(Badge, BadgeSchema); + ``` + + Other subclass hooks: + - endpoints beyond plain CRUD — own statics over `requestMany`/`requestOne` (see `UserBadge.findByUsername`), not more `builders` keys + - `static munge(json)` — massages JSON on legacy `_hydrate` + - `primaryKey` — `TagNotification` uses `"name"` + - `__resource` override, for models with their own ingest path — call `_applyExtraAttributes(id)` after pushing, and read `__ownResource` in anything the base constructor reaches, since subclass fields aren't initialized during `super()` + +5. Run the model's qunit tests plus acceptance tests for its screens. + +Relations to **unmigrated** models (User, Topic, …) are not relationships: store the raw embedded object as a plain attribute (see `bookmark.js` `user`, or `userBadgeResource` inlining sideloads), optionally wrapping it in the legacy model class in a getter or `create`. + +## Working with a converted model + +Reading and mutating: + +- Fields are prototype forwarders to the cached record: `badge.name` reads, `badge.name = x` writes (LegacyMode records are mutable). Relationships are read-only through the wrapper. +- Legacy `get("a.b")` / `set` / `setProperties` still work via `RestCompatModel`. +- Ids are strings in the cache; the `@id` forwarder (`schema.identity.name`, usually `id`) coerces numeric ones back to numbers. `peekRecord` needs `String(id)`. +- Wrap a nested cached resource in its own model class in a getter when callers need that class's getters (`get badge() { return new Badge(this.__resource.badge) }`). +- Probe field existence by reading (`record.foo !== undefined`), never with `in` — the `has` trap on draft `trackedObject`s is unreliable and can hang headless qunit. +- List results are arrays with document `meta` assigned onto them (`result.grant_count`). + +Fetching and persisting: + +- `Model.findAll(opts)` / `Model.findById(id)` — via `builders.list`/`one`. Anything else: `requestMany(this, someBuilder(...))` / `requestOne(...)`. +- `Model.createFromJson(json)` — synchronous ingest for preloaded/embedded payloads; `record.updateFromJson(json)` re-pushes and re-adopts. +- Legacy `store.createRecord(type, attrs)` still works: it produces a _draft_ wrapper (`__isLocalDraft`, attrs in a `trackedObject`, undeclared keys readable). After `save()` the wrapper adopts the cached record and strict schema reads apply. +- `record.save(data)` has two paths: with `static builders.save` it goes through WarpDrive (`store.request`); without, it falls back to the legacy adapter pipeline. Both fire `addModelCallback` hooks and merge `addModelSaveProperty` extras. +- `record.destroy()` (builder path) vs `record.destroyRecord()` (legacy adapter path). +- One-off actions: `warpStore().request(someBuilder(...))` with an inline builder. A builder with no normalizer discards the response body (the handler returns `{ data: null }`) — callers needing it use `ajax` directly, as `UserBadge#revoke` does. +- Optimistic updates: `store.push({ data: { type, id: String(id), attributes } })`, then `this._adoptResource(this.id)` so a draft wrapper sees the new value; push the previous attributes back if the request rejects (`UserBadge#favorite`). + +Plugin-extension APIs (`api.addModelField`/`Getter`/`Method`/…) still work — fields land as tracked properties on the wrapper, not in the cache. Schema-contributed plugin fields are planned but **not built** — don't design anything that depends on them. + +## Changing a converted model + +- **New server field**: add one line to the schema (`attrs(...)`) — `resourceFrom` picks it up automatically. +- **New client-only field**: also declare it in the schema (cheap, and keeps reads legal); mark it with a comment saying who sets it. +- **New relationship**: schema `belongsTo` + normalizer `maybeRelate` + push the related resource into `included`. Only between migrated types. +- **New endpoint**: add a builder; CRUD shapes use `builders/helpers.js`, RPC shapes are inline objects. +- **Derived values**: plain getters on the model class — define them before `defineFieldForwarders` runs. It skips any name that resolves anywhere on the prototype chain, which is also what stops a legacy-derived schema field called `save`/`get`/`destroy` from shadowing a base method. +- Prefer data-layer fixes (normalizer, schema, builder, compat layer) over touching consumers (routes, controllers, components) — the migration's goal is unchanged call sites. +- Shedding `RestCompatModel` for a model is the end state: only after no caller uses `get`/`set`/`store.createRecord`/legacy `save`. diff --git a/AI-AGENTS.md b/AI-AGENTS.md index 852238e8672..bb8a7d93279 100644 --- a/AI-AGENTS.md +++ b/AI-AGENTS.md @@ -22,6 +22,7 @@ Discourse is large with long history. Understand context before changes. - Make display strings translatable (use placeholders, not split strings) - Use "Sentence case" for strings, not "Proper Case" or "lower case" - Plugins/themes can't import npm modules directly; add the dependency to core and expose a `frontend/discourse/app/lib/load-*.js` wrapper that does the `import()` (see `load-morphlex.js`). +- Use the skill at `.skills/discourse-warpdrive-models` when creating or changing WarpDrive-backed models (`frontend/discourse/app/data`) ### Comments & Types - Prefer self-documenting code. Comments should only be added when future misunderstanding is likely. They should be terse, and should describe 'why', not 'what'. They should not be used to describe history. diff --git a/frontend/discourse/admin/controllers/admin-user/badges.js b/frontend/discourse/admin/controllers/admin-user/badges.js index 39aa54ba479..11804a89c7c 100644 --- a/frontend/discourse/admin/controllers/admin-user/badges.js +++ b/frontend/discourse/admin/controllers/admin-user/badges.js @@ -11,7 +11,10 @@ import { arraySortedByProperties, removeValueFromArray, } from "discourse/lib/array-tools"; -import { grantableBadges } from "discourse/lib/grant-badge-utils"; +import { + grantableBadgeOptions, + grantableBadges, +} from "discourse/lib/grant-badge-utils"; import { autoTrackedArray } from "discourse/lib/tracked-tools"; import UserBadge from "discourse/models/user-badge"; import { i18n } from "discourse-i18n"; @@ -61,6 +64,11 @@ export default class AdminUserBadgesController extends Controller { return grantableBadges(this.allBadges, this.userBadges); } + @dependentKeyCompat + get badgeOptions() { + return grantableBadgeOptions(this.availableBadges); + } + get groupedBadges() { const allBadges = this.model; @@ -128,7 +136,7 @@ export default class AdminUserBadgesController extends Controller { // Update the selected badge ID after the combobox has re-rendered. const newSelectedBadge = this.availableBadges[0]; if (newSelectedBadge) { - this.set("selectedBadgeId", newSelectedBadge.get("id")); + this.set("selectedBadgeId", newSelectedBadge.id); } }); } catch (error) { diff --git a/frontend/discourse/admin/routes/admin-user/badges.js b/frontend/discourse/admin/routes/admin-user/badges.js index 54641783ef9..698d0cce1b9 100644 --- a/frontend/discourse/admin/routes/admin-user/badges.js +++ b/frontend/discourse/admin/routes/admin-user/badges.js @@ -20,7 +20,7 @@ export default class AdminUserBadgesRoute extends DiscourseRoute { if (badges.length > 0) { let grantableBadges = controller.availableBadges; if (grantableBadges.length > 0) { - controller.selectedBadgeId = grantableBadges[0].get("id"); + controller.selectedBadgeId = grantableBadges[0].id; } } } finally { diff --git a/frontend/discourse/admin/templates/admin-user/badges.gjs b/frontend/discourse/admin/templates/admin-user/badges.gjs index fca303b4b6b..74ebf944815 100644 --- a/frontend/discourse/admin/templates/admin-user/badges.gjs +++ b/frontend/discourse/admin/templates/admin-user/badges.gjs @@ -44,7 +44,7 @@ export default