persistplugin + addon
Durable records as rows, loaded into a truth and written back as they change.
Putting it in a scene
The manager scene file holds a persist prefab, which every child scene inherits. A scene that keeps records lists the persist kind from #plugins/persist/persist.sim.ts in its sim and has exactly one { "is": "persist" } actor. That row carries the declaration and the requests for records, and no seat is ever sent it. The truth itself is not saved: every other row is made again from the scene when the truth boots, and the records are loaded into it.
"prefabs": {
"persist": {
"kinds": {
"listing": { "key": "uid", "load": "boot" },
"character": { "key": "uid", "load": "claim" }
},
"every": 30,
"claims": {},
"file": "mygame.db",
"busy": 5000
}
}
A rule asks for the records of a claim kind with claim, and finds them as rows on a later step:
import { claim } from '#plugins/persist/persist.sim.ts'
claim(w, 'character', { account })
Properties
Read from the persist prefab. Every value is checked when the truth boots.
kinds : { [kind]: { key, load } }
The kinds whose rows are records. Each kind needs a prefab of its own. A kind the truth's sim has no code for is skipped, so one table in the manager's file serves every child scene, and each keeps the kinds it makes rows of. A child that loads a kind differently from the table gives its persist actors entry a kinds of its own.
| Key | Meaning |
|---|---|
key | The field of the row's data the record is filed under, a string or a number. Use uid for every record a game has. Records name each other by uid, never by a row id, because a row id belongs to one truth for one boot. |
load | boot loads every record of the kind when the addon is attached, such as a market's listings in the scene that holds the market. claim loads records only when a rule asks for them with claim, such as an account's characters. |
every : number
The fewest steps between two writes of one row, a whole number of 1 or more. A row that changes every step is written once every every steps.
claims : object
Always {}. claim writes the requests here, and the addon deletes each one once its records are rows.
file : string
The store's name: the database file under the working directory on the server, and the IndexedDB database in a page.
busy : number
Milliseconds a write on the server waits for another thread's write to finish before it fails. A whole number, 0 or more.
Records
A record is the row's data as JSON, whole. A secret never goes in a row, so it never goes in a record. The addon logs no data, only a record's kind and key when a write fails.
Loading
Records are spawned as rows of their kind, each the kind's prefab with the record written over it. While any load is out the truth does not step, so a rule never sees half a record. The rows are there from the next step on.
- A record already liveA record whose key is already a live row of its kind is not spawned again. For a
claim, the live row is newer than the record and is left as it is. At boot, a live row of abootkind is the scene's starting copy, such as a scheduler's actors entry, and the record is written over it. - A dropped recordA load reads the store only after every write the truth handed it before has settled, so a record a rule deleted is never claimed back.
- A failed loadThe next step throws, so the truth never goes on without its records and makes them again from nothing.
Writing
Writes happen after every step. A row that was only loaded is not written until it changes.
| Row | When it is written |
|---|---|
| A changed row | When its data, compared as JSON, differs from what was last written, and it was last written at least every steps ago. |
A row with urgent: true in its data | That step, whatever every says. The flag is deleted from the data, so no record holds it. A rule sets it on every row a transfer touches, in the step it makes the transfer. |
| A row that has ended | One last time, with its data as it was when it ended. If that data has forget: true, its record is deleted instead. A rule deleting a record sets forget in the step it ends the row. |
| Every changed row | When a seat has closed since the last step, whatever every says, so what a player did before leaving is stored. |
Every record one step writes or deletes goes to the store as one batch, made as one transaction. A process killed during the write leaves all of that step's records or none, so a transfer from one record to another is never stored half made. Batches reach the store in the order their steps ran.
When rewind runs steps again, those steps write nothing. What they come to is written after the step that follows.
When the store fails a batch, each record in it is logged by kind and key. Each of those not written again since is sent in the next batch, whole with the rest of its step's. A step that writes nothing sends the failed records every steps later, until they succeed or newer writes replace them.
Methods
Rules
From #plugins/persist/persist.sim.ts.
claim( w, kind, match )
Asks for the records of a claim kind whose data holds every field of match at that value. Asking twice before the first is answered is one request. The records are rows from the next step on, found with w.all(kind) filtered by the match; a record already a row is not made again. It throws for a kind that does not load by claim.
The addon
From #addons/persist/persist.addon.ts. The server and the page call these; a rule does not.
persist( truth, store ) : { loaded, flush }
Attaches the addon to a truth and starts the boot loads. The truth must have exactly one persist row. It returns two functions.
| Function | Meaning |
|---|---|
loaded() | Resolves when no load is out. |
flush() | Writes every changed row and every failed write now, and resolves once the store has all of them. It rejects if the store took only some. Await it before a truth or the process is let go, so no write that every was holding back is lost. |
sqlite( file, busy ) : Promise<Store>
The server's store: one database file in WAL mode. Every worker thread opens its own connection and writes its own truths' rows into it, each write waiting up to busy milliseconds, a whole number, for another thread's to finish. It matches a field of a record by its JSON, so "1", 1 and true are three different values.
indexeddb( name ) : Promise<Store>
The page's store: the database name in the tab's IndexedDB. A batch is one transaction that commits whole or aborts whole. Only this store is called from a page, so the page never loads the server's.
Stores
Whoever opens a store closes it. One store serves every truth in its thread.
The five calls
Every call is asynchronous. sqlite and indexeddb both provide all five.
| Call | Meaning |
|---|---|
load(kind, match?) | The data of every record of kind whose data holds each field of match at that value, or every record of the kind without a match. |
save(kind, key, data) | Files data under (kind, key), over whatever was there. |
drop(kind, key) | Deletes the record. |
batch(ops) | Makes every op in order as one transaction. An op is { kind, key, data }, a save, or { kind, key, data: null }, a drop. It resolves once all are stored and rejects with none stored. |
close() | Lets the storage go. |
Used by
- accountsKeeps its accounts and sessions as records in the same store.