@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.
- 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 +44 -25
- package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +152 -20
- package/dist_ts_stores_nosqldb/classes.recordstore.js +369 -66
- 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 +59 -46
- package/ts_stores_nosqldb/classes.recordstore.ts +445 -64
- 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
|
@@ -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
|
|
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
|
-
({
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
419
|
-
* fence is permanent: every later record creation in the namespace is refused,
|
|
420
|
-
* 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.
|
|
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
|
|
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
|
-
|
|
629
|
+
text: string,
|
|
463
630
|
validate: (value: unknown) => TValue,
|
|
464
|
-
): TValue
|
|
465
|
-
if (head.text === undefined) return undefined;
|
|
631
|
+
): TValue {
|
|
466
632
|
let parsed: unknown;
|
|
467
633
|
try {
|
|
468
|
-
parsed = JSON.parse(
|
|
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
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
|
535
|
-
await this.sweep(current);
|
|
536
|
-
|
|
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
|
|
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
|
-
|
|
589
|
-
|
|
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
|
|
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.
|
|
611
|
-
* namespace document, so it conflicts with a concurrent
|
|
612
|
-
* retries on a snapshot that holds the other. Returns
|
|
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(
|
|
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 =
|
|
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
|
|
679
|
-
* one that was written against another head token
|
|
680
|
-
*
|
|
681
|
-
*
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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();
|