tr
TryGhost
GitHub
Ghost
Workspace
GitHub
CI Pipeline Executions
Filtered
Runs
Demo
Insights
Compare tasks
Analytics
Sign in
Toggle sidebar
Overview
⌘K
Ghost
Loading workspace stats
Loading workspace insights...
Statistics interval
7 days
30 days
Latest CI Pipeline Executions
Status
Fix filter
Filter
Fuzzy
Filter range
Sort by
Sort by
Start time
Sort ascending
Sort descending
In progress
30436
94a7a2b1 Added a member account module for the members API projection ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections What a member is shown about themselves was never assembled deliberately. A full member payload was built for staff and the members API narrowed it on the way out, so the member-facing shape was whatever survived a recipe written for a different audience, and the two could drift without anything noticing. It now has a module of its own that reads what it needs, models it and serialises it, and the express handlers that answered these two requests have moved to where Ghost's other endpoints live. The update is named update rather than edit because the framework reserves edit for the Admin API's enveloped bodies and this request has always carried a bare one, so naming it otherwise would have changed what Portal sends. Nothing a member receives changes, and the snapshots taken before any of this moved are what say so.
by Rob Lester
R
Succeeded
30436
946411c7 Moved the member account endpoints out of the members middleware ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections Reading and changing a member's own record were express handlers in the members middleware, which is where every members route ended up regardless of what it belonged to. They are endpoints, so they now live where Ghost's other endpoints live, and the work of answering a request is the endpoint's own query. They take the member from the session the route resolves before them, rather than resolving it themselves, which is how the members-facing gift endpoints already work. A request with nobody signed in reaches them with nobody attached and is answered with nothing rather than an error: a themed page asks this on every view, and most of those views have no member. The update is named `update` rather than `edit` because the framework reserves `edit` for the Admin API's enveloped bodies, and this endpoint has always taken a bare one. Naming it otherwise would have changed what Portal sends. The members-facing gift endpoints name their own verbs for the same reason. Nothing a member receives changes, and the snapshots taken before any of this moved are what say so.
by Rob Lester
R
Succeeded
30436
6e26c29e Changed the account projection to decode as one nested parse ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections The service was wiring the projection together by hand: group the rows, decode each kind, then walk the result three more times attaching offers, then payments, then attributions. Every one of those steps had to run, in that order, and the knowledge of what a member's payload contains was spread across the service rather than stated anywhere. Schemas nest, so the decode nests with them. A newsletter, a tier and each kind of subscription are now schemas that parse their own rows and return their own part of the payload, and the member schema simply says it has some of each. Reading a member is a query and a parse: `queries.read` gathers the rows that make one, and `MemberAccount` turns them into what a member receives. What a decode needs and a row cannot carry — what the next payment comes to, where an unsubscribe link points, whether the site shows gravatars — is passed in rather than reached for, so nothing in the schemas does any IO and all of them can be exercised with plain objects. The one ordering rule that is real, that a payment cannot be worked out before the offer it depends on, now sits inside the schema that needs it instead of being a comment on a method a caller had to remember. Offers and attributions still come from the domains that own them, but they are gathered once alongside the rows and travel with the subscription they describe, rather than being attached afterwards by walking the result again.
by Rob Lester
R
Succeeded
30436
2245d7fe Changed the Portal member projection to read by query ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections The members API served Portal a member assembled in the application: load the row through Bookshelf, then walk it through a sequence of services that each attach another piece. What a member receives was the result of that walk rather than anything stated, and the walk pulled in relations only staff ever read. The projection is a query now. The member and each of its collections are read directly, the rows are decoded into the payload, and what a member is shown is described in one place by the query that fetches it and the codec that shapes it. Nothing a member receives changes; the tests taken before any of this moved are what say so, and they pass unedited. Collections are separate queries rather than one statement. A member has two independent collections, and joining both returns their product, so each subscription would repeat once per newsletter. The alternative is aggregating each collection into a JSON column, and that cannot be written once: MySQL and SQLite spell the aggregate differently, and MySQL will not accept an ordering inside it, so the newsletter order this projection promises could not be expressed in SQL at all. Split this way every query compiles identically for both engines and the ordering lives where it belongs. It also means one member and a page of them cost the same number of statements, which is what the admin list will need. The subscription a comped or gifted member holds is derived rather than stored, so it is derived by query too, from the products they hold and the event that granted them. Writing it that way forced a rule into the open that the application had been leaving to chance: a member can hold more than one redeemed gift, and the previous lookup took whichever the database happened to return last. The query ranks them and says why. What the queries do not read is anything another domain owns. An offer carries its own redemption counts and an attribution resolves a URL through routing configuration rather than a column, so both are asked for while decoding. The unsubscribe link is an HMAC over a secret and the avatar is a gravatar URL; both are handed to the codec rather than computed inside it. Identifying a signed-in member stays where it was. A session needs transient_id and last_seen_at, which a member is deliberately never shown, so covering it here would stop this being the member's own view — that surface wants a projection of its own.
by Rob Lester
R
Succeeded
30436
2245d7fe Changed the Portal member projection to read by query ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections The members API served Portal a member assembled in the application: load the row through Bookshelf, then walk it through a sequence of services that each attach another piece. What a member receives was the result of that walk rather than anything stated, and the walk pulled in relations only staff ever read. The projection is a query now. The member and each of its collections are read directly, the rows are decoded into the payload, and what a member is shown is described in one place by the query that fetches it and the codec that shapes it. Nothing a member receives changes; the tests taken before any of this moved are what say so, and they pass unedited. Collections are separate queries rather than one statement. A member has two independent collections, and joining both returns their product, so each subscription would repeat once per newsletter. The alternative is aggregating each collection into a JSON column, and that cannot be written once: MySQL and SQLite spell the aggregate differently, and MySQL will not accept an ordering inside it, so the newsletter order this projection promises could not be expressed in SQL at all. Split this way every query compiles identically for both engines and the ordering lives where it belongs. It also means one member and a page of them cost the same number of statements, which is what the admin list will need. The subscription a comped or gifted member holds is derived rather than stored, so it is derived by query too, from the products they hold and the event that granted them. Writing it that way forced a rule into the open that the application had been leaving to chance: a member can hold more than one redeemed gift, and the previous lookup took whichever the database happened to return last. The query ranks them and says why. What the queries do not read is anything another domain owns. An offer carries its own redemption counts and an attribution resolves a URL through routing configuration rather than a column, so both are asked for while decoding. The unsubscribe link is an HMAC over a secret and the avatar is a gravatar URL; both are handed to the codec rather than computed inside it. Identifying a signed-in member stays where it was. A session needs transient_id and last_seen_at, which a member is deliberately never shown, so covering it here would stop this being the member's own view — that surface wants a projection of its own.
by Rob Lester
R
Succeeded
30436
65eabb67 Added a member account module for the members API projection ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections A member has two views: the one Ghost serves them about themselves, and the one it serves staff about them. Only the second was assembled deliberately. One payload was built for both, and each surface narrowed it on the way out through a pick of its own, so what a member receives was the residue of whichever list ran last rather than anything stated. The lists had already drifted apart with nothing to compare them against. This gives the first of those views a module. It owns its own read, its own projection and its own writable fields, and it says in one place what a member may see and set. The projection is unchanged, byte for byte, and the tests taken beforehand are what say so; only the code behind it moves. The read it runs is narrower than the staff one, which is the point of separating them. Ghost identifies a signed-in reader through this path on every page view of a themed site, so a relation loaded here is loaded on all of them: it stops loading labels and stops asking where the member came from, neither of which any member-facing surface reads. Where each subscription came from is still asked, because a theme can reach it. The withCustomFields flag goes with them, since it existed only to let one caller opt out of part of a read it shared with another audience, and that caller now has a read of its own. Three other surfaces narrow a member for their own audiences and keep doing so where they are: the theme data, which is a versioned part of Ghost's theme API, the newsletter endpoints, which authenticate by uuid rather than by session and withhold billing data on purpose, and the comments author shape. They are recorded in projections.ts so the four can be read side by side, which is how the one genuine disagreement between them became visible: a comment carries its author's internal id, and a member's own response does not.
by Rob Lester
R
Previous
Next