@modelprofile.com/flexharness 11.3.1 → 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 +44 -25
  25. package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +152 -20
  26. package/dist_ts_stores_nosqldb/classes.recordstore.js +369 -66
  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 +59 -46
  57. package/ts_stores_nosqldb/classes.recordstore.ts +445 -64
  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
@@ -47,6 +47,28 @@ export interface IFlexRecordReplacement {
47
47
  contention: (actualRevision: number) => Error;
48
48
  }
49
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
+
50
72
  interface IFlexRecordDecision {
51
73
  document?: FlexRecordDocument;
52
74
  write?: IFlexRecordWrite;
@@ -55,6 +77,7 @@ interface IFlexRecordDecision {
55
77
  export const FLEX_RECORD_COLLECTION = 'flexharness_records';
56
78
  export const FLEX_CHUNK_COLLECTION = 'flexharness_chunks';
57
79
  export const FLEX_NAMESPACE_COLLECTION = 'flexharness_namespaces';
80
+ export const FLEX_CHANGE_COLLECTION = 'flexharness_changes';
58
81
 
59
82
  /**
60
83
  * UTF-16 code units a record holds inline, and the size of each chunk of a longer payload. A
@@ -63,6 +86,18 @@ export const FLEX_NAMESPACE_COLLECTION = 'flexharness_namespaces';
63
86
  */
64
87
  export const FLEX_RECORD_CHUNK_CODE_UNITS = 1024 * 1024;
65
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
+
66
101
  const mutationAttempts = 8;
67
102
  const mutationBackoffBaseMs = 5;
68
103
  const mutationBackoffMaxMs = 200;
@@ -73,7 +108,13 @@ const defaultOperationTimeoutMs = 30_000;
73
108
  * The head of one record. A payload up to one chunk is stored inline; a longer one lives in
74
109
  * immutable chunks named by `blobId`. `writeToken` changes on every committed write and is the
75
110
  * compare-and-swap fence. A head without payload or blob is a placeholder that reserves the
76
- * 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.
77
118
  */
78
119
  export class FlexRecordDocument extends plugins.SmartDataDbDoc<FlexRecordDocument, FlexRecordDocument> {
79
120
  public recordId!: string;
@@ -89,6 +130,21 @@ export class FlexRecordDocument extends plugins.SmartDataDbDoc<FlexRecordDocumen
89
130
  /** Set before the first chunk of this record is written; the record's writes then sweep. */
90
131
  public chunked!: boolean;
91
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;
92
148
  }
93
149
 
94
150
  /** One immutable piece of a chunked payload, written against the head token it will replace. */
@@ -104,6 +160,21 @@ export class FlexChunkDocument extends plugins.SmartDataDbDoc<FlexChunkDocument,
104
160
  public data!: string;
105
161
  }
106
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
+
107
178
  /**
108
179
  * One storage namespace. Every record creation in the namespace increments `creationCount` in
109
180
  * its transaction, so a concurrent fenced purge, which sets `fenced`, conflicts with it. A fenced
@@ -117,18 +188,21 @@ export class FlexNamespaceDocument extends plugins.SmartDataDbDoc<FlexNamespaceD
117
188
 
118
189
  type TRecordModel = typeof FlexRecordDocument;
119
190
  type TChunkModel = typeof FlexChunkDocument;
191
+ type TChangePageModel = typeof FlexChangePageDocument;
120
192
  type TNamespaceModel = typeof FlexNamespaceDocument;
121
193
 
122
194
  export interface IFlexRecordModels {
123
195
  records: TRecordModel;
124
196
  chunks: TChunkModel;
197
+ changes: TChangePageModel;
125
198
  namespaces: TNamespaceModel;
126
199
  }
127
200
 
128
- /** Binds the record, chunk and namespace models to a database. */
201
+ /** Binds the record, chunk, change page and namespace models to a database. */
129
202
  export function bindFlexRecordModels(db: plugins.SmartdataDb): IFlexRecordModels {
130
203
  class BoundRecord extends FlexRecordDocument {}
131
204
  class BoundChunk extends FlexChunkDocument {}
205
+ class BoundChangePage extends FlexChangePageDocument {}
132
206
  class BoundNamespace extends FlexNamespaceDocument {}
133
207
  plugins.defineCollectionModel(BoundRecord, db, {
134
208
  collectionName: FLEX_RECORD_COLLECTION,
@@ -145,8 +219,15 @@ export function bindFlexRecordModels(db: plugins.SmartdataDb): IFlexRecordModels
145
219
  'payloadSha256',
146
220
  'chunked',
147
221
  'sweepCount',
222
+ 'logId',
223
+ 'changeCount',
224
+ 'recentChanges',
225
+ 'lastPageId',
226
+ 'changeLength',
227
+ 'baseLength',
228
+ 'retiredLogId',
148
229
  ],
149
- numericFields: ['revision', 'chunkCount', 'sweepCount'],
230
+ numericFields: ['revision', 'chunkCount', 'sweepCount', 'changeCount', 'changeLength', 'baseLength'],
150
231
  identityFields: ['recordId'],
151
232
  indexes: [
152
233
  { name: 'flexharness_record_id', key: { recordId: 1 }, options: { unique: true } },
@@ -177,6 +258,26 @@ export function bindFlexRecordModels(db: plugins.SmartdataDb): IFlexRecordModels
177
258
  { name: 'flexharness_chunk_namespace', key: { namespaceKey: 1, domain: 1 } },
178
259
  ],
179
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
+ });
180
281
  plugins.defineCollectionModel(BoundNamespace, db, {
181
282
  collectionName: FLEX_NAMESPACE_COLLECTION,
182
283
  persistedFields: ['namespaceKey', 'fenced', 'creationCount'],
@@ -186,7 +287,7 @@ export function bindFlexRecordModels(db: plugins.SmartdataDb): IFlexRecordModels
186
287
  { name: 'flexharness_namespace_key', key: { namespaceKey: 1 }, options: { unique: true } },
187
288
  ],
188
289
  });
189
- return { records: BoundRecord, chunks: BoundChunk, namespaces: BoundNamespace };
290
+ return { records: BoundRecord, chunks: BoundChunk, changes: BoundChangePage, namespaces: BoundNamespace };
190
291
  }
191
292
 
192
293
  interface IResolvedAddress {
@@ -202,6 +303,11 @@ interface IStoredHead {
202
303
  text?: string;
203
304
  }
204
305
 
306
+ interface IStoredLog<TSnapshot> {
307
+ document?: FlexRecordDocument;
308
+ value?: TSnapshot;
309
+ }
310
+
205
311
  const digest = (value: string): string =>
206
312
  plugins.crypto.createHash('sha256').update(value, 'utf8').digest('hex');
207
313
 
@@ -254,12 +360,22 @@ export interface IFlexRecordStoreOptions {
254
360
  * replaced or abandoned payload are swept in a transaction that also touches the head, so a
255
361
  * concurrent deletion or writer can never lose chunks its head references. A head is created in
256
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.
257
372
  */
258
373
  export class FlexRecordStore {
259
374
  private readonly db: plugins.SmartdataDb;
260
375
  private readonly timeoutMs: number;
261
376
  private readonly records: TRecordModel;
262
377
  private readonly chunks: TChunkModel;
378
+ private readonly changes: TChangePageModel;
263
379
  private readonly namespaces: TNamespaceModel;
264
380
  private preparation?: Promise<void>;
265
381
 
@@ -270,8 +386,12 @@ export class FlexRecordStore {
270
386
  }
271
387
  this.db = options.db;
272
388
  this.timeoutMs = timeoutMs;
273
- ({ records: this.records, chunks: this.chunks, namespaces: this.namespaces } =
274
- bindFlexRecordModels(options.db));
389
+ ({
390
+ records: this.records,
391
+ chunks: this.chunks,
392
+ changes: this.changes,
393
+ namespaces: this.namespaces,
394
+ } = bindFlexRecordModels(options.db));
275
395
  }
276
396
 
277
397
  /** Creates or verifies the collections and their indexes once; a failed attempt is retried. */
@@ -298,6 +418,7 @@ export class FlexRecordStore {
298
418
  const options = { mode: 'ensureCompatible' as const, timeoutMs: this.timeoutMs };
299
419
  await this.records.prepareCollection(options);
300
420
  await this.chunks.prepareCollection(options);
421
+ await this.changes.prepareCollection(options);
301
422
  await this.namespaces.prepareCollection(options);
302
423
  }
303
424
 
@@ -323,7 +444,51 @@ export class FlexRecordStore {
323
444
  ): Promise<TValue | undefined> {
324
445
  await this.prepare();
325
446
  const resolved = this.resolve(address);
326
- 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);
327
492
  }
328
493
 
329
494
  public async mutate<TValue>(
@@ -334,10 +499,10 @@ export class FlexRecordStore {
334
499
  const resolved = this.resolve(address);
335
500
  await this.swap(resolved, mutation.contention, async () => {
336
501
  const head = await this.readHead(resolved);
337
- return {
338
- document: head.document,
339
- write: mutation.decide(this.parse(resolved, head, mutation.validate)),
340
- };
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) };
341
506
  });
342
507
  }
343
508
 
@@ -374,14 +539,14 @@ export class FlexRecordStore {
374
539
  throw contention(actualRevision);
375
540
  }
376
541
 
377
- /** Deletes one record and its chunks. */
542
+ /** Deletes one record with its chunks and change pages. */
378
543
  public async delete(address: IFlexRecordAddress): Promise<void> {
379
544
  await this.prepare();
380
545
  const resolved = this.resolve(address);
381
546
  await this.deleteInTransaction(resolved.path, { recordId: resolved.recordId, domain: resolved.domain });
382
547
  }
383
548
 
384
- /** 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. */
385
550
  public async deleteOwner(
386
551
  storageKey: string,
387
552
  sessionId: string,
@@ -396,8 +561,8 @@ export class FlexRecordStore {
396
561
  }
397
562
 
398
563
  /**
399
- * Deletes every record and chunk of a storage namespace in one domain, or in every domain
400
- * 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.
401
566
  */
402
567
  public async purgeNamespace(storageKey: string, domain?: TFlexRecordDomain): Promise<void> {
403
568
  await this.prepare();
@@ -415,9 +580,10 @@ export class FlexRecordStore {
415
580
  }
416
581
 
417
582
  /**
418
- * Fences a storage namespace and deletes every record and chunk of it in one transaction. The
419
- * fence is permanent: every later record creation in the namespace is refused, and a creation
420
- * 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.
421
587
  */
422
588
  public async fenceNamespace(storageKey: string): Promise<void> {
423
589
  await this.prepare();
@@ -433,7 +599,7 @@ export class FlexRecordStore {
433
599
  );
434
600
  }
435
601
 
436
- /** 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. */
437
603
  private async deleteInTransaction(
438
604
  path: string,
439
605
  selector:
@@ -448,6 +614,7 @@ export class FlexRecordStore {
448
614
  await namespace?.(transaction);
449
615
  await this.records.atomicDeleteMany(selector, { session: transaction });
450
616
  await this.chunks.atomicDeleteMany(selector, { session: transaction });
617
+ await this.changes.atomicDeleteMany(selector, { session: transaction });
451
618
  }, { timeoutMS: this.timeoutMs });
452
619
  } catch (error) {
453
620
  if (plugins.SmartdataSession.getCommitOutcome(error) === 'not_committed') throw error;
@@ -459,18 +626,22 @@ export class FlexRecordStore {
459
626
 
460
627
  private parse<TValue>(
461
628
  resolved: IResolvedAddress,
462
- head: IStoredHead,
629
+ text: string,
463
630
  validate: (value: unknown) => TValue,
464
- ): TValue | undefined {
465
- if (head.text === undefined) return undefined;
631
+ ): TValue {
466
632
  let parsed: unknown;
467
633
  try {
468
- parsed = JSON.parse(head.text);
634
+ parsed = JSON.parse(text);
469
635
  } catch (error) {
470
636
  throw new FlexHarnessStoreFormatError(`Malformed JSON at ${resolved.path}.`, { cause: error });
471
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 {
472
643
  try {
473
- return validate(parsed);
644
+ return check();
474
645
  } catch (error) {
475
646
  if (error instanceof FlexHarnessStoreFormatError) {
476
647
  throw new FlexHarnessStoreFormatError(`Invalid record at ${resolved.path}: ${error.message}`, {
@@ -505,6 +676,94 @@ export class FlexRecordStore {
505
676
  );
506
677
  }
507
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
+
508
767
  private async readBlob(document: FlexRecordDocument): Promise<string | undefined> {
509
768
  const stored = await this.chunks.getInstances(
510
769
  { blobId: document.blobId! },
@@ -518,25 +777,30 @@ export class FlexRecordStore {
518
777
  return digest(text) === document.payloadSha256 ? text : undefined;
519
778
  }
520
779
 
521
- /** 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
+ */
522
785
  private async commit(
523
786
  resolved: IResolvedAddress,
524
787
  stored: FlexRecordDocument | undefined,
525
788
  write: IFlexRecordWrite,
789
+ log = false,
526
790
  ): Promise<boolean> {
527
- const text = JSON.stringify(write.value);
528
- 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;
529
793
  let current = stored;
530
794
  if (!current) {
531
- 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);
532
796
  current = await this.insertPlaceholder(resolved);
533
797
  if (!current) return false;
534
- } else if (current.chunked) {
535
- await this.sweep(current);
536
- } else if (chunked && !await this.markChunked(current)) {
537
- 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;
538
801
  }
539
802
  const blobId = chunked ? await this.writeBlob(resolved, current.writeToken, text) : null;
803
+ const writeToken = plugins.crypto.randomUUID();
540
804
  let swapped: boolean;
541
805
  try {
542
806
  const outcome = await this.records.atomicUpdate(
@@ -544,11 +808,18 @@ export class FlexRecordStore {
544
808
  {
545
809
  $set: {
546
810
  revision: write.revision,
547
- writeToken: plugins.crypto.randomUUID(),
811
+ writeToken,
548
812
  payload: chunked ? null : text,
549
813
  blobId,
550
814
  chunkCount: chunked ? splitPayload(text).length : 0,
551
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,
552
823
  },
553
824
  },
554
825
  { timeoutMS: this.timeoutMs },
@@ -564,6 +835,65 @@ export class FlexRecordStore {
564
835
  return swapped;
565
836
  }
566
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
+
567
897
  private newHead(resolved: IResolvedAddress): FlexRecordDocument {
568
898
  const head = new this.records();
569
899
  head.recordId = resolved.recordId;
@@ -575,27 +905,52 @@ export class FlexRecordStore {
575
905
  head.chunkCount = 0;
576
906
  head.payloadSha256 = null;
577
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;
578
915
  return head;
579
916
  }
580
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
+ */
581
923
  private async insertHead(
582
924
  resolved: IResolvedAddress,
583
925
  revision: number,
584
926
  text: string,
927
+ log: boolean,
585
928
  ): Promise<boolean> {
586
929
  const head = this.newHead(resolved);
587
930
  head.revision = revision;
588
- head.payload = text;
589
- 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
+ }
590
945
  try {
591
- return await this.create(resolved, head);
946
+ return await this.create(resolved, head, chunk);
592
947
  } catch (error) {
593
948
  if (plugins.SmartdataSession.getCommitOutcome(error) === 'not_committed') throw error;
594
949
  throw new FlexHarnessStoreCommitUncertainError(resolved.path, 'write', { cause: error });
595
950
  }
596
951
  }
597
952
 
598
- /** 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. */
599
954
  private async insertPlaceholder(
600
955
  resolved: IResolvedAddress,
601
956
  ): Promise<FlexRecordDocument | undefined> {
@@ -607,12 +962,16 @@ export class FlexRecordStore {
607
962
  }
608
963
 
609
964
  /**
610
- * Inserts a new head unless a fence holds its namespace. The transaction also increments the
611
- * namespace document, so it conflicts with a concurrent fenced purge: whichever commits second
612
- * retries on a snapshot that holds the other. Returns false when another writer created the
613
- * 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.
614
969
  */
615
- 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> {
616
975
  const session = this.db.createSession();
617
976
  try {
618
977
  await session.withTransaction(async (transaction) => {
@@ -627,6 +986,7 @@ export class FlexRecordStore {
627
986
  { upsert: true, session: transaction },
628
987
  );
629
988
  await this.records.insert(head, { session: transaction });
989
+ if (chunk) await this.chunks.insert(chunk, { session: transaction });
630
990
  }, { timeoutMS: this.timeoutMs });
631
991
  return true;
632
992
  } catch (error) {
@@ -659,26 +1019,39 @@ export class FlexRecordStore {
659
1019
  const blobId = plugins.crypto.randomUUID();
660
1020
  const pieces = splitPayload(text);
661
1021
  for (let index = 0; index < pieces.length; index++) {
662
- const chunk = new this.chunks();
663
- chunk.chunkId = `${blobId}:${index}`;
664
- chunk.recordId = resolved.recordId;
665
- chunk.domain = resolved.domain;
666
- chunk.ownerKey = resolved.ownerKey;
667
- chunk.namespaceKey = resolved.namespaceKey;
668
- chunk.blobId = blobId;
669
- chunk.baseToken = baseToken;
670
- chunk.index = index;
671
- chunk.data = pieces[index];
1022
+ const chunk = this.newChunk(resolved, blobId, baseToken, index, pieces[index]);
672
1023
  await this.chunks.insert(chunk, { timeoutMS: this.timeoutMs });
673
1024
  }
674
1025
  return blobId;
675
1026
  }
676
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
+
677
1048
  /**
678
- * Deletes the record's chunks that no head can reference any more: every blob but the current
679
- * one that was written against another head token. A writer that read the current token keeps
680
- * its chunks. Touching the head in the same transaction makes a concurrent deletion conflict,
681
- * 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.
682
1055
  */
683
1056
  private async sweep(current: FlexRecordDocument): Promise<void> {
684
1057
  const session = this.db.createSession();
@@ -690,14 +1063,22 @@ export class FlexRecordStore {
690
1063
  { session: transaction },
691
1064
  );
692
1065
  if (touched.matchedCount !== 1) return;
693
- await this.chunks.atomicDeleteMany(
694
- {
695
- recordId: current.recordId,
696
- baseToken: { $ne: current.writeToken },
697
- ...(current.blobId === null ? {} : { blobId: { $ne: current.blobId } }),
698
- },
699
- { session: transaction },
700
- );
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
+ }
701
1082
  }, { timeoutMS: this.timeoutMs });
702
1083
  } finally {
703
1084
  await session.close();