Skip to content

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 taskprivate('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 taskTemplate binding — 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 template
const 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 it
await context.store.shared<number>('lastMailUid', 'taskTemplate').set(from)

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.

  • 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() — writing null does not, since null is a value), but a loop that writes sixty-five names in one message still fails on the sixty-fifth. A node’s private names are its own budget; the shared names 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.

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 mail
await context.store.shared<number>('lastMailUid', 'taskTemplate').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:

  • private there 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. shared is 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.

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 shared name. 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. A shared name 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 taskTemplate slots, 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.

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.

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.

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.

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 shared slot 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.

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.

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.

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.