@rbxts/dataforge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.luau ADDED
@@ -0,0 +1,375 @@
1
+ type Array<T> = { T }
2
+
3
+ export type Counter = () -> number
4
+
5
+ --- Returns the new data, or nil / false to leave it untouched.
6
+ export type DataDispatcher<T> = (T) -> (T | false)?
7
+
8
+ --- A storage handle for one named store. Deliberately not shaped like a
9
+ --- Roblox DataStore. All methods throw on failure.
10
+ export type DatastoreHandle = {
11
+ name: string,
12
+
13
+ get_async: (key: string) -> any?,
14
+ set_async: (key: string, value: any, user_ids: { number }?) -> (),
15
+ update_async: (key: string, transform: (old: any?) -> any?, user_ids: { number }?) -> any?,
16
+ remove_async: ((key: string) -> ())?,
17
+ }
18
+
19
+ export type Hook = {
20
+ job_id: () -> string,
21
+ get_datastore: (name: string) -> DatastoreHandle,
22
+ --- Non-fatal problems (a hook that threw, a write that could not be
23
+ --- released) are reported here instead of a global `warn`.
24
+ warn: (message: string) -> (),
25
+ }
26
+
27
+ export type Scheduler = {
28
+ spawn: (fn: ((...any) -> ...any) | thread, ...any) -> thread,
29
+ defer: (fn: ((...any) -> ...any) | thread, ...any) -> thread,
30
+ wait: (seconds: number?) -> number,
31
+ now: () -> number,
32
+ }
33
+
34
+ export type Migration = {
35
+ name: string,
36
+ apply: (data: unknown) -> unknown,
37
+ }
38
+
39
+ export type Config<T = any> = {
40
+ name: string,
41
+ template: T,
42
+
43
+ _hook: Hook?,
44
+ _scheduler: Scheduler?,
45
+
46
+ migrations: Array<Migration>?,
47
+
48
+ --- seconds, default 30 (locked profiles)
49
+ autosave_interval: number?,
50
+ -- seconds, default autosave_interval (lockless profiles)
51
+ flush_interval: number?,
52
+ --- seconds, default 60
53
+ lock_ttl: number?,
54
+ --- seconds, default lock_ttl + 10
55
+ load_timeout: number?,
56
+ --- seconds, default 1
57
+ load_poll: number?,
58
+ --- default 5
59
+ retry_attempts: number?, -- default 5
60
+ --- seconds, default 1
61
+ retry_base: number?,
62
+ }
63
+
64
+ --- Options shared by the retry helper. Both `ResolvedConfig` and
65
+ --- `ResolvedTransactionConfig` satisfy it.
66
+ export type RetryConfig = {
67
+ _scheduler: Scheduler,
68
+ retry_attempts: number,
69
+ retry_base: number,
70
+ }
71
+
72
+ export type ResolvedConfig<T = any> = RetryConfig & {
73
+ name: string,
74
+ template: T,
75
+
76
+ _hook: Hook,
77
+ _scheduler: Scheduler,
78
+
79
+ migrations: Array<Migration>,
80
+
81
+ autosave_interval: number,
82
+ flush_interval: number,
83
+ lock_ttl: number,
84
+ load_timeout: number,
85
+ load_poll: number,
86
+ }
87
+
88
+ export type TransactionConfig = {
89
+ _hook: Hook?,
90
+ _scheduler: Scheduler?,
91
+ _datastore: DatastoreHandle?,
92
+
93
+ --- seconds, default 60. Grace period others give this transaction's `pending`
94
+ --- before force-resolving it (stamped on the pending as `expires`).
95
+ tx_ttl: number?,
96
+ --- seconds, default 10. How long a transaction holds a lockless key between
97
+ --- taking its snapshot and writing `pending`.
98
+ lock_ttl: number?,
99
+ --- default 5
100
+ retry_attempts: number?,
101
+ --- seconds, default 1
102
+ retry_base: number?,
103
+ }
104
+
105
+ export type ResolvedTransactionConfig = RetryConfig & {
106
+ _hook: Hook,
107
+ _scheduler: Scheduler,
108
+ --- Global marker datastore, resolved once via `tx_handle.get_tx_store`.
109
+ _datastore: DatastoreHandle,
110
+ --- Per-process transaction id counter (ids are `{job_id}-{n}`).
111
+ _counter: Counter,
112
+
113
+ tx_ttl: number,
114
+ lock_ttl: number,
115
+ }
116
+
117
+ export type Lock = {
118
+ id: string,
119
+ expires: number,
120
+ --- Short-lived lock a transaction takes on a lockless key (see `lock_ttl`
121
+ --- in `TransactionConfig`). Other lockless writers wait it out instead of failing.
122
+ tx: boolean?,
123
+ }
124
+
125
+ export type Pending<T = any> = {
126
+ tx_id: string,
127
+ store: string,
128
+ --- Until when a non-owner leaves this pending alone; after it, the marker
129
+ --- may be force-resolved (fallback "aborted").
130
+ expires: number,
131
+ data: T,
132
+ }
133
+
134
+ --- What is stored under each data key.
135
+ export type DataRecord<T = any> = {
136
+ data: T?,
137
+ lock: Lock?,
138
+ pending: Pending<T>?,
139
+ migrations: { string },
140
+ }
141
+
142
+ --- FIFO mutex for coroutines. See `mutex.luau`.
143
+ export type Mutex = {
144
+ _locked: boolean,
145
+ _closed: string?,
146
+ _waiters: { thread },
147
+ _settlers: { thread },
148
+
149
+ scheduler: Scheduler,
150
+
151
+ wait_settled: (self: Mutex) -> (),
152
+ lock: (self: Mutex) -> (),
153
+ unlock: (self: Mutex) -> (),
154
+ close: (self: Mutex, reason: string) -> (),
155
+ is_locked: (self: Mutex) -> boolean,
156
+ is_closed: (self: Mutex) -> boolean,
157
+ with: (self: Mutex, fn: () -> ...any) -> any,
158
+ }
159
+
160
+ export type TxOutcome = "committed" | "aborted"
161
+
162
+ --- One participant's share of a multi-key transaction. Built by `tx_handle.run`
163
+ --- per profile kind; the operation drives it through prepare / commit / abort.
164
+ export type TxPatch = {
165
+ --- A `ProfileBase`. Typed `any` on purpose: `ProfileBase<T>` refers back to
166
+ --- `TxOperation`, and Luau rejects a generic type used with different
167
+ --- parameters inside one recursive group.
168
+ profile: any,
169
+ new_data: any,
170
+ --- Set once `prepare` succeeded (the record carries `pending`).
171
+ prepared: boolean,
172
+
173
+ --- Phase 1: persist `pending` next to the current data. Returns false when
174
+ --- the profile no longer owns its lock. May throw.
175
+ prepare: (pending: Pending) -> boolean,
176
+ --- Applies the outcome in memory. Called exactly once by the operation.
177
+ commit: (operation: TxOperation) -> (),
178
+ abort: (operation: TxOperation) -> (),
179
+ }
180
+
181
+ export type TxContext = {
182
+ get: <T>(self: TxContext, profile: ProfileBase<T>) -> T,
183
+ set: <T>(self: TxContext, profile: ProfileBase<T>, new: T) -> (),
184
+ }
185
+
186
+ --- in-flight multi-key transaction.
187
+ export type TxOperation = {
188
+ _resolving: boolean,
189
+ _applied: boolean,
190
+ _waiters: { thread },
191
+ _unclean: number,
192
+
193
+ id: string,
194
+ config: ResolvedTransactionConfig,
195
+ desired: TxOutcome,
196
+ outcome: TxOutcome?,
197
+
198
+ patches: { TxPatch },
199
+
200
+ --- Resolves the marker (blocking) and applies the outcome to every patch once.
201
+ settle: (self: TxOperation) -> TxOutcome,
202
+ --- A participant wrote a clean record; once all did, the marker is removed.
203
+ cleaned: (self: TxOperation) -> (),
204
+ }
205
+
206
+ --[[
207
+ Profiles
208
+ ]]
209
+
210
+ export type HooksList<T... = ()> = {
211
+ type: string,
212
+ hooks: Array<(T...) -> ()>,
213
+ }
214
+
215
+ export type ProfileBase<T = any> = {
216
+ kind: "locked" | "lockless",
217
+ key: string,
218
+ config: ResolvedConfig<T>,
219
+ user_ids: Array<number>,
220
+ _datastore: DatastoreHandle,
221
+ open: boolean,
222
+ migrations: Array<string>,
223
+
224
+ --- frozen; nil until loaded (locked) / fetched (lockless). Read through `get_data()`.
225
+ _data: T?,
226
+ _mutex: Mutex,
227
+ --- Operations waiting for this profile to write a record without `pending`.
228
+ _cleanup_txs: Array<TxOperation>,
229
+
230
+ --- Current data. Throws if the profile is closed or (lockless) not fetched.
231
+ get_data: (self: ProfileBase<T>) -> T,
232
+ save: (self: ProfileBase<T>) -> (),
233
+ unload: (self: ProfileBase<T>) -> (),
234
+ update: (self: ProfileBase<T>, (T) -> T | false) -> boolean,
235
+
236
+ on_change: (self: ProfileBase<T>, callback: (new: T, old: T?) -> ()) -> (),
237
+ on_save: (self: ProfileBase<T>, callback: () -> ()) -> (),
238
+ --- Fires at the start of `unload`, while the profile is still open: the
239
+ --- last chance to update before the final write.
240
+ on_closing: (self: ProfileBase<T>, callback: () -> ()) -> (),
241
+ --- Fires once the profile is closed and its final write (if any) is done.
242
+ --- Also fires after `lock_lost`.
243
+ on_closed: (self: ProfileBase<T>, callback: () -> ()) -> (),
244
+ on_lock_lost: (self: ProfileBase<T>, callback: () -> ()) -> (),
245
+
246
+ --- Yields until no operation holds or waits on the profile mutex. Once this
247
+ --- returns, the next update call is guaranteed not to yield (nothing is
248
+ --- queued ahead of it).
249
+ wait_settled: (self: ProfileBase<T>) -> (),
250
+
251
+ -- internal, used by transactions
252
+ _set_data: (self: ProfileBase<T>, data: T) -> (),
253
+ }
254
+
255
+ --- Session-locked profile.
256
+ export type Profile<T = any> = ProfileBase<T> & {
257
+ kind: "locked",
258
+ last_write: number,
259
+ --- Whether the profile currently holds its session lock. True after `load`;
260
+ --- `release` clears it, `readquire` takes it again. While released,
261
+ --- `update` / `save` throw and the profile cannot join a transaction.
262
+ is_locked: boolean,
263
+
264
+ _dirty: boolean,
265
+ _hooks: {
266
+ change: HooksList<T, T?>,
267
+ lock_lost: HooksList<()>,
268
+ save: HooksList<()>,
269
+ closing: HooksList<()>,
270
+ closed: HooksList<()>,
271
+ },
272
+
273
+ wait_closed: (self: Profile<T>) -> (),
274
+
275
+ --- Writes the data and gives the session lock back, keeping the profile
276
+ --- open so another server may take the key. No-op when already released.
277
+ release: (self: Profile<T>) -> (),
278
+ --- Takes the session lock again (waits like `load` does) and adopts the
279
+ --- stored record as the current data. No-op when already locked.
280
+ readquire: (self: Profile<T>) -> (),
281
+
282
+ -- internal, used by transactions. Returns false when the lock is gone.
283
+ _write_record: (self: Profile<T>, options: ProfileWriteOptions) -> boolean,
284
+ }
285
+
286
+ export type ProfileWriteOptions = {
287
+ release: boolean,
288
+ pending: Pending?,
289
+ }
290
+
291
+ --- Profile without a session lock; updates are queued and folded into storage on save.
292
+ export type LocklessProfile<T = any> = ProfileBase<T> & {
293
+ kind: "lockless",
294
+ fetched: boolean,
295
+
296
+ queued_updates: Array<DataDispatcher<T>>,
297
+ dirty_pending: boolean,
298
+ --- The transaction lock this profile believes storage holds (taken by
299
+ --- `_write_queued { tx_lock = lock }`, cleared by a write passing it as
300
+ --- `old_tx_lock`). nil when no lock is held.
301
+ _tx_lock: Lock?,
302
+ --- Outcomes of transactions whose `pending` this profile has seen on its record.
303
+ _known_outcomes: { [string]: TxOutcome },
304
+
305
+ _hooks: {
306
+ change: Array<(new: T, old: T?) -> ()>,
307
+ save: Array<() -> ()>,
308
+ closing: Array<() -> ()>,
309
+ closed: Array<() -> ()>,
310
+ --- A session lock showed up on the record (a locked profile owns the key
311
+ --- now); the profile closes.
312
+ lock_lost: Array<() -> ()>,
313
+ },
314
+
315
+ fetch: (self: LocklessProfile<T>) -> T,
316
+
317
+ -- internal, used by transactions
318
+ _write_queued: (self: LocklessProfile<T>, options: LocklessWriteOptions) -> (),
319
+ _queue_update: (self: LocklessProfile<T>, dispatcher: DataDispatcher<T>) -> boolean,
320
+ }
321
+
322
+ --- Read-only snapshot of a key taken with `get_async`. Not a `ProfileBase`:
323
+ --- it cannot write, hold a lock or join a transaction. Returned by `store:peek`.
324
+ export type PeekProfile<T = any> = {
325
+ kind: "peek",
326
+ key: string,
327
+ _config: ResolvedConfig<T>,
328
+ _datastore: DatastoreHandle,
329
+
330
+ --- Lock currently on the record (session or transaction), nil when free.
331
+ lock: Lock?,
332
+ --- Transaction marker on the record, if any. Left as stored, never resolved.
333
+ pending: Pending<T>?,
334
+ --- Migration names after the in-memory migrations of the last read.
335
+ migrations: Array<string>,
336
+
337
+ --- Frozen. The template when the key does not exist.
338
+ _data: T,
339
+
340
+ get_data: (self: PeekProfile<T>) -> T,
341
+ --- Calls `GetAsync` again and replaces `lock`, `pending`, `migrations` and
342
+ --- the data. Yields.
343
+ refresh: (self: PeekProfile<T>) -> T,
344
+ }
345
+
346
+ export type LocklessWriteOptions = {
347
+ --- Leave this transaction lock on the record with this write (built by the
348
+ --- transaction from its own config).
349
+ tx_lock: Lock?,
350
+ --- The transaction lock expected on the record. The write only goes through
351
+ --- (replacing that lock) when the stored lock has the same id; if the lock
352
+ --- is gone or is a different one, nothing is written and the call fails
353
+ --- with "lock_lost". Without it, any lock on the record blocks the write.
354
+ old_tx_lock: Lock?,
355
+ --- Persist `pending` with this write.
356
+ pending: Pending?,
357
+ }
358
+
359
+ --- What a store hands to `profile.load` / `lockless_profile.create`.
360
+ export type LoadOptions = {
361
+ key: string,
362
+ _datastore: DatastoreHandle,
363
+ config: ResolvedConfig,
364
+ user_ids: Array<number>,
365
+ }
366
+
367
+ export type MigrationsResult = {
368
+ applied: boolean,
369
+ success: boolean,
370
+ err: any,
371
+ result: any,
372
+ new_migrations: { string },
373
+ }
374
+
375
+ return nil
package/src/utils.luau ADDED
@@ -0,0 +1,104 @@
1
+ local types = require "./types"
2
+
3
+ export type Counter = types.Counter
4
+
5
+ local utils = {}
6
+
7
+ --- Recursively freezes a table (and nested tables). Non-table values are returned as-is.
8
+ function utils.deep_freeze<T>(value: T): T
9
+ if type(value) ~= "table" then
10
+ return value
11
+ end
12
+ local tbl = value :: any
13
+ if table.isfrozen(tbl) then
14
+ return value
15
+ end
16
+ for _, child in tbl do
17
+ if type(child) == "table" then
18
+ utils.deep_freeze(child)
19
+ end
20
+ end
21
+ table.freeze(tbl :: {})
22
+ return value
23
+ end
24
+
25
+ --- Recursively copies a table. The copy is not frozen.
26
+ function utils.deep_copy<T>(value: T): T
27
+ if type(value) ~= "table" then
28
+ return value
29
+ end
30
+ local copy = {}
31
+ for key, child in value :: any do
32
+ copy[key] = utils.deep_copy(child)
33
+ end
34
+ return copy :: any
35
+ end
36
+
37
+ --- Returns true when both tables have exactly the same set of keys.
38
+ function utils.same_key_set(a: { [any]: any }, b: { [any]: any }): boolean
39
+ for key in a do
40
+ if b[key] == nil then
41
+ return false
42
+ end
43
+ end
44
+ for key in b do
45
+ if a[key] == nil then
46
+ return false
47
+ end
48
+ end
49
+ return true
50
+ end
51
+
52
+ --- pcall that always yields exactly (ok, err_or_first_result), even when `fn` returns nothing.
53
+ function utils.try(fn: (...any) -> ...any, ...: any): (boolean, any)
54
+ local results = { pcall(fn, ...) }
55
+ return results[1], results[2]
56
+ end
57
+
58
+ --- Shallow copy of an array sorted with the provided comparator.
59
+ function utils.sorted<T>(list: { T }, less: (T, T) -> boolean): { T }
60
+ local copy = table.clone(list)
61
+ table.sort(copy, less)
62
+ return copy
63
+ end
64
+
65
+ local HEX = "0123456789abcdef"
66
+
67
+ --- Random 16-char hex id. Collisions are astronomically unlikely, Alternative to httpservice.
68
+ function utils.uuid(): string
69
+ local chars = table.create(16)
70
+ for index = 1, 16 do
71
+ local nibble = math.random(1, 16)
72
+ chars[index] = string.sub(HEX, nibble, nibble)
73
+ end
74
+ return table.concat(chars)
75
+ end
76
+
77
+ function utils.retry<T>(config: types.RetryConfig, callback: () -> T): T
78
+ local last_error: any = nil
79
+ local attempts = config.retry_attempts
80
+ local base = config.retry_base
81
+
82
+ for attempt = 1, attempts do
83
+ local ok, result = pcall(callback)
84
+ if ok then
85
+ return result
86
+ end
87
+ last_error = result
88
+ if attempt < attempts then
89
+ config._scheduler.wait(base * 2 ^ (attempt - 1))
90
+ end
91
+ end
92
+ error(last_error, 0)
93
+ end
94
+
95
+ function utils.counter(): Counter
96
+ local count = 0
97
+ return function()
98
+ local current = count
99
+ count += 1
100
+ return current
101
+ end
102
+ end
103
+
104
+ return utils