Skip to content

feat: narrow the object ⇒ relationship inference - #10

Merged
caiotarifa merged 1 commit into
mainfrom
feat/attribute-objects-and-lid
Aug 14, 2026
Merged

feat: narrow the object ⇒ relationship inference#10
caiotarifa merged 1 commit into
mainfrom
feat/attribute-objects-and-lid

Conversation

@caiotarifa

@caiotarifa caiotarifa commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Closes #9.

What changed

The inference was total: any nested object became a relationship, and one without an id threw. There was no way to say "this object is an attribute", and no way to create a related resource that has no id yet.

An object — or a list of objects — is now only a relationship when it carries an identifier member: type, id, or lid. Anything else is a plain JSON attribute:

await api.create('article', {
  title: 'Hello',
  metadata: { locale: 'en', draft: true },  // attribute — no identifier member
  author: { type: 'people', id: '9' }       // relationship, as before
})

The guard the README promises is intact. An object that carries a type and forgets its id still throws, because the type gives the intent away — only an object with no identifier member at all is reclassified.

Sideposting through lid

A resource with a lid and no id is no longer an error. The linkage holds { type, lid } and the full resource goes to the top-level included, so resource identifiers stay pure. A resource identifier object in 1.1 may carry a lid in place of an id, so the document is well formed on its own terms:

await api.create('article', {
  title: 'Hello',
  comments: [{ type: 'comments', lid: 'c-1', body: 'First' }]
})
{
  "data": {
    "type": "articles",
    "attributes": { "title": "Hello" },
    "relationships": {
      "comments": { "data": [{ "type": "comments", "lid": "c-1" }] }
    }
  },
  "included": [
    { "type": "comments", "lid": "c-1", "attributes": { "body": "First" } }
  ]
}

Whether a server acts on that is its own business. lid is defined by 1.1 but left unused by the base spec, which expects extensions to give it meaning — the point of the open #1740. So the shape is well formed and the semantics are the server's; the client has no reason to refuse it either way. Deduplication now tracks id and lid apart, so two new resources sharing an id of undefined are no longer collapsed into one.

This is deliberately left out of the README until the semantics are settled somewhere other than in each server.

Where this comes from

Sideposting has been asked for since 2014 and never landed in the base spec. The threads worth reading, and what this PR takes from each:

  • #202Need "sideposting", or, the ability to do relationships when POSTing (2014, closed). The original request.
  • #1089Support Embedded Relationships (2016, closed). Asks for full resources nested in relationships, the shape this PR keeps behind $.
  • #1197[WIP] Sideposting draft (2017, closed). The draft that settled on lid in the linkage with the body in included, which is the shape implemented here.
  • #1215Sideposting: use of included; interactions with ?include (closed) and #1216Sideposting: graph requirements and restrictions (open). Why serialising the shape is the easy half.
  • #1705Create/update relationships having attributes? (open). The exact question Allow the caller to opt out of the object ⇒ relationship inference #9 raises, still unanswered upstream.
  • #1740Explain intent of local identifiers in a note in the spec (open). lid is deliberately unspecified in the base spec.
  • #795Support for multiple operations in a single request (open) and #1254Introduce Operations to v1.2. The road that became Atomic Operations, the standardized answer for writing several resources at once.

Nothing here is settled, which is exactly why Fetchja serialises the shape and stays out of the semantics.

$.attributes

$.relationships merged per key, but $.attributes replaced the whole attributes object — so any flat field present would silently drop it. It now merges per key too, envelope winning, matching $.relationships.

Compatibility

Every body shape that serialized before serializes byte for byte the same way. Only shapes that used to throw behave differently:

Body Before After
{ author: { type, id } } relationship unchanged
{ tags: [{ type, id }] } relationship unchanged
{ author: { id: null } } data: null unchanged
{ tags: [] } data: [] unchanged
{ author: { type, name } } throws throws (unchanged)
{ metadata: { locale } } throws attribute
{ steps: [{ order: 1 }] } throws attribute
{ comments: [{ type, lid, body }] } throws sideposted

Notes on the issue

The report also asked about full child objects inside relationships.*.data. That shape is already reachable on main through $.relationships, which passes the linkage through untouched:

await api.create('article', {
  title: 'Hello',
  $: {
    relationships: {
      comments: { data: [{ type: 'comments', attributes: { body: 'Hi' } }] }
    }
  }
})

It stays there rather than moving into the flat shape. Attributes inside a resource identifier contradict the spec, so the escape hatch is the right home for it — lid covers the same ground without the contradiction.

Option (a) from the issue — treating any object with a data key as a relationship — was not taken. data is a legal attribute name, so { payload: { data: 'x' } } would go from a loud throw to a silently malformed request.

Tests

7 new cases: JSON-column attribute, list of plain objects, the throw preserved when type is present without id, sideposting to-one and to-many through lid, deduplication by lid, and $.attributes merging. 103 pass, eslint and tsc --noEmit clean.

Any nested object used to become a relationship, and one without an `id`
threw. That left no way to send a JSON column (`metadata`, `settings`) as
an attribute, and no way to create a related resource that has no `id`
yet.

An object — or a list of objects — is now only a relationship when it
carries an identifier member: `type`, `id`, or `lid`. Anything else is a
plain JSON attribute and goes under `attributes` as it was given. An
object that carries a `type` and forgets its `id` still throws, so a
half-written relationship never clears one by accident.

A resource with a `lid` and no `id` is now sideposted the way JSON:API
1.1 describes: the linkage holds `{ type, lid }` and the full resource
goes to the top-level `included`, keeping resource identifiers pure.
Deduplication tracks `id` and `lid` apart.

`$.attributes` also merged per key over the derived ones, instead of
replacing the whole `attributes` object whenever a flat field was
present, which silently dropped it.

Every body shape that serialized before serializes the same way. Only
shapes that used to throw behave differently.
@caiotarifa
caiotarifa merged commit 240aa97 into main Aug 14, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow the caller to opt out of the object ⇒ relationship inference

1 participant