The Store
A node normally forgets everything when its message is done. The store is the small amount of memory a node can keep instead: a handful of named values, kept for that node alone or shared with every node of the same task or template.
It exists for one shape of problem — knowing where you stopped:
- An email node that remembers the last mail it forwarded, so a restart does not re-send a fortnight of mail, and a gap does not lose any.
- A poller that remembers the timestamp it last fetched up to.
- A node in a continuous task that counts or collects across the messages it is fed.
It is memory, not a database. One value per name, small, written constantly. If a template needs to accumulate a dataset — rows to query later, a history to report on — the answer is still a real database behind a service node. The store is not that, and pointing it at that job will run into its limits quickly.
Two Axes: Who Can Read It, And How Long It Lives
Section titled “Two Axes: Who Can Read It, And How Long It Lives”Every read and write states both, and each one is said exactly once — the verb says who, the argument says how long. They are independent: any verb may take either argument.
Who can read it — the verb:
| verb | readable by | typical use |
|---|---|---|
private(name, binding) |
this node only. Nothing else can see it. | a node’s own counter or cursor |
shared(name, binding) |
every node of the same task or template. One writes it, another reads it. | a cursor a service node starts from and a logic node moves |
How long it lives — the argument:
| binding | remembered | typical use |
|---|---|---|
task |
for the rest of this task, across all its messages. Gone when the task’s row is gone. | counting or collecting inside one continuous run |
taskTemplate |
across runs of this template — the next task reads what the last one wrote. | the email cursor, a “last seen” marker |
The binding defaults to task — private('cursor') is private('cursor', 'task'), and
the same for shared. The default is deliberately the option that forgets, so anything
that outlives a run is always spelled out and can never be mistaken for temporary. The other
side of that: shared('cursor') on its own states neither axis in full — it is shared, and
it is gone when the task is.
One rule the axes do not cover:
- Nothing is shared between branches, for the
taskTemplatebinding — including a shared slot. A task started from a branch reads and writes that branch’s data. A merge copies nothing, and a duplicated template starts empty.
// This node only, remembered for the next run of this templateconst cursor = context.store.private<number>('lastUid', 'taskTemplate')const from = (await cursor.get()) ?? 0
const mails = await fetchSince(from)await cursor.set(mails.at(-1)?.uid ?? from)
// ...and the same value where every node of the template can read itawait context.store.shared<number>('lastMailUid', 'taskTemplate').set(from)cursor: StoreSlot[int] = context.store.private('lastUid', 'taskTemplate')start = await cursor.get() or 0
mails = fetch_since(start)await cursor.set(mails[-1]["uid"] if mails else start)
# ...and the same value where every node of the template can read itshared: StoreSlot[int] = context.store.shared('lastMailUid', 'taskTemplate')await shared.set(start)createService('1.0.0', async function* (ctx) { // shared, so a downstream node can move where the next run starts const cursor = ctx.store.shared<number>('lastMailUid', 'taskTemplate')
for await (const mail of fetchSince((await cursor.get()) ?? 0)) { yield { subject: mail.subject } // After the push, never before: a duplicate is survivable, a loss is not. await cursor.set(mail.uid) }})def handler(ctx): # No `await` in the Python SDK — it has no asyncio at all. cursor: StoreSlot[int] = ctx.store.shared('lastMailUid', 'taskTemplate')
for mail in fetch_since(cursor.get() or 0): yield {"subject": mail["subject"]} # After the push, never before: a duplicate is survivable, a loss is not. cursor.set(mail["uid"])A slot does three things: read it, write it (the last write wins, and the write is confirmed before the line finishes), and delete it, which gives the name back. Asking for the slot itself costs nothing — no request is made until one of those three happens, so a script that never touches the store never talks to it.
What Fits In It
Section titled “What Fits In It”- JSON values only. A number, a string, a boolean,
null, or an object/array of those. A file, a stream or a date object is not storable — those are references Requence resolves on the way to a node, and they have a lifetime the store cannot honour. Store the id, or upload the bytes as a file. - 64 KiB per value. A value that does not fit belongs in a file.
- 64 names per slot group. This is a deliberate ceiling: a name computed inside a loop
turns memory into a table, and this is where that stops. A script can give a name back
(
delete()— writingnulldoes not, sincenullis a value), but a loop that writes sixty-five names in one message still fails on the sixty-fifth. A node’sprivatenames are its own budget; thesharednames are one budget for every node here, so the refusal lands on whoever writes last and the keys filling it need not be theirs. The error says so. - 128 characters per name.
All four are checked by the store itself and reported back as an error on the node, so a value that is too large or a name that is too long fails the node — it never lands half-stored.
Empty and null are the same thing. Reading a name that was never written and reading
one that was written as null both answer null, so a template cannot ask “has this ever
been set”. Write a sentinel value if that difference matters.
Reading It In A Script
Section titled “Reading It In A Script”The value’s type is stated where the slot is asked for, and it covers both the read and the write:
| where the script lives | can it state the type? |
|---|---|
| a TypeScript logic node | yes — private<number>('lastUid', 'taskTemplate'), and the same on shared |
| a JavaScript logic node | no. The type argument is TypeScript syntax; narrow with a typeof check or a JSDoc @type instead |
| a Python logic node | yes, as a variable annotation — cursor: StoreSlot[int] = … |
| a service (TypeScript / Python) | the same two spellings, in the SDK |
Two things follow that surprise people:
- Without a type it is
JSONValue— the union of everything JSON can hold. That is not a mistake to work around; it is the store being honest about what it holds. Narrow it the way you would narrow an unknown value. - A read is always “the value or nothing”. The first run of a new template has
written nothing, so the cold start is part of the type and you have to handle it
(
(await cursor.get()) ?? 0). A slot that claims to be a number and is empty on its first run is exactly the bug this prevents.
The name is free text and nothing checks it. There is no list to pick from, no warning, and no typo detection — a misspelled name is simply a different, empty slot. This is deliberate, because a computed name is legal here (unlike a variable key, which must be a literal): nothing can enumerate the names in advance, so nothing can spell-check them.
A Computed Configuration Field Can Read It
Section titled “A Computed Configuration Field Can Read It”A computed configuration function has the whole store, and reading a cursor there is the point of it: the value the node needs before it runs, produced by the last run. It puts the cursor on the canvas, where anyone reading the template can see what is happening, instead of hiding it inside the service.
// configuration function on the receiver node, field `startAfterUid`return (await context.store.shared<number>('lastMailUid', 'taskTemplate').get()) ?? 0// logic node downstream of the receiver, per mailawait context.store.shared<number>('lastMailUid', 'taskTemplate').set(context.input.mail.uid)# configuration function on the receiver node, field `startAfterUid`cursor: StoreSlot[int] = context.store.shared('lastMailUid', 'taskTemplate')return await cursor.get() or 0# logic node downstream of the receiver, per mailcursor: StoreSlot[int] = context.store.shared('lastMailUid', 'taskTemplate')await cursor.set(context.input["mail"]["uid"])When it runs. Once per activation of the node — and, for a continuous node, again every time the service reconnects. That is what makes the cursor above work: a receiver that runs for a week and loses its connection resumes from the uid the last run reached, not from the one the task started with. A retry is not a reconnect: re-landing the same message keeps the configuration it was dispatched with, because that message may still be in flight.
Two more things to know:
privatethere is a different box. A configuration function’s private names belong to the configuration, not to the service on that node. Neither can see the other’s, even under the same name.sharedis the only bridge between them — which is why the example above uses it.- All fields of one node share one box. One 64-name budget, one namespace, both languages together. Two fields using the same name are the same slot.
The box is cleared with the node, exactly as the node’s own is: delete the node, or point it
at another service version, and it starts over. It shows as its own
<node> — configuration row in the template’s stored-data index, and as its own section in
the node’s store panel.
Two Writers, No Referee
Section titled “Two Writers, No Referee”The last write wins. There is no compare-and-set, no version, and no locking — a value written a moment later simply replaces the one before it.
Where that can bite you:
-
A node racing itself inside one task. In a continuous task a node is activated once per incoming message, and by default those activations run in parallel — so a read-then-write pattern can interleave with itself. A logic node has a Concurrency switch in its settings; turning it off makes the next activation wait for the previous one, which is the fix for this case only. A service node has no such switch: a service’s own process decides its ordering, and if it needs its writes serialised, the service has to do that itself.
-
Two nodes writing one
sharedname. This has no fix at all, and it is worth being blunt about: the Concurrency switch makes a node wait for itself, so two different nodes never wait for each other — turning it off changes nothing here. Asharedname has no referee and no escape hatch.Design around it instead: let one node own each shared name and have the others only read it. A cursor that one node advances and every other node reads is safe; two nodes both advancing it is a lost write waiting to happen.
-
Two runs of the same template at once. Two tasks from one template share the
taskTemplateslots, private and shared alike, and nothing prevents this — it is stated and not enforced. Deliberately starting a second run of a template whose node keeps a cursor means both runs are moving that cursor.
When A Read Or Write Fails
Section titled “When A Read Or Write Fails”The store is a service like any other, so a call to it can fail — and it fails where it was called, not silently:
- If the failure is not caught, the node fails. It takes its on fail output if it has one, and otherwise the task fails.
- Nothing retries. A store restart during a run fails the nodes that touched it in that window; an error branch is the way a template survives that.
- Catching the error and carrying on hides it. A run whose cursor silently stopped advancing looks completely healthy. If you catch a store error, do something visible with it.
Seeing What A Node Remembers
Section titled “Seeing What A Node Remembers”A private slot shows on the node.
- On a task run — open the node’s panel and it carries a
Store (n)chip when the node has data. Each row is a name; expanding shows the value. Read-only, and only for as long as the task’s row exists: a temporary run from the editor is cleaned up after a few hours, and older tasks are dropped by retention. An empty panel on such a task means the row is gone, not that data was lost. - On a task template — a
Store (n)pill under the node opens what that node remembers between runs, and the template’s Settings popover lists which nodes remember something at all, as a way in. This is where clearing lives.
A shared slot belongs to no node, so it is not on the canvas at all. It has a
Shared stored data section instead, on both sides, with the names listed right there —
there is only ever one shared slot per task and one per template branch, so there is nothing
to click through to:
- On a task run — the toolbar’s settings popover (the ⓘ button), read-only, beside Mode and Priority.
- On a task template — the Settings popover, under the per-node index. This is where clearing it lives.
The task side shows the task half only, on both. The email cursor is not there —
that is taskTemplate data, it outlives every run, and it lives on the template, where
reading it needs edit rights on the template. Both task-side views say so in one line, so
nobody concludes the cursor was never written.
The count is live; a value inside an open row is as of the moment it was read. A cursor ticking upward without adding a new name will not animate — reopen the row.
Clearing It
Section titled “Clearing It”A node can remove its own names from a script or a service (delete() on the slot). The
controls below are for clearing from outside — without editing anything and without a
run — and they live on the template:
| control | clears |
|---|---|
| the bin beside a row | that one name |
| Clear this node | everything that node remembers |
| Clear shared data (Settings) | the shared names, on this branch |
| Clear all stored data (Settings) | every node’s data and the shared data, on this branch |
Clearing is refused while a task from the template is running or pending — the control stays where it is and the refusal names the tasks that are in the way, because they would write the data straight back. Stopping those tasks is the way through; there is no override, so a task sitting on a deferred node keeps the lock for as long as it waits.
Deleting the template deletes its data, and deleting a branch (by discarding or merging
it) deletes that branch’s data. Deleting a task deletes its task data.
When A Slot Cold-Starts By Itself
Section titled “When A Slot Cold-Starts By Itself”A slot belongs to a program. A node whose program is gone or replaced cannot be assumed to still mean the same thing by its old data — the only safe read is a fresh start.
So, on a save:
- Changing a service node’s version clears that node’s stored data, whether it is a bump to 1.0.1 or a swap to a different service entirely. There is no opt-out. A different version is a different program: it may count differently, name things differently, or interpret that cursor differently, and quietly handing it the old value is the worse outcome. If a cursor genuinely has to survive a version bump, write it down before saving and put it back afterwards.
- Removing a node from the template clears its data, so a node deleted during a build-and-test loop does not leave data behind forever.
And the two exceptions that catch people:
- Rewriting a logic node’s script does not clear its slot. There is no version on a script — every save changes it, so clearing on every save would wipe a slot on a whitespace edit. A rewritten logic node keeps whatever the previous script wrote, and if that is wrong, clear it by hand: the row’s bin, or Clear this node.
- A
sharedslot never cold-starts, ever. It belongs to no node, so no version bump and no node removal reaches it — and for the email cursor that is exactly what you want: bumping the receiver’s version must not re-download a fortnight of mail. The only reset is Clear shared data, by hand.
Clear-one is the manual escape in every direction — for the logic node that keeps too much, for the service node whose cold start you would rather trigger yourself, and for the shared slot that nothing else can reset.
Secrets And What The View Shows
Section titled “Secrets And What The View Shows”The store holds whatever was put in it, including a value derived from a secret variable. Both views scrub stored values for known secrets when they show them, and on the template — where a value can be older than every task that still exists — that scrubbing is best effort:
- Matching is on the exact text of secrets that this template’s tasks used, so a transformed secret (base64-encoded, truncated, re-formatted) passes straight through.
- A secret written long ago and rotated since is only caught while a task that used the old value still exists. After retention drops those tasks, the old value shows as-is.
If the scrubbing cannot run at all, the panel shows an error instead of the data — never the data unscrubbed.
And the rule that has no exception:
The template’s stored data needs edit rights on the template to see; a task run’s data follows whatever rights let you see the run at all.
Running From The Editor
Section titled “Running From The Editor”Pressing Run in the editor starts a real task, and it uses the saved template’s stored data — not a copy of it. What a test run writes, the next scheduled run reads. The run dialog says so when the template’s nodes use the store. See Continuous Mode — Running from the editor for what to do about it.
Reference
Section titled “Reference”For the exact API in a service, see
Context API — ctx.store
and the @requence/service README. Inside a logic node the same handles are on
context.store.