@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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.flexharness.core.d.ts +4 -0
- package/dist_ts/classes.flexharness.core.js +69 -25
- package/dist_ts/classes.flexharness.imports.js +4 -3
- package/dist_ts/classes.flexharness.reversions.d.ts +7 -1
- package/dist_ts/classes.flexharness.reversions.js +43 -29
- package/dist_ts/classes.flexharness.sessions.js +2 -6
- package/dist_ts/classes.stores.js +20 -18
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/interfaces.d.ts +24 -2
- package/dist_ts/interfaces.flexharness.d.ts +8 -1
- package/dist_ts/interfaces.js +1 -1
- package/dist_ts/plugins.d.ts +3 -3
- package/dist_ts/plugins.js +3 -3
- package/dist_ts/utils.projectionchange.d.ts +34 -0
- package/dist_ts/utils.projectionchange.js +135 -0
- package/dist_ts/utils.storecodecs.d.ts +10 -3
- package/dist_ts/utils.storecodecs.js +29 -4
- package/dist_ts/utils.storerules.d.ts +1 -1
- package/dist_ts/utils.storerules.js +1 -1
- package/dist_ts_migration/v3_legacyflexharness.js +6 -5
- package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +5 -4
- package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +56 -54
- package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +176 -19
- package/dist_ts_stores_nosqldb/classes.recordstore.js +392 -67
- package/dist_ts_stores_nosqldb/index.d.ts +1 -1
- package/dist_ts_stores_nosqldb/index.js +2 -2
- package/dist_ts_stores_nosqldb/plugins.d.ts +1 -1
- package/dist_ts_stores_nosqldb/plugins.js +1 -1
- package/dist_ts_stores_testing/contract.d.ts +33 -0
- package/dist_ts_stores_testing/contract.js +566 -0
- package/dist_ts_stores_testing/fixtures.d.ts +20 -0
- package/dist_ts_stores_testing/fixtures.js +112 -0
- package/dist_ts_stores_testing/index.d.ts +2 -0
- package/dist_ts_stores_testing/index.js +3 -0
- package/dist_ts_stores_testing/plugins.d.ts +5 -0
- package/dist_ts_stores_testing/plugins.js +7 -0
- package/package.json +9 -3
- package/readme.md +6 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.flexharness.core.ts +74 -24
- package/ts/classes.flexharness.imports.ts +3 -2
- package/ts/classes.flexharness.reversions.ts +53 -28
- package/ts/classes.flexharness.sessions.ts +2 -5
- package/ts/classes.stores.ts +40 -37
- package/ts/index.ts +5 -0
- package/ts/interfaces.flexharness.ts +8 -1
- package/ts/interfaces.ts +27 -2
- package/ts/plugins.ts +12 -7
- package/ts/readme.md +6 -3
- package/ts/utils.projectionchange.ts +190 -0
- package/ts/utils.storecodecs.ts +42 -7
- package/ts/utils.storerules.ts +2 -2
- package/ts_migration/v3_legacyflexharness.ts +11 -6
- package/ts_stores_nosqldb/classes.nosqldbstores.ts +79 -71
- package/ts_stores_nosqldb/classes.recordstore.ts +491 -66
- package/ts_stores_nosqldb/index.ts +1 -0
- package/ts_stores_nosqldb/plugins.ts +4 -3
- package/ts_stores_nosqldb/readme.md +6 -2
- package/ts_stores_testing/contract.ts +776 -0
- package/ts_stores_testing/fixtures.ts +127 -0
- package/ts_stores_testing/index.ts +17 -0
- package/ts_stores_testing/plugins.ts +20 -0
- 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
|
|
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
|
|
194
|
+
export interface IFlexRecordModels {
|
|
110
195
|
records: TRecordModel;
|
|
111
196
|
chunks: TChunkModel;
|
|
197
|
+
changes: TChangePageModel;
|
|
112
198
|
namespaces: TNamespaceModel;
|
|
113
199
|
}
|
|
114
200
|
|
|
115
|
-
|
|
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
|
-
({
|
|
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
|
-
|
|
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
|
|
324
|
-
|
|
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,
|
|
537
|
+
if (await this.commit(resolved, document, write)) return;
|
|
329
538
|
}
|
|
330
|
-
throw
|
|
539
|
+
throw contention(actualRevision);
|
|
331
540
|
}
|
|
332
541
|
|
|
333
|
-
/** Deletes one record
|
|
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
|
|
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
|
|
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
|
|
375
|
-
* fence is permanent: every later record creation in the namespace is refused,
|
|
376
|
-
* that runs concurrently either commits first and is deleted, or conflicts and is
|
|
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
|
|
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
|
-
|
|
629
|
+
text: string,
|
|
419
630
|
validate: (value: unknown) => TValue,
|
|
420
|
-
): TValue
|
|
421
|
-
if (head.text === undefined) return undefined;
|
|
631
|
+
): TValue {
|
|
422
632
|
let parsed: unknown;
|
|
423
633
|
try {
|
|
424
|
-
parsed = JSON.parse(
|
|
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
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
|
491
|
-
await this.sweep(current);
|
|
492
|
-
|
|
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
|
|
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
|
-
|
|
545
|
-
|
|
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
|
|
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.
|
|
567
|
-
* namespace document, so it conflicts with a concurrent
|
|
568
|
-
* retries on a snapshot that holds the other. Returns
|
|
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(
|
|
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 =
|
|
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
|
|
635
|
-
* one that was written against another head token
|
|
636
|
-
*
|
|
637
|
-
*
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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();
|