Skip to content

Store ​

Returned by dataforge.create_store. One store wraps one named datastore and hands out profiles for its keys.

Config ​

luau
type Config<T> = {
	name: string,
	template: T,

	migrations: { Migration }?,
	predicate: Predicate<T>?,

	autosave_interval: number?,
	flush_interval: number?,
	lock_ttl: number?,
	load_timeout: number?,
	load_poll: number?,
	retry_attempts: number?,
	retry_base: number?,
}
FieldDefaultDescription
namerequiredDatastore name.
templaterequiredData a brand new key starts with.
migrations{}Ordered list of { name, apply }. See Migrations.
predicatenoneGate on a key's stored data; a rejected key fails with predicate_failed. See Predicate.
autosave_interval30Seconds between autosaves of locked profiles.
flush_intervalautosave_intervalSeconds between flushes of lockless profiles.
lock_ttl60Seconds a session lock stays valid without refresh.
load_timeoutlock_ttl + 10Seconds load waits for a locked key before failing with timeout.
load_poll1Seconds between lock polls while waiting in load.
retry_attempts5Storage retries per operation.
retry_base1Seconds; exponential backoff base between retries.

Predicate ​

luau
type Predicate<T> = (data: T?, key: string) -> boolean

Decides whether this server may work with a key at all. Every call that reads or writes a key runs it against the raw stored data first and fails with predicate_failed when it returns false:

CallWhen it runsOn rejection
store:load, profile:readquireInside the lock's update_async transform, before the lock is takenNo lock is taken, nothing is written, no profile is registered. The call does not wait for another server's lock either.
profile:save / release / autosave / unload's final writeInside the write's update_async transformNothing is written. The profile keeps its session lock and stays open, so a later save can go through again (a rejected write during unload is reported with warn and the lock is left to expire).
lockless:fetchAfter the get_asyncNothing is adopted; the profile stays open and keeps its queue.
lockless:save / flush / unloadInside the flush's update_async transformNothing is written, the queue is kept.
store:peek, peek:refreshAfter the get_asyncNo snapshot is returned; a refresh keeps the previous one.
luau
local store = dataforge.create_store {
	name = "PlayerData",
	template = { coins = 0 },

	-- never touch a key some other system has flagged
	predicate = function(data, key)
		return data == nil or not data.frozen
	end,
}

local result = store:load(key, { player.UserId })
if not result.success and result.error.type == "predicate_failed" then
	print(`{result.error.key} rejected on {result.error.op}`, result.error.data)
end

data is the record's data exactly as it is in storage: nil when the key does not exist yet (a fresh key is not the template at this point) and with the store's migrations not applied yet. A write is gated on what is stored, not on the data about to be written.

The predicate runs inside the update_async transform, so it must be pure: no yielding, no storage calls, no mutation of data. It may run more than once per call when storage retries the transform. An error thrown inside it is not treated as a storage failure: the write is cancelled and the error propagates as is (thrown, not returned as a Result).

Fields ​

FieldTypeDescription
configResolvedConfig<T>Config with every default filled in.
datastoreDatastoreHandleStorage handle for config.name.
closedbooleantrue after close().
profiles{ [string]: Profile<T> }Open locked profiles by key.
lockless_profiles{ [string]: LocklessProfile<T> }Open lockless profiles by key.

Methods ​

store:load(key, user_ids) ​

luau
(self: Store<T>, key: string, user_ids: { number }) -> Result<Profile<T>>

Takes the session lock on key, resolves any dangling transaction, runs migrations and returns the Profile. Yields. Fails with timeout (load_timeout, carrying the holder's lock), roblox, outdated (the record was written by a newer server; no lock is taken), predicate_failed (config.predicate rejected the stored data; no lock is taken), migration_mismatch, tx_marker_invalid, already_loaded (a profile for key is still open on this store) or store_closed; see Errors. user_ids is forwarded to the datastore for GDPR tracking.

A profile that is closing is not "already loaded": unload drops it from the store before its final write, so this call takes the key as soon as that write lands (waiting on the record lock meanwhile). See Loading the same key twice.

store:wait_loaded(key, user_ids) ​

luau
(self: Store<T>, key: string, user_ids: { number }) -> Result<Profile<T>>

Returns the open profile for key; if a load is in flight, waits for it and returns the same result. Otherwise loads it.

store:get_loaded(key) ​

luau
(self: Store<T>, key: string) -> Profile<T>?

The open locked profile for key, or nil. Never yields.

store:get_lockless(key, user_ids) ​

luau
(self: Store<T>, key: string, user_ids: { number }) -> LocklessProfile<T>

Returns the LocklessProfile for key, creating it if needed. Never yields: a lockless profile does no storage work until fetch or its first flush. Throws a store_closed error table on a closed store.

store:peek(key) ​

luau
(self: Store<T>, key: string) -> Result<PeekProfile<T>>

Reads key once with GetAsync and returns a PeekProfile: the stored data (template if missing, migrations applied in memory) and the lock currently on the record, if any. Yields. Takes no lock, writes nothing, and the result is not tracked by the store nor accepted by transactions. Works while another server holds the key. Fails with roblox, outdated, predicate_failed, migration_mismatch or store_closed.

store:transaction(profiles, transform, config?) ​

luau
(
	self: Store<T>,
	profiles: { ProfileBase<T> },
	transform: (data: { T }) -> { T } | false,
	config: TransactionConfig?
) -> Result<boolean>

Positional variant of dataforge.transaction for profiles of this store. transform receives their data in order and returns the new data in the same order, or false to cancel. Ok(false) when cancelled, Ok(true) when committed; fails like dataforge.transaction, plus store_closed. A transform that returns no data for a participant throws (misuse).

store:close() ​

luau
(self: Store<T>) -> ()

Unloads every open profile (locked and lockless) and marks the store closed. Meant for game:BindToClose. Later load / peek / transaction calls fail with store_closed; get_lockless throws it.

Events ​

Each fires for every profile of the store, with the profile as first argument.

on_change also reports the data a key loads with, right after store:load registered the profile and before any wait_loaded waiter resumes; that first call has old == nil. Locked profiles cannot report it themselves (their hooks are attached after the load set the data), so this is the place to reconcile a key's initial state.

luau
store:on_change(function(profile, new, old) end)
store:on_save(function(profile) end)
store:on_closing(function(profile) end)
store:on_closed(function(profile) end)
store:on_lock_lost(function(profile) end)

See Profile events for when each one fires.