Appearance
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:
$pushon the array → inserts a row withaddedAtandaddedBy$pullon the array → setsremovedAtandremovedByon 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
| Column | Type | Description |
|---|---|---|
docHash | Buffer | Parent document hash |
key field (e.g. userId) | Buffer | Identifies the entity |
addedAt | number | Timestamp when added |
addedBy | Buffer | Identity that added this entry |
removedAt | number? | Timestamp when removed (undefined = still active) |
removedBy | Buffer? | 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
| Query | Condition |
|---|---|
| Current members | removedAt IS NULL |
| Former members | removedAt IS NOT NULL |
| Members at time T | addedAt <= T AND (removedAt IS NULL OR removedAt > T) |
| Full history | No filter on removedAt, order by addedAt |
| Who added a member | Check 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