Skip to content

Temporal Arrays ​

Array fields can track their full add/remove history in a separate table. The document keeps the current snapshot as usual; the temporal table preserves who was added or removed, when, and by whom.

The problem ​

When members are removed from a discussion or signers are removed from an identity, the rendered document only shows the current state. History is lost:

  • Messages from former members can't be attributed — were they a member when they posted?
  • Removed cryptographic keys disappear — signatures from old keys can't be verified
  • No audit trail — who had access when?

The data exists in the change log (every $push and $pull is recorded), but the standard rendering collapses it into a final snapshot.

Declaring a temporal field ​

Add temporal to an array field in your schema:

typescript
store.registerType({
    type: 'discussion',
    fields: {
        name: { type: 'string' },
        members: {
            type: 'array',
            temporal: {
                table: 'discussion_members',   // separate table name
                key: 'userId',                 // identifies the same entity across adds/removes
            },
            items: {
                userId: { type: 'uid', required: true },
                role: { type: 'enum', values: ['admin', 'member'] },
            },
        },
    },
});

Your render handler owns the discussion_members table like any other rendered table — the provided defaults (wish-app-base's sqlite layout, createSurrealRenderHandler) create it on registration; a custom handler creates it itself and applies the temporalInsert / temporalArchive ops (see Rendering).

How it works ​

When edits happen on a temporal array field, the renderer updates both the document and the temporal table:

  • $push on the array → inserts a row with addedAt and addedBy
  • $pull on the array → sets removedAt and removedBy on the matching row

The document itself is unchanged — it keeps the current members array as before. The temporal table is the history view.

Temporal table columns ​

ColumnTypeDescription
docHashBufferParent document hash
key field (e.g. userId)BufferIdentifies the entity
addedAtnumberTimestamp when added
addedByBufferIdentity that added this entry
removedAtnumber?Timestamp when removed (undefined = still active)
removedByBuffer?Identity that removed this entry

Plus any other fields from the array items (e.g. role).

Querying temporal data ​

Temporal tables are rendered tables — query them directly from your database, just like any other rendered type. Document-store handles the writes; your app handles the reads.

SurrealDB ​

typescript
// db = your own connection (where your handler renders)

// Current members
const [members] = await db.query(
    'SELECT * FROM discussion_members WHERE docHash = $hash AND removedAt = NONE',
    { hash: discussionHash }
);

// Members at a specific point in time
const [past] = await db.query(`
    SELECT * FROM discussion_members
    WHERE docHash = $hash AND addedAt <= $t AND (removedAt = NONE OR removedAt > $t)
`, { hash: discussionHash, t: 1710000000 });

// Full history
const [history] = await db.query(
    'SELECT * FROM discussion_members WHERE docHash = $hash ORDER BY addedAt',
    { hash: discussionHash }
);

SQLite ​

typescript
// Current members
const members = db.prepare(
    'SELECT * FROM discussion_members WHERE docHash = ? AND removedAt IS NULL'
).all(discussionHash);

// Members at a specific point in time
const past = db.prepare(`
    SELECT * FROM discussion_members
    WHERE docHash = ? AND addedAt <= ? AND (removedAt IS NULL OR removedAt > ?)
`).all(discussionHash, timestamp, timestamp);

Common queries ​

QueryCondition
Current membersremovedAt IS NULL
Former membersremovedAt IS NOT NULL
Members at time TaddedAt <= T AND (removedAt IS NULL OR removedAt > T)
Full historyNo filter on removedAt, order by addedAt
Who added a memberCheck addedBy on the matching row

Updating array items ​

Use the positional $ operator to update fields on an array element in place:

typescript
// Change Bob's role to admin
await store.edit(uid, { hash, 'members.userId': bobUid }, {
    $set: { 'members.$.role': 'admin' }
});

The filter { hash, 'members.userId': bobUid } matches the element; $set with members.$.role updates it. The temporal row keeps its original addedAt — changing a role is not the same as removing and re-adding a member.

See Editing — Positional $ for more on the positional operator.

Write model unchanged ​

Apps use the same $push and $pull operators as before. No new edit operators, no schema migration. The temporal table is populated as a side effect of rendering — the write path is unaware of it.

typescript
// Add a member — same as always
await store.edit(uid, { hash }, {
    $push: { members: { userId: bobUid, role: 'member' } }
});

// Remove a member — same as always
await store.edit(uid, { hash }, {
    $pull: { members: { userId: bobUid } }
});

Use cases ​

Membership history ​

Track who joined and left a discussion or workspace:

typescript
// db = your own connection

const [history] = await db.query(
    'SELECT * FROM discussion_members WHERE docHash = $hash ORDER BY addedAt',
    { hash: discussionHash }
);

for (const row of history) {
    if (row.removedAt) {
        console.log(`${row.userId} was a member from ${row.addedAt} to ${row.removedAt}`);
    } else {
        console.log(`${row.userId} joined at ${row.addedAt}`);
    }
}

Signature verification with key rotation ​

When a signer is removed from an identity, the public key disappears from the rendered document. But the temporal table preserves it:

typescript
store.registerType({
    type: 'identity',
    fields: {
        signers: {
            type: 'array',
            temporal: {
                table: 'identity_signers',
                key: 'signerId',
            },
            items: {
                signerId: { type: 'hash', required: true },
                pubkey: { type: 'bytes' },
            },
        },
    },
});

// Verify a signature from a removed signer — query the temporal table directly
const [signers] = await db.query(`
    SELECT * FROM identity_signers
    WHERE docHash = $uid AND signerId = $sid
    AND addedAt <= $t AND (removedAt = NONE OR removedAt > $t)
`, { uid: identityUid, sid: signerId, t: documentTime });

if (signers.length > 0) {
    // Verify using signers[0].pubkey — even though it's been removed from the identity
}

Long-lived workspaces ​

A workspace with hundreds of contributors over years:

  • Every contributor's membership preserved as flat rows with time windows
  • All signatures verifiable using public keys from when signers were active
  • Full audit trail: who had what role, when they joined, when they left
  • Indexed queries stay fast regardless of history size