@modelprofile.com/flexharness 11.3.0 → 12.0.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.
Files changed (65) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.flexharness.core.d.ts +4 -0
  3. package/dist_ts/classes.flexharness.core.js +69 -25
  4. package/dist_ts/classes.flexharness.imports.js +4 -3
  5. package/dist_ts/classes.flexharness.reversions.d.ts +7 -1
  6. package/dist_ts/classes.flexharness.reversions.js +43 -29
  7. package/dist_ts/classes.flexharness.sessions.js +2 -6
  8. package/dist_ts/classes.stores.js +20 -18
  9. package/dist_ts/index.d.ts +1 -0
  10. package/dist_ts/index.js +2 -1
  11. package/dist_ts/interfaces.d.ts +24 -2
  12. package/dist_ts/interfaces.flexharness.d.ts +8 -1
  13. package/dist_ts/interfaces.js +1 -1
  14. package/dist_ts/plugins.d.ts +3 -3
  15. package/dist_ts/plugins.js +3 -3
  16. package/dist_ts/utils.projectionchange.d.ts +34 -0
  17. package/dist_ts/utils.projectionchange.js +135 -0
  18. package/dist_ts/utils.storecodecs.d.ts +10 -3
  19. package/dist_ts/utils.storecodecs.js +29 -4
  20. package/dist_ts/utils.storerules.d.ts +1 -1
  21. package/dist_ts/utils.storerules.js +1 -1
  22. package/dist_ts_migration/v3_legacyflexharness.js +6 -5
  23. package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +5 -4
  24. package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +56 -54
  25. package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +176 -19
  26. package/dist_ts_stores_nosqldb/classes.recordstore.js +392 -67
  27. package/dist_ts_stores_nosqldb/index.d.ts +1 -1
  28. package/dist_ts_stores_nosqldb/index.js +2 -2
  29. package/dist_ts_stores_nosqldb/plugins.d.ts +1 -1
  30. package/dist_ts_stores_nosqldb/plugins.js +1 -1
  31. package/dist_ts_stores_testing/contract.d.ts +33 -0
  32. package/dist_ts_stores_testing/contract.js +566 -0
  33. package/dist_ts_stores_testing/fixtures.d.ts +20 -0
  34. package/dist_ts_stores_testing/fixtures.js +112 -0
  35. package/dist_ts_stores_testing/index.d.ts +2 -0
  36. package/dist_ts_stores_testing/index.js +3 -0
  37. package/dist_ts_stores_testing/plugins.d.ts +5 -0
  38. package/dist_ts_stores_testing/plugins.js +7 -0
  39. package/package.json +9 -3
  40. package/readme.md +6 -3
  41. package/ts/00_commitinfo_data.ts +1 -1
  42. package/ts/classes.flexharness.core.ts +74 -24
  43. package/ts/classes.flexharness.imports.ts +3 -2
  44. package/ts/classes.flexharness.reversions.ts +53 -28
  45. package/ts/classes.flexharness.sessions.ts +2 -5
  46. package/ts/classes.stores.ts +40 -37
  47. package/ts/index.ts +5 -0
  48. package/ts/interfaces.flexharness.ts +8 -1
  49. package/ts/interfaces.ts +27 -2
  50. package/ts/plugins.ts +12 -7
  51. package/ts/readme.md +6 -3
  52. package/ts/utils.projectionchange.ts +190 -0
  53. package/ts/utils.storecodecs.ts +42 -7
  54. package/ts/utils.storerules.ts +2 -2
  55. package/ts_migration/v3_legacyflexharness.ts +11 -6
  56. package/ts_stores_nosqldb/classes.nosqldbstores.ts +79 -71
  57. package/ts_stores_nosqldb/classes.recordstore.ts +491 -66
  58. package/ts_stores_nosqldb/index.ts +1 -0
  59. package/ts_stores_nosqldb/plugins.ts +4 -3
  60. package/ts_stores_nosqldb/readme.md +6 -2
  61. package/ts_stores_testing/contract.ts +776 -0
  62. package/ts_stores_testing/fixtures.ts +127 -0
  63. package/ts_stores_testing/index.ts +17 -0
  64. package/ts_stores_testing/plugins.ts +20 -0
  65. package/ts_stores_testing/readme.md +62 -0
@@ -39,9 +39,45 @@ export interface IFlexRecordMutation<TValue> {
39
39
  contention: (actualRevision: number) => Error;
40
40
  }
41
41
 
42
+ /** A write that replaces the stored value whole, decided from the record's revision alone. */
43
+ export interface IFlexRecordReplacement {
44
+ /** Decides on the stored value from the head's revision, 0 for an absent record. */
45
+ decide: (actualRevision: number) => IFlexRecordWrite | undefined;
46
+ /** The conflict to report when concurrent writers keep winning the compare-and-swap. */
47
+ contention: (actualRevision: number) => Error;
48
+ }
49
+
50
+ /**
51
+ * How a record holds a snapshot as a base plus the changes saved since. Every function throws a
52
+ * `FlexHarnessStoreFormatError` for a value it rejects.
53
+ */
54
+ export interface IFlexRecordLog<TSnapshot extends { revision: number }, TChange> {
55
+ /** Checks a snapshot in full. */
56
+ validate: (value: unknown) => TSnapshot;
57
+ /** Checks the form of a change. */
58
+ validateChange: (value: unknown) => TChange;
59
+ /** The snapshot `change` makes of `current`, before `validate` checks it. */
60
+ apply: (current: TSnapshot | undefined, change: TChange) => TSnapshot;
61
+ }
62
+
63
+ /** A change to save while the record's head holds `expectedRevision`; it makes the next one. */
64
+ export interface IFlexRecordChange<TChange> {
65
+ expectedRevision: number;
66
+ /** The change, already checked by the log's `validateChange`. */
67
+ value: TChange;
68
+ /** The conflict to report when the head holds another revision. */
69
+ conflict: (actualRevision: number) => Error;
70
+ }
71
+
72
+ interface IFlexRecordDecision {
73
+ document?: FlexRecordDocument;
74
+ write?: IFlexRecordWrite;
75
+ }
76
+
42
77
  export const FLEX_RECORD_COLLECTION = 'flexharness_records';
43
78
  export const FLEX_CHUNK_COLLECTION = 'flexharness_chunks';
44
79
  export const FLEX_NAMESPACE_COLLECTION = 'flexharness_namespaces';
80
+ export const FLEX_CHANGE_COLLECTION = 'flexharness_changes';
45
81
 
46
82
  /**
47
83
  * UTF-16 code units a record holds inline, and the size of each chunk of a longer payload. A
@@ -50,6 +86,18 @@ export const FLEX_NAMESPACE_COLLECTION = 'flexharness_namespaces';
50
86
  */
51
87
  export const FLEX_RECORD_CHUNK_CODE_UNITS = 1024 * 1024;
52
88
 
89
+ /**
90
+ * UTF-16 code units the changes of a log may reach however short its base, so a young record
91
+ * keeps its changes instead of compacting on nearly every save.
92
+ */
93
+ export const FLEX_RECORD_MIN_LOG_CODE_UNITS = 64 * 1024;
94
+
95
+ /**
96
+ * UTF-16 code units of changes a log record's head holds inline. The save that would exceed it
97
+ * moves them, with its own change, into one change page.
98
+ */
99
+ export const FLEX_RECORD_INLINE_CHANGE_CODE_UNITS = 16 * 1024;
100
+
53
101
  const mutationAttempts = 8;
54
102
  const mutationBackoffBaseMs = 5;
55
103
  const mutationBackoffMaxMs = 200;
@@ -60,7 +108,13 @@ const defaultOperationTimeoutMs = 30_000;
60
108
  * The head of one record. A payload up to one chunk is stored inline; a longer one lives in
61
109
  * immutable chunks named by `blobId`. `writeToken` changes on every committed write and is the
62
110
  * compare-and-swap fence. A head without payload or blob is a placeholder that reserves the
63
- * record for a chunked first write; it reads as absent.
111
+ * record for a first write longer than one chunk; it reads as absent.
112
+ *
113
+ * A log record's payload is a base, `{ base }`, always in chunks, and its head names a log,
114
+ * `logId`: the `changeCount` changes saved since the base. The head holds the latest of them in
115
+ * `recentChanges`; the earlier ones fill change pages, chained from `lastPageId` back to the log.
116
+ * A head without `logId`, also one an earlier version wrote, holds its value whole. A write that
117
+ * replaces a log names it `retiredLogId`, and the next write deletes its pages.
64
118
  */
65
119
  export class FlexRecordDocument extends plugins.SmartDataDbDoc<FlexRecordDocument, FlexRecordDocument> {
66
120
  public recordId!: string;
@@ -76,6 +130,21 @@ export class FlexRecordDocument extends plugins.SmartDataDbDoc<FlexRecordDocumen
76
130
  /** Set before the first chunk of this record is written; the record's writes then sweep. */
77
131
  public chunked!: boolean;
78
132
  public sweepCount!: number;
133
+ /** The write token of the base's commit, which names the log; null without a log. */
134
+ public logId!: string | null;
135
+ public changeCount!: number;
136
+ /** The log's changes saved since its last page, oldest first; empty without a log. */
137
+ public recentChanges!: string[];
138
+ /** The log's latest change page; null while the log has none. */
139
+ public lastPageId!: string | null;
140
+ /**
141
+ * UTF-16 code units of the log's changes and of its base; a log outgrows neither its base nor,
142
+ * for a shorter base, `FLEX_RECORD_MIN_LOG_CODE_UNITS`.
143
+ */
144
+ public changeLength!: number;
145
+ public baseLength!: number;
146
+ /** The log the last write replaced, whose pages the next write deletes; null without one. */
147
+ public retiredLogId!: string | null;
79
148
  }
80
149
 
81
150
  /** One immutable piece of a chunked payload, written against the head token it will replace. */
@@ -91,6 +160,21 @@ export class FlexChunkDocument extends plugins.SmartDataDbDoc<FlexChunkDocument,
91
160
  public data!: string;
92
161
  }
93
162
 
163
+ /**
164
+ * One immutable page of a log record's changes, oldest first, written before the head swap that
165
+ * commits it. `previousId` names the log's previous page, or the log itself for its first page.
166
+ */
167
+ export class FlexChangePageDocument extends plugins.SmartDataDbDoc<FlexChangePageDocument, FlexChangePageDocument> {
168
+ public pageId!: string;
169
+ public recordId!: string;
170
+ public domain!: TFlexRecordDomain;
171
+ public ownerKey!: string;
172
+ public namespaceKey!: string;
173
+ public logId!: string;
174
+ public previousId!: string;
175
+ public changes!: string[];
176
+ }
177
+
94
178
  /**
95
179
  * One storage namespace. Every record creation in the namespace increments `creationCount` in
96
180
  * its transaction, so a concurrent fenced purge, which sets `fenced`, conflicts with it. A fenced
@@ -104,17 +188,21 @@ export class FlexNamespaceDocument extends plugins.SmartDataDbDoc<FlexNamespaceD
104
188
 
105
189
  type TRecordModel = typeof FlexRecordDocument;
106
190
  type TChunkModel = typeof FlexChunkDocument;
191
+ type TChangePageModel = typeof FlexChangePageDocument;
107
192
  type TNamespaceModel = typeof FlexNamespaceDocument;
108
193
 
109
- interface IBoundModels {
194
+ export interface IFlexRecordModels {
110
195
  records: TRecordModel;
111
196
  chunks: TChunkModel;
197
+ changes: TChangePageModel;
112
198
  namespaces: TNamespaceModel;
113
199
  }
114
200
 
115
- function bindModels(db: plugins.SmartdataDb): IBoundModels {
201
+ /** Binds the record, chunk, change page and namespace models to a database. */
202
+ export function bindFlexRecordModels(db: plugins.SmartdataDb): IFlexRecordModels {
116
203
  class BoundRecord extends FlexRecordDocument {}
117
204
  class BoundChunk extends FlexChunkDocument {}
205
+ class BoundChangePage extends FlexChangePageDocument {}
118
206
  class BoundNamespace extends FlexNamespaceDocument {}
119
207
  plugins.defineCollectionModel(BoundRecord, db, {
120
208
  collectionName: FLEX_RECORD_COLLECTION,
@@ -131,8 +219,15 @@ function bindModels(db: plugins.SmartdataDb): IBoundModels {
131
219
  'payloadSha256',
132
220
  'chunked',
133
221
  'sweepCount',
222
+ 'logId',
223
+ 'changeCount',
224
+ 'recentChanges',
225
+ 'lastPageId',
226
+ 'changeLength',
227
+ 'baseLength',
228
+ 'retiredLogId',
134
229
  ],
135
- numericFields: ['revision', 'chunkCount', 'sweepCount'],
230
+ numericFields: ['revision', 'chunkCount', 'sweepCount', 'changeCount', 'changeLength', 'baseLength'],
136
231
  identityFields: ['recordId'],
137
232
  indexes: [
138
233
  { name: 'flexharness_record_id', key: { recordId: 1 }, options: { unique: true } },
@@ -163,6 +258,26 @@ function bindModels(db: plugins.SmartdataDb): IBoundModels {
163
258
  { name: 'flexharness_chunk_namespace', key: { namespaceKey: 1, domain: 1 } },
164
259
  ],
165
260
  });
261
+ plugins.defineCollectionModel(BoundChangePage, db, {
262
+ collectionName: FLEX_CHANGE_COLLECTION,
263
+ persistedFields: [
264
+ 'pageId',
265
+ 'recordId',
266
+ 'domain',
267
+ 'ownerKey',
268
+ 'namespaceKey',
269
+ 'logId',
270
+ 'previousId',
271
+ 'changes',
272
+ ],
273
+ identityFields: ['pageId'],
274
+ indexes: [
275
+ { name: 'flexharness_change_page', key: { pageId: 1 }, options: { unique: true } },
276
+ { name: 'flexharness_change_log', key: { recordId: 1, logId: 1 } },
277
+ { name: 'flexharness_change_owner', key: { ownerKey: 1, domain: 1 } },
278
+ { name: 'flexharness_change_namespace', key: { namespaceKey: 1, domain: 1 } },
279
+ ],
280
+ });
166
281
  plugins.defineCollectionModel(BoundNamespace, db, {
167
282
  collectionName: FLEX_NAMESPACE_COLLECTION,
168
283
  persistedFields: ['namespaceKey', 'fenced', 'creationCount'],
@@ -172,7 +287,7 @@ function bindModels(db: plugins.SmartdataDb): IBoundModels {
172
287
  { name: 'flexharness_namespace_key', key: { namespaceKey: 1 }, options: { unique: true } },
173
288
  ],
174
289
  });
175
- return { records: BoundRecord, chunks: BoundChunk, namespaces: BoundNamespace };
290
+ return { records: BoundRecord, chunks: BoundChunk, changes: BoundChangePage, namespaces: BoundNamespace };
176
291
  }
177
292
 
178
293
  interface IResolvedAddress {
@@ -188,6 +303,11 @@ interface IStoredHead {
188
303
  text?: string;
189
304
  }
190
305
 
306
+ interface IStoredLog<TSnapshot> {
307
+ document?: FlexRecordDocument;
308
+ value?: TSnapshot;
309
+ }
310
+
191
311
  const digest = (value: string): string =>
192
312
  plugins.crypto.createHash('sha256').update(value, 'utf8').digest('hex');
193
313
 
@@ -240,12 +360,22 @@ export interface IFlexRecordStoreOptions {
240
360
  * replaced or abandoned payload are swept in a transaction that also touches the head, so a
241
361
  * concurrent deletion or writer can never lose chunks its head references. A head is created in
242
362
  * a transaction that checks its namespace document, so a fenced namespace gains no record.
363
+ *
364
+ * A log record takes a change in the head swap itself, which appends it to the head's recent
365
+ * changes, so a save costs the change rather than the record and reads and writes only the head.
366
+ * The save that would grow the recent changes past `FLEX_RECORD_INLINE_CHANGE_CODE_UNITS` writes
367
+ * them, with its own change, as one change page first and empties them in its swap. The base lives
368
+ * in chunks whatever its length, so the head the swap rewrites holds no payload. Once the changes
369
+ * would outgrow the base, or a shorter base's minimum room, a save compacts the record instead: it
370
+ * writes the base with every change applied, checked in full.
371
+ * The change pages of earlier logs are swept like chunks.
243
372
  */
244
373
  export class FlexRecordStore {
245
374
  private readonly db: plugins.SmartdataDb;
246
375
  private readonly timeoutMs: number;
247
376
  private readonly records: TRecordModel;
248
377
  private readonly chunks: TChunkModel;
378
+ private readonly changes: TChangePageModel;
249
379
  private readonly namespaces: TNamespaceModel;
250
380
  private preparation?: Promise<void>;
251
381
 
@@ -256,7 +386,12 @@ export class FlexRecordStore {
256
386
  }
257
387
  this.db = options.db;
258
388
  this.timeoutMs = timeoutMs;
259
- ({ records: this.records, chunks: this.chunks, namespaces: this.namespaces } = bindModels(options.db));
389
+ ({
390
+ records: this.records,
391
+ chunks: this.chunks,
392
+ changes: this.changes,
393
+ namespaces: this.namespaces,
394
+ } = bindFlexRecordModels(options.db));
260
395
  }
261
396
 
262
397
  /** Creates or verifies the collections and their indexes once; a failed attempt is retried. */
@@ -283,6 +418,7 @@ export class FlexRecordStore {
283
418
  const options = { mode: 'ensureCompatible' as const, timeoutMs: this.timeoutMs };
284
419
  await this.records.prepareCollection(options);
285
420
  await this.chunks.prepareCollection(options);
421
+ await this.changes.prepareCollection(options);
286
422
  await this.namespaces.prepareCollection(options);
287
423
  }
288
424
 
@@ -308,7 +444,51 @@ export class FlexRecordStore {
308
444
  ): Promise<TValue | undefined> {
309
445
  await this.prepare();
310
446
  const resolved = this.resolve(address);
311
- return this.parse(resolved, await this.readHead(resolved), validate);
447
+ const head = await this.readHead(resolved);
448
+ return head.text === undefined ? undefined : this.parse(resolved, head.text, validate);
449
+ }
450
+
451
+ /** Reads a log record: its base with every change applied, checked in full. */
452
+ public async readLog<TSnapshot extends { revision: number }, TChange>(
453
+ address: IFlexRecordAddress,
454
+ log: IFlexRecordLog<TSnapshot, TChange>,
455
+ ): Promise<TSnapshot | undefined> {
456
+ await this.prepare();
457
+ return (await this.loadLog(this.resolve(address), log)).value;
458
+ }
459
+
460
+ /**
461
+ * Saves a change to a log record while its head holds the expected revision. Only the head is
462
+ * read when the record takes the change; a compaction loads the record and checks the result in
463
+ * full. A change the record takes is checked in form only, by the caller; its content is checked
464
+ * once a compaction or a read applies it.
465
+ */
466
+ public async append<TSnapshot extends { revision: number }, TChange>(
467
+ address: IFlexRecordAddress,
468
+ log: IFlexRecordLog<TSnapshot, TChange>,
469
+ change: IFlexRecordChange<TChange>,
470
+ ): Promise<void> {
471
+ await this.prepare();
472
+ const resolved = this.resolve(address);
473
+ const text = JSON.stringify(change.value);
474
+ const revision = change.expectedRevision + 1;
475
+ let actualRevision = 0;
476
+ for (let attempt = 0; attempt < mutationAttempts; attempt++) {
477
+ if (attempt > 0) await mutationBackoff(attempt);
478
+ const head = await this.findHead(resolved);
479
+ actualRevision = head?.revision ?? 0;
480
+ if (actualRevision !== change.expectedRevision) throw change.conflict(actualRevision);
481
+ if (head && this.takesChange(head, text)) {
482
+ if (await this.commitChange(resolved, head, revision, text)) return;
483
+ continue;
484
+ }
485
+ const stored = await this.loadLog(resolved, log);
486
+ actualRevision = stored.document?.revision ?? 0;
487
+ if (actualRevision !== change.expectedRevision) throw change.conflict(actualRevision);
488
+ const value = log.validate(log.apply(stored.value, change.value));
489
+ if (await this.commit(resolved, stored.document, { revision, value }, true)) return;
490
+ }
491
+ throw change.conflict(actualRevision);
312
492
  }
313
493
 
314
494
  public async mutate<TValue>(
@@ -317,27 +497,56 @@ export class FlexRecordStore {
317
497
  ): Promise<void> {
318
498
  await this.prepare();
319
499
  const resolved = this.resolve(address);
500
+ await this.swap(resolved, mutation.contention, async () => {
501
+ const head = await this.readHead(resolved);
502
+ const current = head.text === undefined
503
+ ? undefined
504
+ : this.parse(resolved, head.text, mutation.validate);
505
+ return { document: head.document, write: mutation.decide(current) };
506
+ });
507
+ }
508
+
509
+ /**
510
+ * Replaces a record's value on a decision from its revision alone. Only the head is read: the
511
+ * stored payload and its chunks are neither loaded nor validated, since the write replaces them.
512
+ */
513
+ public async replace(
514
+ address: IFlexRecordAddress,
515
+ replacement: IFlexRecordReplacement,
516
+ ): Promise<void> {
517
+ await this.prepare();
518
+ const resolved = this.resolve(address);
519
+ await this.swap(resolved, replacement.contention, async () => {
520
+ const document = await this.findHead(resolved) ?? undefined;
521
+ return { document, write: replacement.decide(document?.revision ?? 0) };
522
+ });
523
+ }
524
+
525
+ /** Decides and commits until a head swap wins or a decision writes nothing. */
526
+ private async swap(
527
+ resolved: IResolvedAddress,
528
+ contention: (actualRevision: number) => Error,
529
+ decide: () => Promise<IFlexRecordDecision>,
530
+ ): Promise<void> {
320
531
  let actualRevision = 0;
321
532
  for (let attempt = 0; attempt < mutationAttempts; attempt++) {
322
533
  if (attempt > 0) await mutationBackoff(attempt);
323
- const head = await this.readHead(resolved);
324
- const current = this.parse(resolved, head, mutation.validate);
325
- actualRevision = head.document?.revision ?? 0;
326
- const write = mutation.decide(current);
534
+ const { document, write } = await decide();
535
+ actualRevision = document?.revision ?? 0;
327
536
  if (!write) return;
328
- if (await this.commit(resolved, head.document, write)) return;
537
+ if (await this.commit(resolved, document, write)) return;
329
538
  }
330
- throw mutation.contention(actualRevision);
539
+ throw contention(actualRevision);
331
540
  }
332
541
 
333
- /** Deletes one record and its chunks. */
542
+ /** Deletes one record with its chunks and change pages. */
334
543
  public async delete(address: IFlexRecordAddress): Promise<void> {
335
544
  await this.prepare();
336
545
  const resolved = this.resolve(address);
337
546
  await this.deleteInTransaction(resolved.path, { recordId: resolved.recordId, domain: resolved.domain });
338
547
  }
339
548
 
340
- /** Deletes every record and chunk one session owns in the given domains. */
549
+ /** Deletes every record, chunk and change page one session owns in the given domains. */
341
550
  public async deleteOwner(
342
551
  storageKey: string,
343
552
  sessionId: string,
@@ -352,8 +561,8 @@ export class FlexRecordStore {
352
561
  }
353
562
 
354
563
  /**
355
- * Deletes every record and chunk of a storage namespace in one domain, or in every domain
356
- * together with its namespace document unless a fence holds it.
564
+ * Deletes every record, chunk and change page of a storage namespace in one domain, or in every
565
+ * domain together with its namespace document unless a fence holds it.
357
566
  */
358
567
  public async purgeNamespace(storageKey: string, domain?: TFlexRecordDomain): Promise<void> {
359
568
  await this.prepare();
@@ -371,9 +580,10 @@ export class FlexRecordStore {
371
580
  }
372
581
 
373
582
  /**
374
- * Fences a storage namespace and deletes every record and chunk of it in one transaction. The
375
- * fence is permanent: every later record creation in the namespace is refused, and a creation
376
- * that runs concurrently either commits first and is deleted, or conflicts and is refused.
583
+ * Fences a storage namespace and deletes every record, chunk and change page of it in one
584
+ * transaction. The fence is permanent: every later record creation in the namespace is refused,
585
+ * and a creation that runs concurrently either commits first and is deleted, or conflicts and is
586
+ * refused.
377
587
  */
378
588
  public async fenceNamespace(storageKey: string): Promise<void> {
379
589
  await this.prepare();
@@ -389,7 +599,7 @@ export class FlexRecordStore {
389
599
  );
390
600
  }
391
601
 
392
- /** Deletes the selected records and chunks, after `namespace` changes the namespace document. */
602
+ /** Deletes the selected records, chunks and change pages, after `namespace` changes the namespace document. */
393
603
  private async deleteInTransaction(
394
604
  path: string,
395
605
  selector:
@@ -404,6 +614,7 @@ export class FlexRecordStore {
404
614
  await namespace?.(transaction);
405
615
  await this.records.atomicDeleteMany(selector, { session: transaction });
406
616
  await this.chunks.atomicDeleteMany(selector, { session: transaction });
617
+ await this.changes.atomicDeleteMany(selector, { session: transaction });
407
618
  }, { timeoutMS: this.timeoutMs });
408
619
  } catch (error) {
409
620
  if (plugins.SmartdataSession.getCommitOutcome(error) === 'not_committed') throw error;
@@ -415,18 +626,22 @@ export class FlexRecordStore {
415
626
 
416
627
  private parse<TValue>(
417
628
  resolved: IResolvedAddress,
418
- head: IStoredHead,
629
+ text: string,
419
630
  validate: (value: unknown) => TValue,
420
- ): TValue | undefined {
421
- if (head.text === undefined) return undefined;
631
+ ): TValue {
422
632
  let parsed: unknown;
423
633
  try {
424
- parsed = JSON.parse(head.text);
634
+ parsed = JSON.parse(text);
425
635
  } catch (error) {
426
636
  throw new FlexHarnessStoreFormatError(`Malformed JSON at ${resolved.path}.`, { cause: error });
427
637
  }
638
+ return this.check(resolved, () => validate(parsed));
639
+ }
640
+
641
+ /** Runs a check of stored data and names the record in a format error it throws. */
642
+ private check<TValue>(resolved: IResolvedAddress, check: () => TValue): TValue {
428
643
  try {
429
- return validate(parsed);
644
+ return check();
430
645
  } catch (error) {
431
646
  if (error instanceof FlexHarnessStoreFormatError) {
432
647
  throw new FlexHarnessStoreFormatError(`Invalid record at ${resolved.path}: ${error.message}`, {
@@ -461,6 +676,94 @@ export class FlexRecordStore {
461
676
  );
462
677
  }
463
678
 
679
+ /**
680
+ * Loads a log record with the head it was read at. A head without a log holds its value whole;
681
+ * a base must be wrapped, so a version that reads values whole fails on it.
682
+ */
683
+ private async loadLog<TSnapshot extends { revision: number }, TChange>(
684
+ resolved: IResolvedAddress,
685
+ log: IFlexRecordLog<TSnapshot, TChange>,
686
+ ): Promise<IStoredLog<TSnapshot>> {
687
+ for (let attempt = 0; attempt < chunkReadAttempts; attempt++) {
688
+ const head = await this.readHead(resolved);
689
+ const document = head.document;
690
+ if (!document || head.text === undefined) return { document };
691
+ if (!document.logId) return { document, value: this.parse(resolved, head.text, log.validate) };
692
+ const changes = await this.readChanges(document);
693
+ if (changes) {
694
+ return { document, value: this.fold(resolved, document, head.text, changes, log) };
695
+ }
696
+ const latest = await this.findHead(resolved);
697
+ if (latest?.writeToken === document.writeToken) {
698
+ throw new FlexHarnessStoreFormatError(
699
+ `${resolved.path} references ${document.changeCount} changes of log "${document.logId}", but they are incomplete.`,
700
+ );
701
+ }
702
+ }
703
+ throw new FlexHarnessStoreFormatError(
704
+ `${resolved.path} changed during ${chunkReadAttempts} consecutive reads.`,
705
+ );
706
+ }
707
+
708
+ /**
709
+ * The data of the head's changes, oldest first: its pages, found by walking back from its last
710
+ * page to its log, then its recent changes. Undefined when the pages are incomplete.
711
+ */
712
+ private async readChanges(document: FlexRecordDocument): Promise<string[] | undefined> {
713
+ const pages: string[][] = [];
714
+ if (document.lastPageId !== null) {
715
+ const stored = await this.changes.getInstances(
716
+ { recordId: document.recordId, logId: document.logId! },
717
+ { timeoutMS: this.timeoutMs },
718
+ );
719
+ const byId = new Map(stored.map((page) => [page.pageId, page]));
720
+ let pageId = document.lastPageId;
721
+ while (pageId !== document.logId) {
722
+ const page = byId.get(pageId);
723
+ if (!page || !Array.isArray(page.changes) || pages.length === stored.length) return undefined;
724
+ pages.push(page.changes);
725
+ pageId = page.previousId;
726
+ }
727
+ }
728
+ if (!Array.isArray(document.recentChanges)) return undefined;
729
+ const changes = [...pages.reverse().flat(), ...document.recentChanges];
730
+ return changes.length === document.changeCount ? changes : undefined;
731
+ }
732
+
733
+ /** The base with every change applied, checked in full and at the head's revision. */
734
+ private fold<TSnapshot extends { revision: number }, TChange>(
735
+ resolved: IResolvedAddress,
736
+ document: FlexRecordDocument,
737
+ text: string,
738
+ changes: readonly string[],
739
+ log: IFlexRecordLog<TSnapshot, TChange>,
740
+ ): TSnapshot {
741
+ let value = this.parse(resolved, text, (wrapper) => {
742
+ if (
743
+ typeof wrapper !== 'object' || wrapper === null || Array.isArray(wrapper) ||
744
+ Object.keys(wrapper).length !== 1 || !('base' in wrapper)
745
+ ) {
746
+ throw new FlexHarnessStoreFormatError('A log record must hold exactly { base }.');
747
+ }
748
+ return log.validate(wrapper.base);
749
+ });
750
+ if (changes.length > 0) {
751
+ for (const data of changes) {
752
+ const change = this.parse(resolved, data, log.validateChange);
753
+ const current = value;
754
+ value = this.check(resolved, () => log.apply(current, change));
755
+ }
756
+ const folded = value;
757
+ value = this.check(resolved, () => log.validate(folded));
758
+ }
759
+ if (value.revision !== document.revision) {
760
+ throw new FlexHarnessStoreFormatError(
761
+ `${resolved.path} holds revision ${value.revision}, but its head names ${document.revision}.`,
762
+ );
763
+ }
764
+ return value;
765
+ }
766
+
464
767
  private async readBlob(document: FlexRecordDocument): Promise<string | undefined> {
465
768
  const stored = await this.chunks.getInstances(
466
769
  { blobId: document.blobId! },
@@ -474,25 +777,30 @@ export class FlexRecordStore {
474
777
  return digest(text) === document.payloadSha256 ? text : undefined;
475
778
  }
476
779
 
477
- /** Returns false when another writer swapped the head first; nothing of this write remains. */
780
+ /**
781
+ * Writes the value whole, or as the base of a new log, empty, when `log` is set; a base always
782
+ * goes into chunks. Returns false when another writer swapped the head first; nothing of this
783
+ * write remains.
784
+ */
478
785
  private async commit(
479
786
  resolved: IResolvedAddress,
480
787
  stored: FlexRecordDocument | undefined,
481
788
  write: IFlexRecordWrite,
789
+ log = false,
482
790
  ): Promise<boolean> {
483
- const text = JSON.stringify(write.value);
484
- const chunked = text.length > FLEX_RECORD_CHUNK_CODE_UNITS;
791
+ const text = JSON.stringify(log ? { base: write.value } : write.value);
792
+ const chunked = log || text.length > FLEX_RECORD_CHUNK_CODE_UNITS;
485
793
  let current = stored;
486
794
  if (!current) {
487
- if (!chunked) return this.insertHead(resolved, write.revision, text);
795
+ if (text.length <= FLEX_RECORD_CHUNK_CODE_UNITS) return this.insertHead(resolved, write.revision, text, log);
488
796
  current = await this.insertPlaceholder(resolved);
489
797
  if (!current) return false;
490
- } else if (current.chunked) {
491
- await this.sweep(current);
492
- } else if (chunked && !await this.markChunked(current)) {
493
- return false;
798
+ } else {
799
+ if (current.chunked || current.retiredLogId) await this.sweep(current);
800
+ if (chunked && !current.chunked && !await this.markChunked(current)) return false;
494
801
  }
495
802
  const blobId = chunked ? await this.writeBlob(resolved, current.writeToken, text) : null;
803
+ const writeToken = plugins.crypto.randomUUID();
496
804
  let swapped: boolean;
497
805
  try {
498
806
  const outcome = await this.records.atomicUpdate(
@@ -500,11 +808,18 @@ export class FlexRecordStore {
500
808
  {
501
809
  $set: {
502
810
  revision: write.revision,
503
- writeToken: plugins.crypto.randomUUID(),
811
+ writeToken,
504
812
  payload: chunked ? null : text,
505
813
  blobId,
506
814
  chunkCount: chunked ? splitPayload(text).length : 0,
507
815
  payloadSha256: chunked ? digest(text) : null,
816
+ logId: log ? writeToken : null,
817
+ changeCount: 0,
818
+ recentChanges: [],
819
+ lastPageId: null,
820
+ changeLength: 0,
821
+ baseLength: log ? text.length : 0,
822
+ retiredLogId: current.logId ?? null,
508
823
  },
509
824
  },
510
825
  { timeoutMS: this.timeoutMs },
@@ -520,6 +835,65 @@ export class FlexRecordStore {
520
835
  return swapped;
521
836
  }
522
837
 
838
+ /**
839
+ * Whether the record takes a change: a log that stays no longer than its base, or than the
840
+ * minimum room of a shorter base. A head whose recent changes are no list takes none, so the
841
+ * compaction's read refuses it.
842
+ */
843
+ private takesChange(head: FlexRecordDocument, text: string): boolean {
844
+ return Boolean(head.logId) && Array.isArray(head.recentChanges) &&
845
+ text.length <= FLEX_RECORD_CHUNK_CODE_UNITS &&
846
+ head.changeLength + text.length <= Math.max(head.baseLength, FLEX_RECORD_MIN_LOG_CODE_UNITS);
847
+ }
848
+
849
+ /**
850
+ * Appends a change to the head's recent changes in the head swap, or, when they would grow past
851
+ * their inline room, writes them with the change as a new page and empties them in the swap.
852
+ * Returns false when another writer swapped the head first; nothing of this write remains.
853
+ */
854
+ private async commitChange(
855
+ resolved: IResolvedAddress,
856
+ head: FlexRecordDocument,
857
+ revision: number,
858
+ text: string,
859
+ ): Promise<boolean> {
860
+ const recentLength = head.recentChanges.reduce((length, data) => length + data.length, 0);
861
+ const page = recentLength + text.length > FLEX_RECORD_INLINE_CHANGE_CODE_UNITS
862
+ ? new this.changes()
863
+ : undefined;
864
+ if (page) {
865
+ page.pageId = plugins.crypto.randomUUID();
866
+ page.recordId = resolved.recordId;
867
+ page.domain = resolved.domain;
868
+ page.ownerKey = resolved.ownerKey;
869
+ page.namespaceKey = resolved.namespaceKey;
870
+ page.logId = head.logId!;
871
+ page.previousId = head.lastPageId ?? head.logId!;
872
+ page.changes = [...head.recentChanges, text];
873
+ await this.changes.insert(page, { timeoutMS: this.timeoutMs });
874
+ }
875
+ const writeToken = plugins.crypto.randomUUID();
876
+ const counts = { changeCount: 1, changeLength: text.length };
877
+ let swapped: boolean;
878
+ try {
879
+ const outcome = await this.records.atomicUpdate(
880
+ { recordId: resolved.recordId, writeToken: head.writeToken },
881
+ page
882
+ ? { $set: { revision, writeToken, recentChanges: [], lastPageId: page.pageId }, $inc: counts }
883
+ : { $set: { revision, writeToken }, $inc: counts, $push: { recentChanges: text } },
884
+ { timeoutMS: this.timeoutMs },
885
+ );
886
+ swapped = outcome.matchedCount === 1;
887
+ } catch (error) {
888
+ if (isDefiniteRejection(error)) throw error;
889
+ throw new FlexHarnessStoreCommitUncertainError(resolved.path, 'write', { cause: error });
890
+ }
891
+ if (!swapped && page) {
892
+ await this.changes.atomicDeleteMany({ pageId: page.pageId }, { timeoutMS: this.timeoutMs });
893
+ }
894
+ return swapped;
895
+ }
896
+
523
897
  private newHead(resolved: IResolvedAddress): FlexRecordDocument {
524
898
  const head = new this.records();
525
899
  head.recordId = resolved.recordId;
@@ -531,27 +905,52 @@ export class FlexRecordStore {
531
905
  head.chunkCount = 0;
532
906
  head.payloadSha256 = null;
533
907
  head.sweepCount = 0;
908
+ head.logId = null;
909
+ head.changeCount = 0;
910
+ head.recentChanges = [];
911
+ head.lastPageId = null;
912
+ head.changeLength = 0;
913
+ head.baseLength = 0;
914
+ head.retiredLogId = null;
534
915
  return head;
535
916
  }
536
917
 
918
+ /**
919
+ * Creates the record with a first payload of up to one chunk in one transaction: inline, or as
920
+ * the single chunk of a log's base. The creation never touches the head again, so a concurrent
921
+ * fenced purge conflicts with it at most once.
922
+ */
537
923
  private async insertHead(
538
924
  resolved: IResolvedAddress,
539
925
  revision: number,
540
926
  text: string,
927
+ log: boolean,
541
928
  ): Promise<boolean> {
542
929
  const head = this.newHead(resolved);
543
930
  head.revision = revision;
544
- head.payload = text;
545
- head.chunked = false;
931
+ let chunk: FlexChunkDocument | undefined;
932
+ if (log) {
933
+ chunk = this.newChunk(resolved, plugins.crypto.randomUUID(), head.writeToken, 0, text);
934
+ head.payload = null;
935
+ head.chunked = true;
936
+ head.blobId = chunk.blobId;
937
+ head.chunkCount = 1;
938
+ head.payloadSha256 = digest(text);
939
+ head.logId = head.writeToken;
940
+ head.baseLength = text.length;
941
+ } else {
942
+ head.payload = text;
943
+ head.chunked = false;
944
+ }
546
945
  try {
547
- return await this.create(resolved, head);
946
+ return await this.create(resolved, head, chunk);
548
947
  } catch (error) {
549
948
  if (plugins.SmartdataSession.getCommitOutcome(error) === 'not_committed') throw error;
550
949
  throw new FlexHarnessStoreCommitUncertainError(resolved.path, 'write', { cause: error });
551
950
  }
552
951
  }
553
952
 
554
- /** Reserves the record for a chunked first write, so its chunks have a head token to replace. */
953
+ /** Reserves the record for a first write longer than one chunk, so its chunks have a head token to replace. */
555
954
  private async insertPlaceholder(
556
955
  resolved: IResolvedAddress,
557
956
  ): Promise<FlexRecordDocument | undefined> {
@@ -563,12 +962,16 @@ export class FlexRecordStore {
563
962
  }
564
963
 
565
964
  /**
566
- * Inserts a new head unless a fence holds its namespace. The transaction also increments the
567
- * namespace document, so it conflicts with a concurrent fenced purge: whichever commits second
568
- * retries on a snapshot that holds the other. Returns false when another writer created the
569
- * record first.
965
+ * Inserts a new head, and the chunk it references if any, unless a fence holds its namespace.
966
+ * The transaction also increments the namespace document, so it conflicts with a concurrent
967
+ * fenced purge: whichever commits second retries on a snapshot that holds the other. Returns
968
+ * false when another writer created the record first.
570
969
  */
571
- private async create(resolved: IResolvedAddress, head: FlexRecordDocument): Promise<boolean> {
970
+ private async create(
971
+ resolved: IResolvedAddress,
972
+ head: FlexRecordDocument,
973
+ chunk?: FlexChunkDocument,
974
+ ): Promise<boolean> {
572
975
  const session = this.db.createSession();
573
976
  try {
574
977
  await session.withTransaction(async (transaction) => {
@@ -583,6 +986,7 @@ export class FlexRecordStore {
583
986
  { upsert: true, session: transaction },
584
987
  );
585
988
  await this.records.insert(head, { session: transaction });
989
+ if (chunk) await this.chunks.insert(chunk, { session: transaction });
586
990
  }, { timeoutMS: this.timeoutMs });
587
991
  return true;
588
992
  } catch (error) {
@@ -615,26 +1019,39 @@ export class FlexRecordStore {
615
1019
  const blobId = plugins.crypto.randomUUID();
616
1020
  const pieces = splitPayload(text);
617
1021
  for (let index = 0; index < pieces.length; index++) {
618
- const chunk = new this.chunks();
619
- chunk.chunkId = `${blobId}:${index}`;
620
- chunk.recordId = resolved.recordId;
621
- chunk.domain = resolved.domain;
622
- chunk.ownerKey = resolved.ownerKey;
623
- chunk.namespaceKey = resolved.namespaceKey;
624
- chunk.blobId = blobId;
625
- chunk.baseToken = baseToken;
626
- chunk.index = index;
627
- chunk.data = pieces[index];
1022
+ const chunk = this.newChunk(resolved, blobId, baseToken, index, pieces[index]);
628
1023
  await this.chunks.insert(chunk, { timeoutMS: this.timeoutMs });
629
1024
  }
630
1025
  return blobId;
631
1026
  }
632
1027
 
1028
+ private newChunk(
1029
+ resolved: IResolvedAddress,
1030
+ blobId: string,
1031
+ baseToken: string,
1032
+ index: number,
1033
+ data: string,
1034
+ ): FlexChunkDocument {
1035
+ const chunk = new this.chunks();
1036
+ chunk.chunkId = `${blobId}:${index}`;
1037
+ chunk.recordId = resolved.recordId;
1038
+ chunk.domain = resolved.domain;
1039
+ chunk.ownerKey = resolved.ownerKey;
1040
+ chunk.namespaceKey = resolved.namespaceKey;
1041
+ chunk.blobId = blobId;
1042
+ chunk.baseToken = baseToken;
1043
+ chunk.index = index;
1044
+ chunk.data = data;
1045
+ return chunk;
1046
+ }
1047
+
633
1048
  /**
634
- * Deletes the record's chunks that no head can reference any more: every blob but the current
635
- * one that was written against another head token. A writer that read the current token keeps
636
- * its chunks. Touching the head in the same transaction makes a concurrent deletion conflict,
637
- * so chunks written after a deletion are never swept by a transaction that predates it.
1049
+ * Deletes the record's chunks and change pages that no head can reference any more: every blob
1050
+ * but the current one that was written against another head token, and every page of the log the
1051
+ * last write retired, found by its index rather than by scanning the current log's pages. A
1052
+ * writer that read the current token keeps its chunks.
1053
+ * Touching the head in the same transaction makes a concurrent deletion conflict, so chunks and
1054
+ * pages written after a deletion are never swept by a transaction that predates it.
638
1055
  */
639
1056
  private async sweep(current: FlexRecordDocument): Promise<void> {
640
1057
  const session = this.db.createSession();
@@ -646,14 +1063,22 @@ export class FlexRecordStore {
646
1063
  { session: transaction },
647
1064
  );
648
1065
  if (touched.matchedCount !== 1) return;
649
- await this.chunks.atomicDeleteMany(
650
- {
651
- recordId: current.recordId,
652
- baseToken: { $ne: current.writeToken },
653
- ...(current.blobId === null ? {} : { blobId: { $ne: current.blobId } }),
654
- },
655
- { session: transaction },
656
- );
1066
+ if (current.chunked) {
1067
+ await this.chunks.atomicDeleteMany(
1068
+ {
1069
+ recordId: current.recordId,
1070
+ baseToken: { $ne: current.writeToken },
1071
+ ...(current.blobId === null ? {} : { blobId: { $ne: current.blobId } }),
1072
+ },
1073
+ { session: transaction },
1074
+ );
1075
+ }
1076
+ if (current.retiredLogId) {
1077
+ await this.changes.atomicDeleteMany(
1078
+ { recordId: current.recordId, logId: current.retiredLogId },
1079
+ { session: transaction },
1080
+ );
1081
+ }
657
1082
  }, { timeoutMS: this.timeoutMs });
658
1083
  } finally {
659
1084
  await session.close();