@acodeninja/persist 3.3.1 → 3.3.2

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.
@@ -15,8 +15,24 @@ export class Tag extends Persist.Model {
15
15
  static tag = Persist.Property.String.required;
16
16
  }
17
17
 
18
- const transaction = connection.start();
18
+ const transaction = connection.transaction();
19
19
 
20
20
  await transaction.put(new Tag({tag: 'documentation'}));
21
21
  await transaction.commit();
22
22
  ```
23
+
24
+ Nothing reaches storage until `commit()`. Reads inside a transaction go straight
25
+ to the engine, so they see the data as it was before the transaction started.
26
+
27
+ ### Write ordering
28
+
29
+ Both `put` and `delete` order their writes so that a reader never sees an index
30
+ entry without the model it points at:
31
+
32
+ - `put` writes the model, then the indexes that list it. An index that has not
33
+ caught up hides a model that is stored, so it reads as missing.
34
+ - `delete` writes the indexes, then deletes the model. A model nothing lists is
35
+ unreachable, so it costs storage.
36
+
37
+ The inverse of either leaves an index pointing at a model that is not there,
38
+ which fails every read of that id.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acodeninja/persist",
3
- "version": "3.3.1",
3
+ "version": "3.3.2",
4
4
  "description": "A JSON based data modelling and persistence module with alternate storage mechanisms.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -39,6 +39,9 @@
39
39
  "@eslint/js": "^10.0.1",
40
40
  "@jest/globals": "^30.4.1",
41
41
  "@semantic-release/commit-analyzer": "^13.0.1",
42
+ "@semantic-release/github": "^12.0.10",
43
+ "@semantic-release/npm": "^13.2.0",
44
+ "@semantic-release/release-notes-generator": "^14.1.1",
42
45
  "eslint": "^10.9.1",
43
46
  "globals": "^17.11.0",
44
47
  "husky": "^9.1.7",
package/src/Connection.js CHANGED
@@ -290,8 +290,12 @@ export default class Connection {
290
290
 
291
291
  await processModel(model);
292
292
 
293
+ // Models before the indexes that list them. Written the other way round,
294
+ // a reader that queries an index between the two writes finds an id whose
295
+ // model is not there yet, and a get on it fails.
296
+ await Promise.all(modelsToPut.map(m => this.#storage.putModel(m.toData())));
297
+
293
298
  await Promise.all([
294
- Promise.all(modelsToPut.map(m => this.#storage.putModel(m.toData()))),
295
299
  Promise.all(Object.entries(modelsToReindex).map(async ([constructorName, models]) => {
296
300
  const modelConstructor = this.#models.get(constructorName);
297
301
  const index = await this.#storage.getIndex(modelConstructor);
@@ -446,9 +450,11 @@ export default class Connection {
446
450
  state.updateIndex(indexName, index);
447
451
  }
448
452
 
453
+ // Indexes before the models they stop listing. Deleted first, an index
454
+ // left pointing at a model that is gone breaks every read of it, where a
455
+ // model no index lists is merely unreachable.
449
456
  await Promise.all([
450
457
  Promise.all([...modelsToUpdate].map(id => this.#storage.putModel(state.modelCache.get(id).toData()))),
451
- Promise.all([...modelsToDelete].map(id => this.#storage.deleteModel(id))),
452
458
  Promise.all(state
453
459
  .getTaintedIndexes()
454
460
  .entries()
@@ -464,6 +470,8 @@ export default class Connection {
464
470
  ),
465
471
  ),
466
472
  ]);
473
+
474
+ await Promise.all([...modelsToDelete].map(id => this.#storage.deleteModel(id)));
467
475
  }
468
476
 
469
477
  /**
@@ -160,34 +160,35 @@ export class Operation {
160
160
  }
161
161
  }
162
162
 
163
+ const QUEUED_OPERATIONS = ['putModel', 'deleteModel', 'putIndex', 'putSearchIndex'];
164
+
163
165
  /**
166
+ * Wrap a storage engine so that writes queue as operations and reads pass through.
164
167
  *
165
- * @param {Array<Operation>} transactions
168
+ * @param {Array<Operation>} operations
166
169
  * @param {StorageEngine} engine
167
170
  * @return {StorageEngine}
168
171
  */
169
172
  export function CreateTransactionalStorageEngine(operations, engine) {
170
- const transactionalEngine = Object.create(engine);
171
-
172
- transactionalEngine.putModel = (...args) => {
173
- operations.push(new Operation('putModel', ...args));
174
- return Promise.resolve();
175
- };
176
-
177
- transactionalEngine.deleteModel = (...args) => {
178
- operations.push(new Operation('deleteModel', ...args));
179
- return Promise.resolve();
180
- };
181
-
182
- transactionalEngine.putIndex = (...args) => {
183
- operations.push(new Operation('putIndex', ...args));
184
- return Promise.resolve();
185
- };
186
-
187
- transactionalEngine.putSearchIndex = (...args) => {
188
- operations.push(new Operation('putSearchIndex', ...args));
189
- return Promise.resolve();
190
- };
191
-
192
- return transactionalEngine;
173
+ return new Proxy(engine, {
174
+ get(target, property) {
175
+ if (QUEUED_OPERATIONS.includes(property))
176
+ return (...args) => {
177
+ operations.push(new Operation(property, ...args));
178
+ return Promise.resolve();
179
+ };
180
+
181
+ const value = Reflect.get(target, property, target);
182
+
183
+ // Methods are bound to the engine rather than to the wrapper. An
184
+ // engine holding its state in private class members cannot run its
185
+ // own methods with anything else as the receiver, and every read
186
+ // made through the transaction threw "Receiver must be an instance
187
+ // of". The class itself is left alone, because binding it renames it
188
+ // to "bound <Engine>" and the engine's errors quote that name.
189
+ return typeof value === 'function' && property !== 'constructor'
190
+ ? value.bind(target)
191
+ : value;
192
+ },
193
+ });
193
194
  }