Step 9 of 1275% complete

Add a Second Entity Type

Estimated time: 12 minutes

Add a Second Entity Type

Learning Objectives

By the end of this step, you'll:

  • Know why real Arkiv apps use multiple entity types
  • Have added a second entity type linked to the first
  • Understand foreign-key style relationships via shared attributes
  • Have set differentiated expiresIn per entity type

Content

Why a second entity type?

Real Arkiv apps almost never have one entity type. The basic shape is a parent and one or more child types linked by a shared attribute. Think:

  • Posts and comments
  • Documents and access grants
  • Devices and readings
  • Agents and memories
  • Notes and tags

In this step we add reactions to messages. Each reaction is its own entity, linked back to the message it reacts to. Once you can do this, you can model anything else.

The pattern

Arkiv has no built-in foreign-key field. You make a relationship by putting the parent's entity key into a shared attribute on the child.

import { jsonToPayload, ExpirationTime } from '@arkiv-network/sdk/utils';
import { PROJECT_ATTRIBUTE } from '../../../../lib/config';

// 1. The parent (a message)
const { entityKey: messageKey } = await walletClient.createEntity({
  payload: jsonToPayload({ text: 'Hello workshop' }),
  contentType: 'application/json',
  attributes: [
    PROJECT_ATTRIBUTE,
    { key: 'type', value: 'workshop_message' },
    { key: 'createdAtMs', value: Date.now() },
  ],
  expiresIn: ExpirationTime.fromDays(180),
});

// 2. The child (a reaction) shares the project and points at the parent
await walletClient.createEntity({
  payload: jsonToPayload({ emoji: 'heart' }),
  contentType: 'application/json',
  attributes: [
    PROJECT_ATTRIBUTE,
    { key: 'type', value: 'workshop_reaction' },
    { key: 'messageKey', value: messageKey },   // <-- the foreign key
    { key: 'createdAtMs', value: Date.now() },
  ],
  expiresIn: ExpirationTime.fromDays(30),
});

Two details worth pausing on:

  1. Reactions expire faster than messages. Reactions are ephemeral interactions; messages are durable content. Differentiated expiresIn per entity type is a signature mark of an Arkiv app that knows what it is doing.
  2. Both have PROJECT_ATTRIBUTE and a type discriminator. Project namespacing always; type lets you filter the right shape.

Querying the children of a parent

To load all reactions for a specific message in one round trip:

import { eq } from '@arkiv-network/sdk/query';

const reactions = await publicClient.buildQuery()
  .where([
    eq(PROJECT_ATTRIBUTE.key, PROJECT_ATTRIBUTE.value),
    eq('type', 'workshop_reaction'),
    eq('messageKey', messageKey),
  ])
  .withPayload(true)
  .limit(200)
  .fetch();

One round trip, one query. This same shape works for any parent and child: posts and comments, agents and memories, devices and readings, documents and access grants.

I'm at step 9: Add a Second Entity Type.

The workshop has me adding "reactions" to messages, where each reaction is its own
Arkiv entity linked back to the parent message via a shared "messageKey" attribute.

Help me:
1. Add a POST endpoint at /api/serverless-dapp101/reactions that takes { messageKey, emoji }
   and creates a workshop_reaction entity. Include the project attribute and a 30-day
   expiresIn using ExpirationTime.fromDays.
2. Add a GET endpoint that lists reactions for a given messageKey by querying with
   eq('type', 'workshop_reaction') and eq('messageKey', ...).
3. In the hello-world page, add three emoji buttons under each message that POST a
   reaction and refresh the list.

Constraints I want enforced:
- PROJECT_ATTRIBUTE on every write and every query
- Reactions expire after 30 days (messages stay 180)
- Use jsonToPayload and ExpirationTime helpers (not raw seconds)
- Catch EntityMutationError by name on writes

Update the internal implementation plan with notes and show me the plan so I can track your progress.

Three rules to remember

  1. Project tag every entity, in every type. Always.
  2. Use a shared attribute key to link parent and child. Always.
  3. Right-size expiresIn per entity type. Ephemeral types live shorter; durable types live longer.

✓ Checkpoint

Troubleshooting

Q: My reactions return as an empty list even though I posted some. A: Indexer lag, give it 5 to 30 seconds and refresh. If they still do not appear, check (a) that you stamped PROJECT_ATTRIBUTE on both the write and the read, and (b) that the messageKey value on the write exactly matches the entity key you are querying for.

Q: Can I model many-to-many relationships? A: Yes, but not with an array attribute on either side. Use a third entity type that represents the link itself (for example workshop_message_tag with messageKey and tag attributes). Then a many-to-many is two eq filters on that link type.

Q: Should messageKey be a string or a number attribute? A: String. Entity keys are hex strings, not numbers. Only store numerics as numbers (timestamps, scores, counts) so you can range-query them.

Q: What if I want to "edit" a message instead of adding a reaction? A: Use walletClient.updateEntity({ entityKey, ... }). Two caveats from step 8: only the current $owner can update, and any attribute you omit is dropped (it is a full replace).