@rbxts/dataforge 0.1.0 → 0.2.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.
@@ -1,3 +1,4 @@
1
+ local errors = require "../errors"
1
2
  local types = require "../types"
2
3
 
3
4
  type Array<T> = { T }
@@ -8,25 +9,33 @@ export type MigrationsResult = types.MigrationsResult
8
9
 
9
10
  local common = {}
10
11
 
11
- --- Throws right away when the profile is closed, Its not necessary to add this, as it will get rejected either way,
12
- --- by the mutex, but its used to throw instantly.
12
+ --- Throws a `profile_closed` error right away when the profile is closed. Not
13
+ --- strictly needed (the closed mutex rejects the call anyway) but it fails
14
+ --- before queueing on the mutex.
13
15
  function common.assert_open(self: ProfileBase)
14
16
  if not self.open then
15
- error(`datastore: profile "{self.key}" is closed`, 3)
17
+ error(errors.closed(self), 0)
16
18
  end
17
19
  end
18
20
 
19
21
  --- Applies the declared migrations not yet in `applied` (in order). Pure, so it
20
- --- is safe inside an update transform. Fails when the record's applied list does
21
- --- not prefix-match the declared list.
22
- function common.apply_migrations(data: any, applied: { string }, migrations: Array<types.Migration>): MigrationsResult
22
+ --- is safe inside an update transform. Fails with `outdated` in `err` when the
23
+ --- record lists more migrations than declared (a newer server wrote it), or
24
+ --- `migration_mismatch` when a name in the applied prefix differs. An error
25
+ --- thrown by a migration's `apply` propagates as is.
26
+ function common.apply_migrations(
27
+ key: string,
28
+ data: any,
29
+ applied: { string },
30
+ migrations: Array<types.Migration>
31
+ ): MigrationsResult
23
32
  local applied_count = #applied
24
33
 
25
34
  if applied_count > #migrations then
26
35
  return {
27
36
  applied = false,
28
37
  success = false,
29
- err = `record has {applied_count} applied migrations but only {#migrations} are declared`,
38
+ err = errors.outdated(key, applied, common.all_migration_names(migrations)),
30
39
  new_migrations = applied,
31
40
  }
32
41
  end
@@ -38,7 +47,13 @@ function common.apply_migrations(data: any, applied: { string }, migrations: Arr
38
47
  return {
39
48
  applied = false,
40
49
  success = false,
41
- err = `migration mismatch at index {index}: record has "{actual}", declared is "{expected}"`,
50
+ err = errors.migration_mismatch(key, {
51
+ index = index,
52
+ expected = expected,
53
+ actual = actual,
54
+ applied = applied,
55
+ declared = common.all_migration_names(migrations),
56
+ }),
42
57
  new_migrations = applied,
43
58
  }
44
59
  end
@@ -67,6 +82,24 @@ function common.all_migration_names(migrations: Array<types.Migration>): { strin
67
82
  return names
68
83
  end
69
84
 
85
+ --- Whether the record lists more migrations than this server declares. Pure.
86
+ function common.is_outdated(record: types.DataRecord?, config: types.ResolvedConfig): boolean
87
+ if not record or not record.migrations then
88
+ return false
89
+ end
90
+ return #record.migrations > #config.migrations
91
+ end
92
+
93
+ --- Rethrows a failed `apply_migrations` with the profile attached to an
94
+ --- `outdated` error (a `migration_mismatch` has no profile field).
95
+ function common.raise_migrations(profile: ProfileBase, migrated: MigrationsResult)
96
+ local err: any = migrated.err
97
+ if err.type == "outdated" then
98
+ err.profile = profile
99
+ end
100
+ error(err, 0)
101
+ end
102
+
70
103
  function common.is_lock_owned(record: types.DataRecord?, job_id: string)
71
104
  if not record then
72
105
  return false
@@ -1,5 +1,7 @@
1
1
  local common = require "./common"
2
+ local errors = require "../errors"
2
3
  local mutex = require "../mutex"
4
+ local result = require "../result"
3
5
  local tx_handle = require "../tx/handle"
4
6
  local types = require "../types"
5
7
  local utils = require "../utils"
@@ -15,7 +17,7 @@ lockless_profile.__index = lockless_profile
15
17
  function lockless_profile.get_data(self: LocklessProfile)
16
18
  common.assert_open(self)
17
19
  if not self.fetched then
18
- error(`datastore: lockless profile "{self.key}" has not been fetched`)
20
+ error(errors.not_fetched(self), 0)
19
21
  end
20
22
  return self._data
21
23
  end
@@ -67,11 +69,11 @@ local function resolve_record(self: LocklessProfile, record: DataRecord?, data:
67
69
  return utils.deep_copy(config.template), common.all_migration_names(config.migrations)
68
70
  end
69
71
 
70
- local result = common.apply_migrations(data, applied, config.migrations)
71
- if not result.success then
72
- error(`datastore: failed to apply migrations for "{self.key}": {result.err}`)
72
+ local migrated = common.apply_migrations(self.key, data, applied, config.migrations)
73
+ if not migrated.success then
74
+ common.raise_migrations(self, migrated)
73
75
  end
74
- return result.result, result.new_migrations
76
+ return migrated.result, migrated.new_migrations
75
77
  end
76
78
 
77
79
  local function replay(queue: Array<types.DataDispatcher<any>>, data: any): any
@@ -108,13 +110,15 @@ local function process_lock_lost(self: LocklessProfile)
108
110
  return
109
111
  end
110
112
  self.open = false
111
- self._mutex:close(`datastore: profile "{self.key}" is session locked`)
113
+ self._mutex:close(errors.profile_closed(self, "lock_lost"))
112
114
  fire_hooks(self, "lock_lost", self._hooks.lock_lost)
113
115
  fire_hooks(self, "closed", self._hooks.closed)
114
116
  end
115
117
 
116
118
  type SaveState = {
117
119
  applied: boolean,
120
+ --- the lock found on the record (session or transaction), nil when none
121
+ lock: types.Lock?,
118
122
  --- a live transaction lock is on the record; `tx_lock_expires` says until when
119
123
  tx_locked: boolean,
120
124
  tx_lock_expires: number,
@@ -123,6 +127,9 @@ type SaveState = {
123
127
  tx_lock_lost: boolean,
124
128
  outdated: boolean,
125
129
  unknown_pending: types.Pending?,
130
+ --- the transform threw (a migration or a queued dispatcher): rethrown after
131
+ --- the storage call instead of being retried as a storage failure
132
+ failure: any,
126
133
  }
127
134
 
128
135
  type WriteOptions = types.LocklessWriteOptions
@@ -135,7 +142,12 @@ type WriteOptions = types.LocklessWriteOptions
135
142
  --- `self._tx_lock`). `options.old_tx_lock` is the lock this write expects to
136
143
  --- find on the record: the write only goes through (and clears that lock) when
137
144
  --- the stored lock has the same id. If the lock is gone or belongs to someone
138
- --- else, nothing is written and the call fails with "lock_lost".
145
+ --- else, nothing is written and the call fails with `tx_lock_lost`.
146
+ ---
147
+ --- Throws `lockless_locked` (a live session lock; the profile is closed),
148
+ --- `tx_lock_lost` (closed too), `outdated` (a newer server wrote the record;
149
+ --- nothing written), `migration_mismatch`, `roblox`, or whatever a queued
150
+ --- dispatcher / migration threw.
139
151
  local function write_queued(self: LocklessProfile, options: WriteOptions)
140
152
  local config = self.config
141
153
  local scheduler = config._scheduler
@@ -146,23 +158,19 @@ local function write_queued(self: LocklessProfile, options: WriteOptions)
146
158
 
147
159
  local state: SaveState = {
148
160
  applied = false,
161
+ lock = nil,
149
162
  tx_locked = false,
150
163
  tx_lock_expires = 0,
151
164
  tx_lock_lost = false,
152
165
  session_locked = false,
153
166
  outdated = false,
154
167
  unknown_pending = nil,
168
+ failure = nil,
155
169
  }
156
170
 
157
- local function transform(old: DataRecord?): DataRecord?
158
- state.applied = false
159
- state.tx_locked = false
160
- state.session_locked = false
161
- state.tx_lock_lost = false
162
- state.outdated = false
163
- state.unknown_pending = nil
164
-
171
+ local function transform_body(old: DataRecord?): DataRecord?
165
172
  local lock = if old then old.lock else nil
173
+ state.lock = lock
166
174
 
167
175
  -- a live session lock means a locked profile owns this key now: the
168
176
  -- profile lost its lockless standing, whatever else it expected on the
@@ -192,7 +200,7 @@ local function write_queued(self: LocklessProfile, options: WriteOptions)
192
200
 
193
201
  -- more migrations applied than this server declares: a newer server
194
202
  -- already touched the record, so this server must not write to it
195
- if old and old.migrations and #old.migrations > #config.migrations then
203
+ if common.is_outdated(old, config) then
196
204
  state.outdated = true
197
205
  return nil
198
206
  end
@@ -222,22 +230,45 @@ local function write_queued(self: LocklessProfile, options: WriteOptions)
222
230
  }
223
231
  end
224
232
 
233
+ -- the body runs migrations and queued dispatchers (user code) and may raise
234
+ -- a `migration_mismatch`: none of those is a storage failure, so the
235
+ -- storage retry must not see them. They cancel the write and are rethrown.
236
+ local function transform(old: DataRecord?): DataRecord?
237
+ state.applied = false
238
+ state.lock = nil
239
+ state.tx_locked = false
240
+ state.session_locked = false
241
+ state.tx_lock_lost = false
242
+ state.outdated = false
243
+ state.unknown_pending = nil
244
+ state.failure = nil
245
+
246
+ local ok, new = pcall(transform_body, old)
247
+ if not ok then
248
+ state.failure = new
249
+ return nil
250
+ end
251
+ return new
252
+ end
253
+
225
254
  while true do
226
- local record = utils.retry(config, function()
227
- return datastore.update_async(self.key, transform, self.user_ids)
228
- end) :: DataRecord
255
+ local record = utils.update_async(config, datastore, self.key, transform, self.user_ids) :: DataRecord
256
+
257
+ if state.failure ~= nil then
258
+ error(state.failure, 0)
259
+ end
229
260
 
230
261
  if state.session_locked then
231
262
  self._tx_lock = nil
232
263
  process_lock_lost(self)
233
- error(`datastore: "{self.key}" is session locked`)
264
+ error(errors.lockless_locked(self, state.lock :: types.Lock), 0)
234
265
  end
235
266
 
236
267
  if state.tx_lock_lost then
237
268
  -- whatever is on the record now is not ours; do not touch it again
238
269
  self._tx_lock = nil
239
270
  process_lock_lost(self)
240
- error(`datastore: lock_lost for "{self.key}"`)
271
+ error(errors.tx_lock_lost(self, old_tx_lock :: types.Lock, state.lock), 0)
241
272
  end
242
273
 
243
274
  if state.tx_locked then
@@ -248,7 +279,8 @@ local function write_queued(self: LocklessProfile, options: WriteOptions)
248
279
 
249
280
  if state.outdated then
250
281
  error(
251
- `datastore: "{self.key}" was written by a newer server (record has {#record.migrations} applied migrations, this server declares {#config.migrations})`
282
+ errors.outdated(self.key, record.migrations or {}, common.all_migration_names(config.migrations), self),
283
+ 0
252
284
  )
253
285
  end
254
286
 
@@ -307,16 +339,20 @@ end
307
339
 
308
340
  --- Applies every queued update to the stored data in one update_async and
309
341
  --- refreshes the local data with the result. Marks the profile fetched.
310
- --- Throws (keeping the queue) if another server holds a live session lock, or
311
- --- if the record was already written by a newer server (more migrations applied
312
- --- than this server declares). Neither case is retried.
313
- function lockless_profile.save(self: LocklessProfile)
314
- common.assert_open(self)
315
- -- before the mutex so hooks can still queue updates that land in this save
316
- fire_hooks(self, "save", self._hooks.save)
317
- common.assert_open(self)
318
- self._mutex:with(function()
319
- write_queued(self, {})
342
+ --- Fails (keeping the queue) with `lockless_locked` if another server holds a
343
+ --- live session lock (the profile is closed), `outdated` if the record was
344
+ --- already written by a newer server, `migration_mismatch`, `roblox` or
345
+ --- `profile_closed`. An error thrown by a queued dispatcher propagates as is.
346
+ function lockless_profile.save(self: LocklessProfile): types.Result<nil>
347
+ return result.capture(function()
348
+ common.assert_open(self)
349
+ -- before the mutex so hooks can still queue updates that land in this save
350
+ fire_hooks(self, "save", self._hooks.save)
351
+ common.assert_open(self)
352
+ self._mutex:with(function()
353
+ write_queued(self, {})
354
+ end)
355
+ return nil
320
356
  end)
321
357
  end
322
358
 
@@ -326,11 +362,15 @@ function lockless_profile.unload(self: LocklessProfile)
326
362
  end
327
363
  -- last chance to queue updates while the profile is still open
328
364
  fire_hooks(self, "closing", self._hooks.closing)
365
+ -- a closing hook may have unloaded the profile itself
366
+ if not self.open then
367
+ return
368
+ end
329
369
 
330
370
  self.open = false
331
371
  self._mutex:lock()
332
372
  -- everything still waiting on the mutex (and anything after) is rejected
333
- self._mutex:close(`datastore: profile "{self.key}" is closed`)
373
+ self._mutex:close(errors.profile_closed(self, "unloaded"))
334
374
  local ok, err = utils.try(function()
335
375
  if #self.queued_updates > 0 or self.dirty_pending then
336
376
  write_queued(self, {})
@@ -338,7 +378,9 @@ function lockless_profile.unload(self: LocklessProfile)
338
378
  end)
339
379
  self._mutex:unlock()
340
380
  if not ok then
341
- self.config._hook.warn(`datastore: failed to flush "{self.key}" on unload ({#self.queued_updates} changes lost): {tostring(err)}`)
381
+ self.config._hook.warn(
382
+ `datastore: failed to flush "{self.key}" on unload ({#self.queued_updates} changes lost): {tostring(err)}`
383
+ )
342
384
  end
343
385
  fire_hooks(self, "closed", self._hooks.closed)
344
386
  end
@@ -380,14 +422,12 @@ local function read_record(self: LocklessProfile)
380
422
  local config = self.config
381
423
  local datastore = self._datastore
382
424
 
383
- local record = utils.retry(config, function()
384
- return datastore.get_async(self.key)
385
- end) :: DataRecord?
425
+ local record = utils.get_async(config, datastore, self.key) :: DataRecord?
386
426
 
387
427
  local lock = if record then record.lock else nil
388
428
  if lock and not lock.tx and lock.expires > config._scheduler.now() then
389
429
  process_lock_lost(self)
390
- error(`datastore: "{self.key}" is session locked`)
430
+ error(errors.lockless_locked(self, lock), 0)
391
431
  end
392
432
  -- an expired session lock belongs to a dead server: ignored, the next write clears it
393
433
  -- a transaction lock does not matter for a read: the data under it is the
@@ -414,22 +454,32 @@ local function read_record(self: LocklessProfile)
414
454
  return self._data
415
455
  end
416
456
 
417
- function lockless_profile.fetch(self: LocklessProfile)
418
- common.assert_open(self)
419
- return self._mutex:with(function()
420
- return read_record(self)
457
+ --- Fails with `lockless_locked` (a session lock is on the record; the profile
458
+ --- is closed), `outdated` (a newer server wrote the record), `migration_mismatch`,
459
+ --- `roblox`, `tx_marker_invalid` or `profile_closed`. An error thrown by a
460
+ --- queued dispatcher propagates as is.
461
+ function lockless_profile.fetch(self: LocklessProfile): types.Result<any>
462
+ return result.capture(function()
463
+ common.assert_open(self)
464
+ return self._mutex:with(function()
465
+ return read_record(self)
466
+ end)
421
467
  end)
422
468
  end
423
469
 
424
470
  --- Queues `dispatcher` to run against the stored data on the next save. When
425
471
  --- the profile is fetched, the local data is advanced right away so `get_data()`
426
472
  --- reflects the queued change. A dispatcher returning `false` (or nil) is a
427
- --- no-op and is not queued. Waits for any in-flight save/fetch on the mutex.
428
- function lockless_profile.update(self: LocklessProfile, dispatcher: (any) -> any): boolean
429
- common.assert_open(self)
430
-
431
- return self._mutex:with(function()
432
- return queue_update(self, dispatcher)
473
+ --- no-op and is not queued (Ok(false)). Waits for any in-flight save/fetch on
474
+ --- the mutex. Fails with `profile_closed`; an error thrown by the dispatcher
475
+ --- propagates as is.
476
+ function lockless_profile.update(self: LocklessProfile, dispatcher: (any) -> any): types.Result<boolean>
477
+ return result.capture(function()
478
+ common.assert_open(self)
479
+
480
+ return self._mutex:with(function()
481
+ return queue_update(self, dispatcher)
482
+ end)
433
483
  end)
434
484
  end
435
485
 
@@ -449,9 +499,11 @@ local function run_flush_loop(self: LocklessProfile)
449
499
  continue
450
500
  end
451
501
 
452
- local ok, err = utils.try(self.save, self)
502
+ local ok, res = utils.try(self.save, self)
453
503
  if not ok then
454
- config._hook.warn(`datastore: failed to flush "{self.key}": {tostring(err)}`)
504
+ config._hook.warn(`datastore: failed to flush "{self.key}": {tostring(res)}`)
505
+ elseif not res.success then
506
+ config._hook.warn(`datastore: failed to flush "{self.key}": {tostring(res.error)}`)
455
507
  end
456
508
  end
457
509
  end)
@@ -1,4 +1,5 @@
1
1
  local common = require "./common"
2
+ local result = require "../result"
2
3
  local types = require "../types"
3
4
  local utils = require "../utils"
4
5
 
@@ -16,14 +17,13 @@ peek_profile.__index = peek_profile
16
17
 
17
18
  --- Reads the record with `GetAsync` and adopts it: `lock` and `pending` are
18
19
  --- taken as stored, the data gets the declared migrations applied in memory
19
- --- (nothing is written). A missing key resolves to the template.
20
+ --- (nothing is written). A missing key resolves to the template. Throws
21
+ --- `roblox`, `outdated` or `migration_mismatch`.
20
22
  local function read(self: PeekProfile): any
21
23
  local config = self._config
22
24
  local datastore = self._datastore
23
25
 
24
- local record = utils.retry(config, function()
25
- return datastore.get_async(self.key)
26
- end) :: DataRecord?
26
+ local record = utils.get_async(config, datastore, self.key) :: DataRecord?
27
27
 
28
28
  self.lock = if record then record.lock else nil
29
29
  self.pending = if record then record.pending else nil
@@ -34,12 +34,12 @@ local function read(self: PeekProfile): any
34
34
  data = utils.deep_copy(config.template)
35
35
  migrations = common.all_migration_names(config.migrations)
36
36
  else
37
- local result = common.apply_migrations(record.data, record.migrations or {}, config.migrations)
38
- if not result.success then
39
- error(`datastore: failed to apply migrations for "{self.key}": {result.err}`)
37
+ local migrated = common.apply_migrations(self.key, record.data, record.migrations or {}, config.migrations)
38
+ if not migrated.success then
39
+ error(migrated.err, 0)
40
40
  end
41
- data = result.result
42
- migrations = result.new_migrations
41
+ data = migrated.result
42
+ migrations = migrated.new_migrations
43
43
  end
44
44
 
45
45
  self.migrations = migrations
@@ -51,14 +51,17 @@ function peek_profile.get_data(self: PeekProfile)
51
51
  return self._data
52
52
  end
53
53
 
54
- --- Calls `get_async` again and replaces the snapshot. Yields.
55
- function peek_profile.refresh(self: PeekProfile)
56
- return read(self)
54
+ --- Calls `get_async` again and replaces the snapshot. Yields. Fails with
55
+ --- `roblox`, `outdated` or `migration_mismatch`, leaving the previous snapshot
56
+ --- in place.
57
+ function peek_profile.refresh(self: PeekProfile): types.Result<any>
58
+ return result.capture(read, self)
57
59
  end
58
60
 
59
61
  --- Takes the first snapshot right away (yields). The profile has no mutex, no
60
- --- loops and no hooks: it is a plain value that can be refreshed.
61
- function peek_profile.create(options: PeekOptions): PeekProfile
62
+ --- loops and no hooks: it is a plain value that can be refreshed. Fails with
63
+ --- `roblox`, `outdated` or `migration_mismatch`.
64
+ function peek_profile.create(options: PeekOptions): types.Result<PeekProfile>
62
65
  local self = {} :: PeekProfile
63
66
  self.kind = "peek"
64
67
  self.key = options.key
@@ -70,8 +73,11 @@ function peek_profile.create(options: PeekOptions): PeekProfile
70
73
  self._data = nil :: any
71
74
 
72
75
  local profile = setmetatable(self, peek_profile) :: any
73
- read(profile)
74
- return profile
76
+ local first = result.capture(read, profile)
77
+ if not first.success then
78
+ return first
79
+ end
80
+ return result.ok(profile)
75
81
  end
76
82
 
77
83
  return {