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'] },
            },
        },
    },
});

The store creates the discussion_members table automatically on registration. No migration needed — the table is populated from the change log.

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
const db = store.storage.db;

// 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
const db = store.storage.db;

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