@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/README.md ADDED
@@ -0,0 +1 @@
1
+ # DataForge
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "dataforge",
3
+ "tree": {
4
+ "$path": "src"
5
+ }
6
+ }
package/index.d.ts ADDED
@@ -0,0 +1,423 @@
1
+ /// <reference types="@rbxts/types" />
2
+
3
+ /**
4
+ * Roblox DataStore library with cross-key transactions, session-locked and
5
+ * lockless profiles, migrations and an immutable data model.
6
+ */
7
+ declare namespace dataforge {
8
+ /*
9
+ Storage backends
10
+ */
11
+
12
+ /**
13
+ * A storage handle for one named store. Deliberately not shaped like a
14
+ * Roblox DataStore. All methods throw on failure.
15
+ */
16
+ interface DatastoreHandle {
17
+ name: string;
18
+
19
+ get_async: (key: string) => unknown;
20
+ set_async: (key: string, value: unknown, user_ids?: number[]) => void;
21
+ /**
22
+ * Atomic per key. Cancels when `transform` returns `undefined` and
23
+ * returns the value stored after the call.
24
+ */
25
+ update_async: (key: string, transform: (old: unknown) => unknown, user_ids?: number[]) => unknown;
26
+ /** Optional; used to delete transaction markers. */
27
+ remove_async?: (key: string) => void;
28
+ }
29
+
30
+ /** How a store reaches storage. */
31
+ interface Hook {
32
+ /** Identifies this server. Session locks and transaction ids carry it. */
33
+ job_id: () => string;
34
+ /** Returns the handle for a named store. Called once per store name. */
35
+ get_datastore: (name: string) => DatastoreHandle;
36
+ /**
37
+ * Non-fatal problems (a hook that threw, a write that could not be
38
+ * released) are reported here instead of a global `warn`.
39
+ */
40
+ warn: (message: string) => void;
41
+ }
42
+
43
+ /** How the library spawns threads, waits and reads the clock. */
44
+ interface Scheduler {
45
+ spawn: (fn: ((...args: unknown[]) => unknown) | thread, ...args: unknown[]) => thread;
46
+ defer: (fn: ((...args: unknown[]) => unknown) | thread, ...args: unknown[]) => thread;
47
+ wait: (seconds?: number) => number;
48
+ now: () => number;
49
+ }
50
+
51
+ type MemoryHookOp = "get_async" | "set_async" | "update_async" | "remove_async" | "*";
52
+ type MemoryHookFailMode = "before" | "after";
53
+
54
+ interface MemoryHookOpLogEntry {
55
+ store: string;
56
+ key: string;
57
+ op: string;
58
+ }
59
+
60
+ /**
61
+ * In-memory hook meant for tests. Every handle created from one hook shares
62
+ * one backend table, so several stores over one hook behave like several
63
+ * servers. Values are deep-copied in and out.
64
+ */
65
+ interface MemoryHook extends Hook {
66
+ scheduler: Scheduler;
67
+
68
+ /** Raw backend: `data[store][key]`. */
69
+ data: Map<string, Map<string, unknown>>;
70
+ /** Seconds waited through the scheduler before every operation (default `0`). */
71
+ latency: number;
72
+ /** Log of every operation performed. */
73
+ ops: MemoryHookOpLogEntry[];
74
+ /** Every message the library reported through `warn` (nothing is printed). */
75
+ warnings: string[];
76
+
77
+ /**
78
+ * The next matching operation throws. `"before"` throws without touching
79
+ * the backend; `"after"` performs the write and then throws (unknown
80
+ * outcome). `op = "*"` matches any operation.
81
+ */
82
+ fail_next(
83
+ store: string,
84
+ key: string,
85
+ op: MemoryHookOp,
86
+ mode: MemoryHookFailMode,
87
+ message?: string,
88
+ ): void;
89
+ /** Drops pending injected failures. */
90
+ clear_failures(): void;
91
+ /** Deep copy of the stored record. */
92
+ dump(store: string, key: string): unknown;
93
+ }
94
+
95
+ /**
96
+ * Virtual-time scheduler for tests. Never yields the real thread: `wait`
97
+ * suspends the calling coroutine and registers it to resume at
98
+ * `now + seconds`.
99
+ */
100
+ interface VirtualScheduler extends Scheduler {
101
+ /**
102
+ * Advances the clock by `dt` and resumes every due thread in
103
+ * (resume time, registration order).
104
+ */
105
+ step: (dt: number) => void;
106
+ /** Runs everything due at the current time. */
107
+ flush: () => void;
108
+ /** Number of queued threads. */
109
+ pending: () => number;
110
+ /** Errors thrown by spawned threads. */
111
+ errors: string[];
112
+ }
113
+
114
+ /*
115
+ Configuration
116
+ */
117
+
118
+ interface Migration {
119
+ name: string;
120
+ apply: (data: unknown) => unknown;
121
+ }
122
+
123
+ interface Config<T = unknown> {
124
+ name: string;
125
+ template: T;
126
+
127
+ migrations?: Migration[];
128
+
129
+ /** seconds, default 30 (locked profiles) */
130
+ autosave_interval?: number;
131
+ /** seconds, default autosave_interval (lockless profiles) */
132
+ flush_interval?: number;
133
+ /** seconds, default 60 */
134
+ lock_ttl?: number;
135
+ /** seconds, default lock_ttl + 10 */
136
+ load_timeout?: number;
137
+ /** seconds, default 1 */
138
+ load_poll?: number;
139
+ /** default 5 */
140
+ retry_attempts?: number;
141
+ /** seconds, default 1 */
142
+ retry_base?: number;
143
+ }
144
+
145
+ /** `Config<T>` with every optional field filled in. */
146
+ type ResolvedConfig<T = unknown> = Required<Config<T>>;
147
+
148
+ interface TransactionConfig {
149
+ /**
150
+ * seconds, default 60. Grace period others give this transaction's
151
+ * `pending` before force-resolving it.
152
+ */
153
+ tx_ttl?: number;
154
+ /**
155
+ * seconds, default 10. How long a transaction holds a lockless key
156
+ * between taking its snapshot and writing `pending`.
157
+ */
158
+ lock_ttl?: number;
159
+ /** default 5 */
160
+ retry_attempts?: number;
161
+ /** seconds, default 1 */
162
+ retry_base?: number;
163
+ }
164
+
165
+ /*
166
+ Stored shapes
167
+ */
168
+
169
+ interface Lock {
170
+ id: string;
171
+ expires: number;
172
+ /**
173
+ * Short-lived lock a transaction takes on a lockless key. Other
174
+ * lockless writers wait it out instead of failing.
175
+ */
176
+ tx?: boolean;
177
+ }
178
+
179
+ interface Pending<T = unknown> {
180
+ tx_id: string;
181
+ store: string;
182
+ /**
183
+ * Until when a non-owner leaves this pending alone; after it, the marker
184
+ * may be force-resolved (fallback "aborted").
185
+ */
186
+ expires: number;
187
+ data: T;
188
+ }
189
+
190
+ /** What is stored under each data key. */
191
+ interface DataRecord<T = unknown> {
192
+ data?: T;
193
+ lock?: Lock;
194
+ pending?: Pending<T>;
195
+ migrations: string[];
196
+ }
197
+
198
+ type TxOutcome = "committed" | "aborted";
199
+
200
+ /*
201
+ Profiles
202
+ */
203
+
204
+ /**
205
+ * The transform passed to `update`. Returns the new data, or
206
+ * `undefined` / `false` to leave it untouched.
207
+ */
208
+ type DataDispatcher<T> = (data: T) => T | false | undefined;
209
+
210
+ /** Shared shape of both profile kinds. */
211
+ interface ProfileBase<T = unknown> {
212
+ readonly kind: "locked" | "lockless";
213
+ readonly key: string;
214
+ readonly config: ResolvedConfig<T>;
215
+ readonly user_ids: number[];
216
+ readonly open: boolean;
217
+ readonly migrations: string[];
218
+
219
+ /** Current data. Throws if the profile is closed or (lockless) not fetched. */
220
+ get_data(): T;
221
+ save(): void;
222
+ unload(): void;
223
+ /**
224
+ * Applies `dispatcher` to the current data. Returning `undefined` or
225
+ * `false` leaves the data untouched (returns `false`); anything else
226
+ * becomes the new data and is persisted on the next save.
227
+ */
228
+ update(dispatcher: DataDispatcher<T>): boolean;
229
+
230
+ on_change(callback: (newData: T, oldData: T | undefined) => void): void;
231
+ on_save(callback: () => void): void;
232
+ /**
233
+ * Fires at the start of `unload`, while the profile is still open: the
234
+ * last chance to update before the final write.
235
+ */
236
+ on_closing(callback: () => void): void;
237
+ /**
238
+ * Fires once the profile is closed and its final write (if any) is done.
239
+ * Also fires after `lock_lost`.
240
+ */
241
+ on_closed(callback: () => void): void;
242
+ on_lock_lost(callback: () => void): void;
243
+
244
+ /**
245
+ * Yields until no operation holds or waits on the profile mutex. Once
246
+ * this returns, the next update call is guaranteed not to yield.
247
+ */
248
+ wait_settled(): void;
249
+ }
250
+
251
+ /** Session-locked profile. */
252
+ interface Profile<T = unknown> extends ProfileBase<T> {
253
+ readonly kind: "locked";
254
+ readonly last_write: number;
255
+ /**
256
+ * Whether the profile currently holds its session lock. True after
257
+ * `load`; `release` clears it, `readquire` takes it again. While
258
+ * released, `update` / `save` throw and the profile cannot join a
259
+ * transaction.
260
+ */
261
+ readonly is_locked: boolean;
262
+
263
+ /** Yields until the closed profile finished its final write. Throws if still open. */
264
+ wait_closed(): void;
265
+
266
+ /**
267
+ * Writes the data and gives the session lock back, keeping the profile
268
+ * open so another server may take the key. No-op when already released.
269
+ */
270
+ release(): void;
271
+ /**
272
+ * Takes the session lock again (waits like `load` does) and adopts the
273
+ * stored record as the current data. No-op when already locked.
274
+ */
275
+ readquire(): void;
276
+ }
277
+
278
+ /** Profile without a session lock; updates are queued and folded into storage on save. */
279
+ interface LocklessProfile<T = unknown> extends ProfileBase<T> {
280
+ readonly kind: "lockless";
281
+ readonly fetched: boolean;
282
+
283
+ readonly queued_updates: DataDispatcher<T>[];
284
+ readonly dirty_pending: boolean;
285
+
286
+ /**
287
+ * Reads the stored record into the local data: migrations are applied,
288
+ * then the queued updates replayed on top. Marks the profile fetched.
289
+ * Yields. Throws (keeping the queue) if a session lock is on the record.
290
+ */
291
+ fetch(): T;
292
+ }
293
+
294
+ /** Either profile kind; narrow on `kind`. */
295
+ type AnyProfile<T = unknown> = Profile<T> | LocklessProfile<T>;
296
+
297
+ /**
298
+ * Read-only snapshot of a key. Not a `ProfileBase`: it cannot write, hold a
299
+ * lock or join a transaction. Returned by `store.peek`.
300
+ */
301
+ interface PeekProfile<T = unknown> {
302
+ readonly kind: "peek";
303
+ readonly key: string;
304
+
305
+ /** Lock currently on the record (session or transaction), `undefined` when free. */
306
+ readonly lock: Lock | undefined;
307
+ /** Transaction marker on the record, if any. Left as stored, never resolved. */
308
+ readonly pending: Pending<T> | undefined;
309
+ /** Migration names after the in-memory migrations of the last read. */
310
+ readonly migrations: string[];
311
+
312
+ get_data(): T;
313
+ /** Calls `GetAsync` again and replaces `lock`, `pending`, `migrations` and the data. Yields. */
314
+ refresh(): T;
315
+ }
316
+
317
+ /*
318
+ Transactions
319
+ */
320
+
321
+ interface TxContext {
322
+ get<T>(profile: ProfileBase<T>): T;
323
+ set<T>(profile: ProfileBase<T>, newData: T): void;
324
+ }
325
+
326
+ /*
327
+ Store
328
+ */
329
+
330
+ interface Store<T = unknown> {
331
+ readonly closed: boolean;
332
+ readonly config: ResolvedConfig<T>;
333
+
334
+ readonly profiles: ReadonlyMap<string, Profile<T>>;
335
+ readonly lockless_profiles: ReadonlyMap<string, LocklessProfile<T>>;
336
+
337
+ /** Takes the session lock for `key` and loads it. Yields. Throws when already loaded. */
338
+ load(key: string, user_ids: number[]): Profile<T>;
339
+ /** Returns the profile for `key`, waiting for it to load or loading it if nobody has yet. */
340
+ wait_loaded(key: string, user_ids: number[]): Profile<T>;
341
+ get_loaded(key: string): Profile<T> | undefined;
342
+
343
+ /**
344
+ * Returns the lockless profile for `key`, creating it if needed. Never
345
+ * yields: a lockless profile does no storage work until `fetch`.
346
+ */
347
+ get_lockless(key: string, user_ids: number[]): LocklessProfile<T>;
348
+
349
+ /**
350
+ * Read-only snapshot of `key` via `GetAsync`: the current data and lock.
351
+ * Yields. The result is not tracked by the store, takes no lock and
352
+ * cannot join a transaction.
353
+ */
354
+ peek(key: string): PeekProfile<T>;
355
+
356
+ /**
357
+ * Atomically updates profiles of this store (locked or lockless).
358
+ * `transform` gets their data in order and returns the new data in the
359
+ * same order, or `false` to cancel. Returns `false` when cancelled,
360
+ * `true` when committed; throws when aborted.
361
+ */
362
+ transaction(
363
+ profiles: ProfileBase<T>[],
364
+ transform: (data: T[]) => T[] | false,
365
+ config?: TransactionConfig,
366
+ ): boolean;
367
+
368
+ on_change(callback: (profile: AnyProfile<T>, newData: T, oldData: T | undefined) => void): void;
369
+ on_save(callback: (profile: AnyProfile<T>) => void): void;
370
+ on_closing(callback: (profile: AnyProfile<T>) => void): void;
371
+ on_closed(callback: (profile: AnyProfile<T>) => void): void;
372
+ on_lock_lost(callback: (profile: AnyProfile<T>) => void): void;
373
+
374
+ /**
375
+ * Unloads every profile (locked and lockless) concurrently and waits for
376
+ * all of them. Loads and transactions are rejected from the first line on.
377
+ */
378
+ close(): void;
379
+ }
380
+
381
+ /*
382
+ Module
383
+ */
384
+
385
+ /** Creates a store over the datastore named `config.name`. Throws if `name` or `template` is missing. */
386
+ function create_store<T>(config: Config<T>): Store<T>;
387
+
388
+ /**
389
+ * Atomically updates any set of profiles, across stores and mixing locked
390
+ * and lockless ones. `process` reads with `ctx.get(profile)` and stages
391
+ * writes with `ctx.set(profile, newData)`; return `false` to cancel.
392
+ *
393
+ * Returns `false` when cancelled, `true` when committed. Throws when aborted.
394
+ * `config` defaults its retry settings to the first profile's.
395
+ */
396
+ function transaction(
397
+ profiles: ProfileBase<any>[],
398
+ process: (ctx: TxContext) => boolean | undefined,
399
+ config?: TransactionConfig,
400
+ ): boolean;
401
+
402
+ namespace hooks {
403
+ namespace memory {
404
+ function create(scheduler: Scheduler): MemoryHook;
405
+ }
406
+ namespace roblox {
407
+ /** Adapts `DataStoreService`. Default inside Roblox. */
408
+ function create(): Hook;
409
+ }
410
+ }
411
+
412
+ namespace schedulers {
413
+ namespace virtual {
414
+ function create(): VirtualScheduler;
415
+ }
416
+ namespace roblox {
417
+ /** Wraps the `task` library. Default inside Roblox. */
418
+ function create(): Scheduler;
419
+ }
420
+ }
421
+ }
422
+
423
+ export = dataforge;
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@rbxts/dataforge",
3
+ "version": "0.1.0",
4
+ "description": "Roblox DataStore library with cross-key transactions, session-locked and lockless profiles, migrations and an immutable data model.",
5
+ "license": "MIT",
6
+ "author": "PepeElToro41",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/PepeElToro41/dataforge.git"
10
+ },
11
+ "homepage": "https://pepeeltoro41.github.io/dataforge/",
12
+ "keywords": [
13
+ "roblox",
14
+ "roblox-ts",
15
+ "datastore",
16
+ "luau"
17
+ ],
18
+ "main": "src/init.luau",
19
+ "types": "index.d.ts",
20
+ "files": [
21
+ "src",
22
+ "index.d.ts",
23
+ "default.project.json",
24
+ "README.md"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "devDependencies": {
30
+ "@rbxts/types": "^1.0.947"
31
+ }
32
+ }
@@ -0,0 +1,186 @@
1
+ --!strict
2
+
3
+ local types = require "../types"
4
+ local utils = require "../utils"
5
+
6
+ --[[
7
+ In-memory hook. Meant for tests (the library's own and yours).
8
+
9
+ - Every store handle created from the same hook shares one backend table, so
10
+ several `create_store` instances over one hook behave like several servers.
11
+ - Values are deep-copied on the way in and out (no aliasing with the backend).
12
+ - `latency` (seconds) is waited through the runtime before every operation, so
13
+ op interleavings are controllable under the virtual scheduler.
14
+ - `fail_next(store, key, op, mode)`: the next matching operation throws.
15
+ "before" throws without touching the backend; "after" performs the write and
16
+ then throws (simulates an unknown outcome / dying mid-call).
17
+ ]]
18
+
19
+ export type Op = "get_async" | "set_async" | "update_async" | "remove_async" | "*"
20
+ export type FailMode = "before" | "after"
21
+
22
+ export type OpLogEntry = { store: string, key: string, op: string }
23
+
24
+ export type MemoryHook = types.Hook & {
25
+ scheduler: types.Scheduler,
26
+
27
+ data: { [string]: { [string]: any } },
28
+ latency: number,
29
+ ops: { OpLogEntry },
30
+ --- every message the library reported through `warn` (nothing is printed)
31
+ warnings: { string },
32
+ fail_next: (self: MemoryHook, store: string, key: string, op: Op, mode: FailMode, message: string?) -> (),
33
+ dump: (self: MemoryHook, store: string, key: string) -> any?,
34
+ clear_failures: (self: MemoryHook) -> (),
35
+ }
36
+
37
+ type Failure = {
38
+ store: string,
39
+ key: string,
40
+ op: Op,
41
+ mode: FailMode,
42
+ message: string,
43
+ }
44
+
45
+ local HEX = "0123456789abcdef"
46
+
47
+ function random_jobid(): string
48
+ local chars = table.create(16)
49
+ for index = 1, 16 do
50
+ local nibble = math.random(1, 16)
51
+ chars[index] = string.sub(HEX, nibble, nibble)
52
+ end
53
+ return table.concat(chars)
54
+ end
55
+
56
+ local memory = {}
57
+
58
+ function memory.create(scheduler: types.Scheduler): MemoryHook
59
+ local handles: { [string]: types.DatastoreHandle } = {}
60
+ local failures: { Failure } = {}
61
+
62
+ local self = {} :: MemoryHook
63
+
64
+ self.data = {}
65
+ self.latency = 0
66
+ self.ops = {}
67
+ self.warnings = {}
68
+ self.scheduler = scheduler
69
+
70
+ function self.warn(message: string)
71
+ table.insert(self.warnings, message)
72
+ end
73
+
74
+ local function bucket(store: string): { [string]: any }
75
+ local existing = self.data[store]
76
+ if existing == nil then
77
+ existing = {}
78
+ self.data[store] = existing
79
+ end
80
+ return existing
81
+ end
82
+
83
+ local function take_failure(store: string, key: string, op: string, mode: FailMode): Failure?
84
+ for index, failure in failures do
85
+ local matches = failure.store == store
86
+ and (failure.key == "*" or failure.key == key)
87
+ and (failure.op == "*" or failure.op == op)
88
+ and failure.mode == mode
89
+ if matches then
90
+ table.remove(failures, index)
91
+ return failure
92
+ end
93
+ end
94
+ return nil
95
+ end
96
+
97
+ local function begin(store: string, key: string, op: string)
98
+ if self.latency > 0 then
99
+ scheduler.wait(self.latency)
100
+ end
101
+ table.insert(self.ops, { store = store, key = key, op = op })
102
+ local failure = take_failure(store, key, op, "before")
103
+ if failure then
104
+ error(failure.message, 0)
105
+ end
106
+ end
107
+
108
+ local function finish(store: string, key: string, op: string)
109
+ local failure = take_failure(store, key, op, "after")
110
+ if failure then
111
+ error(failure.message, 0)
112
+ end
113
+ end
114
+
115
+ -- one id per hook instance: locks are owned by job id, so it must be stable
116
+ local job_id = random_jobid()
117
+ function self.job_id()
118
+ return job_id
119
+ end
120
+
121
+ function self.get_datastore(name: string): types.DatastoreHandle
122
+ local existing = handles[name]
123
+ if existing then
124
+ return existing
125
+ end
126
+ local values = bucket(name)
127
+ local handle = {} :: types.DatastoreHandle
128
+ handle.name = name
129
+
130
+ function handle.get_async(key: string): any?
131
+ begin(name, key, "get_async")
132
+ local value = utils.deep_copy(values[key])
133
+ finish(name, key, "get_async")
134
+ return value
135
+ end
136
+
137
+ function handle.set_async(key: string, value: any, _user_ids: { number }?)
138
+ begin(name, key, "set_async")
139
+ values[key] = utils.deep_copy(value)
140
+ finish(name, key, "set_async")
141
+ end
142
+
143
+ function handle.update_async(key: string, transform: (old: any?) -> any?, _user_ids: { number }?): any?
144
+ begin(name, key, "update_async")
145
+ local old = utils.deep_copy(values[key])
146
+ local new = transform(old)
147
+ local result = old
148
+ if new ~= nil then
149
+ values[key] = utils.deep_copy(new)
150
+ result = new
151
+ end
152
+ finish(name, key, "update_async")
153
+ return result
154
+ end
155
+
156
+ function handle.remove_async(key: string)
157
+ begin(name, key, "remove_async")
158
+ values[key] = nil
159
+ finish(name, key, "remove_async")
160
+ end
161
+
162
+ handles[name] = handle
163
+ return handle
164
+ end
165
+
166
+ function self.fail_next(_, store, key, op, mode, message)
167
+ table.insert(failures, {
168
+ store = store,
169
+ key = key,
170
+ op = op,
171
+ mode = mode,
172
+ message = message or `memory hook: injected failure ({mode} {op} {store}/{key})`,
173
+ })
174
+ end
175
+ function self.clear_failures()
176
+ table.clear(failures)
177
+ end
178
+
179
+ function self.dump(_, store, key)
180
+ return utils.deep_copy(bucket(store)[key])
181
+ end
182
+
183
+ return self
184
+ end
185
+
186
+ return memory