@modelprofile.com/flexharness 12.0.0 → 12.1.1
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_stores_nosqldb/classes.nosqldbstores.d.ts +4 -3
- package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +5 -4
- package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +22 -12
- package/dist_ts_stores_nosqldb/classes.recordstore.js +198 -125
- package/dist_ts_typedrequest/catalog.d.ts +4 -4
- package/dist_ts_typedrequest/catalog.js +25 -7
- package/dist_ts_typedrequest/describe.js +23 -11
- package/dist_ts_typedrequest/interfaces.d.ts +17 -1
- package/package.json +3 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_stores_nosqldb/classes.nosqldbstores.ts +4 -3
- package/ts_stores_nosqldb/classes.recordstore.ts +221 -138
- package/ts_stores_nosqldb/readme.md +11 -5
- package/ts_typedrequest/catalog.ts +24 -6
- package/ts_typedrequest/describe.ts +19 -10
- package/ts_typedrequest/interfaces.ts +21 -1
- package/ts_typedrequest/readme.md +5 -3
|
@@ -176,14 +176,16 @@ export class FlexChangePageDocument extends plugins.SmartDataDbDoc<FlexChangePag
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
179
|
+
* A namespace fence or a target's deletion marker. Every write touches its namespace in its
|
|
180
|
+
* transaction, so it conflicts with a concurrent fence or deletion admission. A purge token
|
|
181
|
+
* hides the target until bounded cleanup completes; a fence permanently refuses writes.
|
|
182
182
|
*/
|
|
183
183
|
export class FlexNamespaceDocument extends plugins.SmartDataDbDoc<FlexNamespaceDocument, FlexNamespaceDocument> {
|
|
184
184
|
public namespaceKey!: string;
|
|
185
185
|
public fenced!: boolean;
|
|
186
186
|
public creationCount!: number;
|
|
187
|
+
public purgeToken?: string | null;
|
|
188
|
+
public storageNamespaceKey?: string;
|
|
187
189
|
}
|
|
188
190
|
|
|
189
191
|
type TRecordModel = typeof FlexRecordDocument;
|
|
@@ -280,11 +282,12 @@ export function bindFlexRecordModels(db: plugins.SmartdataDb): IFlexRecordModels
|
|
|
280
282
|
});
|
|
281
283
|
plugins.defineCollectionModel(BoundNamespace, db, {
|
|
282
284
|
collectionName: FLEX_NAMESPACE_COLLECTION,
|
|
283
|
-
persistedFields: ['namespaceKey', 'fenced', 'creationCount'],
|
|
285
|
+
persistedFields: ['namespaceKey', 'fenced', 'creationCount', 'purgeToken', 'storageNamespaceKey'],
|
|
284
286
|
numericFields: ['creationCount'],
|
|
285
287
|
identityFields: ['namespaceKey'],
|
|
286
288
|
indexes: [
|
|
287
289
|
{ name: 'flexharness_namespace_key', key: { namespaceKey: 1 }, options: { unique: true } },
|
|
290
|
+
{ name: 'flexharness_namespace_storage', key: { storageNamespaceKey: 1 } },
|
|
288
291
|
],
|
|
289
292
|
});
|
|
290
293
|
return { records: BoundRecord, chunks: BoundChunk, changes: BoundChangePage, namespaces: BoundNamespace };
|
|
@@ -308,6 +311,15 @@ interface IStoredLog<TSnapshot> {
|
|
|
308
311
|
value?: TSnapshot;
|
|
309
312
|
}
|
|
310
313
|
|
|
314
|
+
type TDeletionSelector =
|
|
315
|
+
| { recordId: string; domain: TFlexRecordDomain }
|
|
316
|
+
| { ownerKey: string; domain: TFlexRecordDomain }
|
|
317
|
+
| { namespaceKey: string; domain?: TFlexRecordDomain };
|
|
318
|
+
|
|
319
|
+
type TDeletionDocument = FlexRecordDocument | FlexChunkDocument | FlexChangePageDocument | FlexNamespaceDocument;
|
|
320
|
+
|
|
321
|
+
const deletionBatchSize = 4;
|
|
322
|
+
|
|
311
323
|
const digest = (value: string): string =>
|
|
312
324
|
plugins.crypto.createHash('sha256').update(value, 'utf8').digest('hex');
|
|
313
325
|
|
|
@@ -324,17 +336,6 @@ function splitPayload(text: string): string[] {
|
|
|
324
336
|
return chunks;
|
|
325
337
|
}
|
|
326
338
|
|
|
327
|
-
/** A rejection the database states before anything is written; every other failure may have applied. */
|
|
328
|
-
function isDefiniteRejection(error: unknown): boolean {
|
|
329
|
-
return error instanceof plugins.SmartdataPersistenceError && [
|
|
330
|
-
'invalid_argument',
|
|
331
|
-
'invalid_configuration',
|
|
332
|
-
'invalid_document',
|
|
333
|
-
'unsupported_operation',
|
|
334
|
-
'unique_conflict',
|
|
335
|
-
].includes(error.code);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
339
|
/**
|
|
339
340
|
* Waits before retrying a lost head swap: exponential from 5 ms, capped at 200 ms, with full
|
|
340
341
|
* jitter over its upper half, so writers that lost the same race spread out.
|
|
@@ -444,8 +445,10 @@ export class FlexRecordStore {
|
|
|
444
445
|
): Promise<TValue | undefined> {
|
|
445
446
|
await this.prepare();
|
|
446
447
|
const resolved = this.resolve(address);
|
|
447
|
-
|
|
448
|
-
|
|
448
|
+
return this.readVisible(resolved, async () => {
|
|
449
|
+
const head = await this.readHead(resolved);
|
|
450
|
+
return head.text === undefined ? undefined : this.parse(resolved, head.text, validate);
|
|
451
|
+
});
|
|
449
452
|
}
|
|
450
453
|
|
|
451
454
|
/** Reads a log record: its base with every change applied, checked in full. */
|
|
@@ -454,7 +457,8 @@ export class FlexRecordStore {
|
|
|
454
457
|
log: IFlexRecordLog<TSnapshot, TChange>,
|
|
455
458
|
): Promise<TSnapshot | undefined> {
|
|
456
459
|
await this.prepare();
|
|
457
|
-
|
|
460
|
+
const resolved = this.resolve(address);
|
|
461
|
+
return this.readVisible(resolved, async () => (await this.loadLog(resolved, log)).value);
|
|
458
462
|
}
|
|
459
463
|
|
|
460
464
|
/**
|
|
@@ -539,11 +543,12 @@ export class FlexRecordStore {
|
|
|
539
543
|
throw contention(actualRevision);
|
|
540
544
|
}
|
|
541
545
|
|
|
542
|
-
/**
|
|
546
|
+
/** Logically deletes one record before removing its chunks and change pages in bounded batches. */
|
|
543
547
|
public async delete(address: IFlexRecordAddress): Promise<void> {
|
|
544
548
|
await this.prepare();
|
|
545
549
|
const resolved = this.resolve(address);
|
|
546
|
-
await this.
|
|
550
|
+
await this.deleteBatched(resolved.path, resolved.namespaceKey, `record/${resolved.recordId}`,
|
|
551
|
+
{ recordId: resolved.recordId, domain: resolved.domain });
|
|
547
552
|
}
|
|
548
553
|
|
|
549
554
|
/** Deletes every record, chunk and change page one session owns in the given domains. */
|
|
@@ -554,76 +559,195 @@ export class FlexRecordStore {
|
|
|
554
559
|
): Promise<void> {
|
|
555
560
|
await this.prepare();
|
|
556
561
|
const ownerKey = digest(JSON.stringify([storageKey, sessionId]));
|
|
557
|
-
|
|
558
|
-
`${FLEX_RECORD_COLLECTION}/${
|
|
559
|
-
|
|
560
|
-
|
|
562
|
+
for (const domain of domains) {
|
|
563
|
+
await this.deleteBatched(`${FLEX_RECORD_COLLECTION}/${domain}/owner/${ownerKey}`,
|
|
564
|
+
digest(storageKey), `owner/${ownerKey}/${domain}`, { ownerKey, domain });
|
|
565
|
+
}
|
|
561
566
|
}
|
|
562
567
|
|
|
563
568
|
/**
|
|
564
|
-
*
|
|
565
|
-
*
|
|
569
|
+
* Marks the namespace or domain absent before draining its records, chunks and change pages.
|
|
570
|
+
* The marker survives interruption and is removed after cleanup unless a fence holds it.
|
|
566
571
|
*/
|
|
567
572
|
public async purgeNamespace(storageKey: string, domain?: TFlexRecordDomain): Promise<void> {
|
|
568
573
|
await this.prepare();
|
|
569
574
|
const namespaceKey = digest(storageKey);
|
|
570
|
-
await this.
|
|
575
|
+
await this.deleteBatched(
|
|
571
576
|
`${FLEX_RECORD_COLLECTION}/${domain ?? 'all'}/namespace/${namespaceKey}`,
|
|
577
|
+
namespaceKey,
|
|
578
|
+
domain ? `domain/${namespaceKey}/${domain}` : namespaceKey,
|
|
572
579
|
domain ? { namespaceKey, domain } : { namespaceKey },
|
|
573
|
-
domain
|
|
574
|
-
? undefined
|
|
575
|
-
: (transaction) => this.namespaces.atomicDeleteMany(
|
|
576
|
-
{ namespaceKey, fenced: false },
|
|
577
|
-
{ session: transaction },
|
|
578
|
-
),
|
|
579
580
|
);
|
|
580
581
|
}
|
|
581
582
|
|
|
582
583
|
/**
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
* refused.
|
|
584
|
+
* Atomically fences a storage namespace before bounded physical deletion. The fence is
|
|
585
|
+
* permanent: every later write is refused, and a concurrent write either commits before
|
|
586
|
+
* the fence and is deleted, or conflicts and is refused.
|
|
587
587
|
*/
|
|
588
588
|
public async fenceNamespace(storageKey: string): Promise<void> {
|
|
589
589
|
await this.prepare();
|
|
590
590
|
const namespaceKey = digest(storageKey);
|
|
591
|
-
await this.
|
|
591
|
+
await this.deleteBatched(
|
|
592
592
|
`${FLEX_RECORD_COLLECTION}/all/namespace/${namespaceKey}`,
|
|
593
|
+
namespaceKey,
|
|
594
|
+
namespaceKey,
|
|
593
595
|
{ namespaceKey },
|
|
594
|
-
|
|
595
|
-
{ namespaceKey },
|
|
596
|
-
{ $set: { fenced: true }, $setOnInsert: { creationCount: 0 } },
|
|
597
|
-
{ upsert: true, session: transaction },
|
|
598
|
-
),
|
|
596
|
+
true,
|
|
599
597
|
);
|
|
600
598
|
}
|
|
601
599
|
|
|
602
|
-
|
|
603
|
-
private async deleteInTransaction(
|
|
600
|
+
private async deleteBatched(
|
|
604
601
|
path: string,
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
namespace?: (transaction: plugins.SmartdataSession) => Promise<unknown>,
|
|
602
|
+
namespaceKey: string,
|
|
603
|
+
markerKey: string,
|
|
604
|
+
selector: TDeletionSelector,
|
|
605
|
+
fence = false,
|
|
610
606
|
): Promise<void> {
|
|
607
|
+
const token = await this.transaction(path, 'delete', async (transaction) => {
|
|
608
|
+
await this.touchNamespace(namespaceKey, transaction);
|
|
609
|
+
const marker = await this.namespaces.getInstance({ namespaceKey: markerKey }, { session: transaction });
|
|
610
|
+
const purgeToken = marker?.purgeToken ?? plugins.crypto.randomUUID();
|
|
611
|
+
await this.namespaces.atomicUpdate({ namespaceKey: markerKey }, {
|
|
612
|
+
$set: { purgeToken, storageNamespaceKey: namespaceKey, ...(fence ? { fenced: true } : {}) },
|
|
613
|
+
$setOnInsert: { creationCount: 0, ...(fence ? {} : { fenced: false }) },
|
|
614
|
+
}, { upsert: true, session: transaction });
|
|
615
|
+
return purgeToken;
|
|
616
|
+
});
|
|
617
|
+
if (!token) return;
|
|
618
|
+
const batches: {
|
|
619
|
+
model: TRecordModel | TChunkModel | TChangePageModel | TNamespaceModel;
|
|
620
|
+
identity: string;
|
|
621
|
+
filter: TDeletionSelector | { storageNamespaceKey: string; namespaceKey: { $ne: string } };
|
|
622
|
+
}[] = [
|
|
623
|
+
{ model: this.records, identity: 'recordId', filter: selector },
|
|
624
|
+
{ model: this.chunks, identity: 'chunkId', filter: selector },
|
|
625
|
+
{ model: this.changes, identity: 'pageId', filter: selector },
|
|
626
|
+
];
|
|
627
|
+
if (markerKey === namespaceKey && !('domain' in selector)) batches.push({ model: this.namespaces,
|
|
628
|
+
identity: 'namespaceKey', filter: { storageNamespaceKey: namespaceKey, namespaceKey: { $ne: markerKey } } });
|
|
629
|
+
for (const { model, identity, filter } of batches) {
|
|
630
|
+
for (;;) {
|
|
631
|
+
const page = await model.getPagedInstances<TDeletionDocument>({ filter, sortField: identity,
|
|
632
|
+
uniqueField: identity, limit: deletionBatchSize, timeoutMS: this.timeoutMs });
|
|
633
|
+
if (page.documents.length === 0) break;
|
|
634
|
+
const active = await this.transaction(path, 'delete', async (transaction) => {
|
|
635
|
+
const touched = await this.namespaces.atomicUpdate({ namespaceKey: markerKey, purgeToken: token },
|
|
636
|
+
{ $inc: { creationCount: 1 } }, { session: transaction });
|
|
637
|
+
if (touched.matchedCount !== 1) return false;
|
|
638
|
+
const identifiers = page.documents.map((document) => this.documentIdentity(document, identity));
|
|
639
|
+
await model.atomicDeleteMany<TDeletionDocument>({ ...filter, [identity]: { $in: identifiers } },
|
|
640
|
+
{ session: transaction });
|
|
641
|
+
return true;
|
|
642
|
+
});
|
|
643
|
+
if (!active) return;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
await this.transaction(path, 'delete', async (transaction) => {
|
|
647
|
+
await this.touchNamespace(namespaceKey, transaction);
|
|
648
|
+
const completed = await this.namespaces.atomicUpdate({ namespaceKey: markerKey, purgeToken: token },
|
|
649
|
+
{ $set: { purgeToken: null } }, { session: transaction });
|
|
650
|
+
if (completed.matchedCount !== 1) return;
|
|
651
|
+
await this.namespaces.atomicDeleteMany({ namespaceKey: markerKey, purgeToken: null, fenced: false },
|
|
652
|
+
{ session: transaction });
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private async transaction<TResult>(
|
|
657
|
+
path: string,
|
|
658
|
+
operation: 'write' | 'delete',
|
|
659
|
+
callback: (transaction: plugins.SmartdataSession) => Promise<TResult>,
|
|
660
|
+
): Promise<TResult | undefined> {
|
|
611
661
|
const session = this.db.createSession();
|
|
612
662
|
try {
|
|
613
|
-
await session.withTransaction(
|
|
614
|
-
await namespace?.(transaction);
|
|
615
|
-
await this.records.atomicDeleteMany(selector, { session: transaction });
|
|
616
|
-
await this.chunks.atomicDeleteMany(selector, { session: transaction });
|
|
617
|
-
await this.changes.atomicDeleteMany(selector, { session: transaction });
|
|
618
|
-
}, { timeoutMS: this.timeoutMs });
|
|
663
|
+
return await session.withTransaction(callback, { timeoutMS: this.timeoutMs });
|
|
619
664
|
} catch (error) {
|
|
620
665
|
if (plugins.SmartdataSession.getCommitOutcome(error) === 'not_committed') throw error;
|
|
621
|
-
throw new FlexHarnessStoreCommitUncertainError(path,
|
|
666
|
+
throw new FlexHarnessStoreCommitUncertainError(path, operation, { cause: error });
|
|
622
667
|
} finally {
|
|
623
668
|
await session.close();
|
|
624
669
|
}
|
|
625
670
|
}
|
|
626
671
|
|
|
672
|
+
private documentIdentity(document: TDeletionDocument, identity: string): string {
|
|
673
|
+
if (identity === 'recordId') return (document as FlexRecordDocument).recordId;
|
|
674
|
+
if (identity === 'chunkId') return (document as FlexChunkDocument).chunkId;
|
|
675
|
+
if (identity === 'pageId') return (document as FlexChangePageDocument).pageId;
|
|
676
|
+
return document.namespaceKey;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
private markerKeys(resolved: IResolvedAddress): string[] {
|
|
680
|
+
return [resolved.namespaceKey, `domain/${resolved.namespaceKey}/${resolved.domain}`,
|
|
681
|
+
`owner/${resolved.ownerKey}/${resolved.domain}`, `record/${resolved.recordId}`];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private async blocked(resolved: IResolvedAddress, session?: plugins.SmartdataSession): Promise<boolean> {
|
|
685
|
+
const markers = await this.namespaces.getInstances({ namespaceKey: { $in: this.markerKeys(resolved) } },
|
|
686
|
+
session ? { session } : { timeoutMS: this.timeoutMs });
|
|
687
|
+
return markers.some((marker) => marker.fenced || Boolean(marker.purgeToken));
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private touchNamespace(namespaceKey: string, session: plugins.SmartdataSession): Promise<unknown> {
|
|
691
|
+
return this.namespaces.atomicUpdate({ namespaceKey },
|
|
692
|
+
{ $inc: { creationCount: 1 }, $setOnInsert: { fenced: false } }, { upsert: true, session });
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private writeTransaction<TResult>(resolved: IResolvedAddress,
|
|
696
|
+
callback: (transaction: plugins.SmartdataSession) => Promise<TResult>): Promise<TResult | undefined> {
|
|
697
|
+
return this.transaction(resolved.path, 'write', async (transaction) => {
|
|
698
|
+
if (await this.blocked(resolved, transaction)) throw new FlexHarnessStorePurgedError(resolved.path);
|
|
699
|
+
await this.touchNamespace(resolved.namespaceKey, transaction);
|
|
700
|
+
return callback(transaction);
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
private async readVisible<TValue>(resolved: IResolvedAddress,
|
|
705
|
+
read: () => Promise<TValue | undefined>): Promise<TValue | undefined> {
|
|
706
|
+
if (await this.blocked(resolved)) return undefined;
|
|
707
|
+
try {
|
|
708
|
+
const value = await read();
|
|
709
|
+
return await this.blocked(resolved) ? undefined : value;
|
|
710
|
+
} catch (error) {
|
|
711
|
+
if (await this.blocked(resolved)) return undefined;
|
|
712
|
+
throw error;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
private addressOf(document: FlexRecordDocument): IResolvedAddress {
|
|
717
|
+
return { recordId: document.recordId, domain: document.domain, ownerKey: document.ownerKey,
|
|
718
|
+
namespaceKey: document.namespaceKey, path: `${FLEX_RECORD_COLLECTION}/${document.domain}/${document.recordId}` };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
private async removeBlob(resolved: IResolvedAddress, blobId: string): Promise<void> {
|
|
722
|
+
await this.sweepDocuments(resolved, this.chunks, 'chunkId', { blobId });
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private async sweepDocuments(
|
|
726
|
+
resolved: IResolvedAddress,
|
|
727
|
+
model: TChunkModel | TChangePageModel,
|
|
728
|
+
identity: 'chunkId' | 'pageId',
|
|
729
|
+
selector: { recordId?: string; blobId?: string | { $ne: string }; baseToken?: { $ne: string }; logId?: string },
|
|
730
|
+
current?: FlexRecordDocument,
|
|
731
|
+
): Promise<void> {
|
|
732
|
+
for (;;) {
|
|
733
|
+
const page = await model.getPagedInstances<TDeletionDocument>({ filter: selector, sortField: identity, uniqueField: identity,
|
|
734
|
+
limit: deletionBatchSize, timeoutMS: this.timeoutMs });
|
|
735
|
+
if (page.documents.length === 0) return;
|
|
736
|
+
const active = await this.writeTransaction(resolved, async (transaction) => {
|
|
737
|
+
if (current) {
|
|
738
|
+
const head = await this.records.atomicUpdate({ recordId: current.recordId, writeToken: current.writeToken },
|
|
739
|
+
{ $inc: { sweepCount: 1 } }, { session: transaction });
|
|
740
|
+
if (head.matchedCount !== 1) return false;
|
|
741
|
+
}
|
|
742
|
+
const identifiers = page.documents.map((document) => this.documentIdentity(document, identity));
|
|
743
|
+
await model.atomicDeleteMany<TDeletionDocument>({ ...selector, [identity]: { $in: identifiers } },
|
|
744
|
+
{ session: transaction });
|
|
745
|
+
return true;
|
|
746
|
+
});
|
|
747
|
+
if (!active) return;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
627
751
|
private parse<TValue>(
|
|
628
752
|
resolved: IResolvedAddress,
|
|
629
753
|
text: string,
|
|
@@ -800,10 +924,10 @@ export class FlexRecordStore {
|
|
|
800
924
|
if (chunked && !current.chunked && !await this.markChunked(current)) return false;
|
|
801
925
|
}
|
|
802
926
|
const blobId = chunked ? await this.writeBlob(resolved, current.writeToken, text) : null;
|
|
927
|
+
if (blobId === undefined) return false;
|
|
803
928
|
const writeToken = plugins.crypto.randomUUID();
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
const outcome = await this.records.atomicUpdate(
|
|
929
|
+
const outcome = await this.writeTransaction(resolved, (transaction) =>
|
|
930
|
+
this.records.atomicUpdate(
|
|
807
931
|
{ recordId: resolved.recordId, writeToken: current.writeToken },
|
|
808
932
|
{
|
|
809
933
|
$set: {
|
|
@@ -822,15 +946,11 @@ export class FlexRecordStore {
|
|
|
822
946
|
retiredLogId: current.logId ?? null,
|
|
823
947
|
},
|
|
824
948
|
},
|
|
825
|
-
{
|
|
826
|
-
);
|
|
827
|
-
|
|
828
|
-
} catch (error) {
|
|
829
|
-
if (isDefiniteRejection(error)) throw error;
|
|
830
|
-
throw new FlexHarnessStoreCommitUncertainError(resolved.path, 'write', { cause: error });
|
|
831
|
-
}
|
|
949
|
+
{ session: transaction },
|
|
950
|
+
));
|
|
951
|
+
const swapped = outcome?.matchedCount === 1;
|
|
832
952
|
if (!swapped && blobId !== null) {
|
|
833
|
-
await this.
|
|
953
|
+
await this.removeBlob(resolved, blobId);
|
|
834
954
|
}
|
|
835
955
|
return swapped;
|
|
836
956
|
}
|
|
@@ -870,28 +990,21 @@ export class FlexRecordStore {
|
|
|
870
990
|
page.logId = head.logId!;
|
|
871
991
|
page.previousId = head.lastPageId ?? head.logId!;
|
|
872
992
|
page.changes = [...head.recentChanges, text];
|
|
873
|
-
await this.changes.insert(page, { timeoutMS: this.timeoutMs });
|
|
874
993
|
}
|
|
875
994
|
const writeToken = plugins.crypto.randomUUID();
|
|
876
995
|
const counts = { changeCount: 1, changeLength: text.length };
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
const outcome = await this.records.atomicUpdate(
|
|
996
|
+
const outcome = await this.writeTransaction(resolved, async (transaction) => {
|
|
997
|
+
const updated = await this.records.atomicUpdate(
|
|
880
998
|
{ recordId: resolved.recordId, writeToken: head.writeToken },
|
|
881
999
|
page
|
|
882
1000
|
? { $set: { revision, writeToken, recentChanges: [], lastPageId: page.pageId }, $inc: counts }
|
|
883
1001
|
: { $set: { revision, writeToken }, $inc: counts, $push: { recentChanges: text } },
|
|
884
|
-
{
|
|
1002
|
+
{ session: transaction },
|
|
885
1003
|
);
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
}
|
|
891
|
-
if (!swapped && page) {
|
|
892
|
-
await this.changes.atomicDeleteMany({ pageId: page.pageId }, { timeoutMS: this.timeoutMs });
|
|
893
|
-
}
|
|
894
|
-
return swapped;
|
|
1004
|
+
if (updated.matchedCount === 1 && page) await this.changes.insert(page, { session: transaction });
|
|
1005
|
+
return updated;
|
|
1006
|
+
});
|
|
1007
|
+
return outcome?.matchedCount === 1;
|
|
895
1008
|
}
|
|
896
1009
|
|
|
897
1010
|
private newHead(resolved: IResolvedAddress): FlexRecordDocument {
|
|
@@ -942,12 +1055,7 @@ export class FlexRecordStore {
|
|
|
942
1055
|
head.payload = text;
|
|
943
1056
|
head.chunked = false;
|
|
944
1057
|
}
|
|
945
|
-
|
|
946
|
-
return await this.create(resolved, head, chunk);
|
|
947
|
-
} catch (error) {
|
|
948
|
-
if (plugins.SmartdataSession.getCommitOutcome(error) === 'not_committed') throw error;
|
|
949
|
-
throw new FlexHarnessStoreCommitUncertainError(resolved.path, 'write', { cause: error });
|
|
950
|
-
}
|
|
1058
|
+
return this.create(resolved, head, chunk);
|
|
951
1059
|
}
|
|
952
1060
|
|
|
953
1061
|
/** Reserves the record for a first write longer than one chunk, so its chunks have a head token to replace. */
|
|
@@ -972,22 +1080,11 @@ export class FlexRecordStore {
|
|
|
972
1080
|
head: FlexRecordDocument,
|
|
973
1081
|
chunk?: FlexChunkDocument,
|
|
974
1082
|
): Promise<boolean> {
|
|
975
|
-
const session = this.db.createSession();
|
|
976
1083
|
try {
|
|
977
|
-
await
|
|
978
|
-
const namespace = await this.namespaces.getInstance(
|
|
979
|
-
{ namespaceKey: resolved.namespaceKey },
|
|
980
|
-
{ session: transaction },
|
|
981
|
-
);
|
|
982
|
-
if (namespace?.fenced) throw new FlexHarnessStorePurgedError(resolved.path);
|
|
983
|
-
await this.namespaces.atomicUpdate(
|
|
984
|
-
{ namespaceKey: resolved.namespaceKey },
|
|
985
|
-
{ $inc: { creationCount: 1 }, $setOnInsert: { fenced: false } },
|
|
986
|
-
{ upsert: true, session: transaction },
|
|
987
|
-
);
|
|
1084
|
+
await this.writeTransaction(resolved, async (transaction) => {
|
|
988
1085
|
await this.records.insert(head, { session: transaction });
|
|
989
1086
|
if (chunk) await this.chunks.insert(chunk, { session: transaction });
|
|
990
|
-
}
|
|
1087
|
+
});
|
|
991
1088
|
return true;
|
|
992
1089
|
} catch (error) {
|
|
993
1090
|
if (
|
|
@@ -997,30 +1094,38 @@ export class FlexRecordStore {
|
|
|
997
1094
|
return false;
|
|
998
1095
|
}
|
|
999
1096
|
throw error;
|
|
1000
|
-
} finally {
|
|
1001
|
-
await session.close();
|
|
1002
1097
|
}
|
|
1003
1098
|
}
|
|
1004
1099
|
|
|
1005
1100
|
private async markChunked(current: FlexRecordDocument): Promise<boolean> {
|
|
1006
|
-
const outcome = await this.records.atomicUpdate(
|
|
1101
|
+
const outcome = await this.writeTransaction(this.addressOf(current), (transaction) => this.records.atomicUpdate(
|
|
1007
1102
|
{ recordId: current.recordId, writeToken: current.writeToken },
|
|
1008
1103
|
{ $set: { chunked: true } },
|
|
1009
|
-
{
|
|
1010
|
-
);
|
|
1011
|
-
return outcome
|
|
1104
|
+
{ session: transaction },
|
|
1105
|
+
));
|
|
1106
|
+
return outcome?.matchedCount === 1;
|
|
1012
1107
|
}
|
|
1013
1108
|
|
|
1014
1109
|
private async writeBlob(
|
|
1015
1110
|
resolved: IResolvedAddress,
|
|
1016
1111
|
baseToken: string,
|
|
1017
1112
|
text: string,
|
|
1018
|
-
): Promise<string> {
|
|
1113
|
+
): Promise<string | undefined> {
|
|
1019
1114
|
const blobId = plugins.crypto.randomUUID();
|
|
1020
1115
|
const pieces = splitPayload(text);
|
|
1021
1116
|
for (let index = 0; index < pieces.length; index++) {
|
|
1022
1117
|
const chunk = this.newChunk(resolved, blobId, baseToken, index, pieces[index]);
|
|
1023
|
-
await this.
|
|
1118
|
+
const inserted = await this.writeTransaction(resolved, async (transaction) => {
|
|
1119
|
+
const head = await this.records.atomicUpdate({ recordId: resolved.recordId, writeToken: baseToken },
|
|
1120
|
+
{ $inc: { sweepCount: 1 } }, { session: transaction });
|
|
1121
|
+
if (head.matchedCount !== 1) return false;
|
|
1122
|
+
await this.chunks.insert(chunk, { session: transaction });
|
|
1123
|
+
return true;
|
|
1124
|
+
});
|
|
1125
|
+
if (!inserted) {
|
|
1126
|
+
await this.removeBlob(resolved, blobId);
|
|
1127
|
+
return undefined;
|
|
1128
|
+
}
|
|
1024
1129
|
}
|
|
1025
1130
|
return blobId;
|
|
1026
1131
|
}
|
|
@@ -1054,34 +1159,12 @@ export class FlexRecordStore {
|
|
|
1054
1159
|
* pages written after a deletion are never swept by a transaction that predates it.
|
|
1055
1160
|
*/
|
|
1056
1161
|
private async sweep(current: FlexRecordDocument): Promise<void> {
|
|
1057
|
-
const
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
);
|
|
1065
|
-
if (touched.matchedCount !== 1) return;
|
|
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
|
-
}
|
|
1082
|
-
}, { timeoutMS: this.timeoutMs });
|
|
1083
|
-
} finally {
|
|
1084
|
-
await session.close();
|
|
1085
|
-
}
|
|
1162
|
+
const resolved = this.addressOf(current);
|
|
1163
|
+
if (current.chunked) await this.sweepDocuments(resolved, this.chunks, 'chunkId', {
|
|
1164
|
+
recordId: current.recordId, baseToken: { $ne: current.writeToken },
|
|
1165
|
+
...(current.blobId === null ? {} : { blobId: { $ne: current.blobId } }),
|
|
1166
|
+
}, current);
|
|
1167
|
+
if (current.retiredLogId) await this.sweepDocuments(resolved, this.changes, 'pageId',
|
|
1168
|
+
{ recordId: current.recordId, logId: current.retiredLogId }, current);
|
|
1086
1169
|
}
|
|
1087
1170
|
}
|
|
@@ -21,22 +21,28 @@ const harness = new FlexHarness({ scopeResolver, modelResolver, stores });
|
|
|
21
21
|
|
|
22
22
|
The database is the application's. The stores never close it, and they need NoSQLDB or a MongoDB deployment with transactions. `prepare()` creates or verifies the `flexharness_records`, `flexharness_chunks`, `flexharness_changes` and `flexharness_namespaces` collections and their indexes; every store operation awaits the same preparation, so calling it at startup only moves that work and its failure forward. `operationTimeoutMs` (1 to 120000, default 30000) bounds every database call and transaction.
|
|
23
23
|
|
|
24
|
-
A snapshot of up to `FLEX_RECORD_CHUNK_CODE_UNITS` UTF-16 code units of JSON is stored inside its record. A longer one is split into chunks of that size, each below 3 MiB of UTF-8, so a snapshot may exceed the 16 MiB document limit of NoSQLDB and MongoDB. Chunks are written before the record swaps to them. The chunks a record no longer references are removed by a later write of that record, in
|
|
24
|
+
A snapshot of up to `FLEX_RECORD_CHUNK_CODE_UNITS` UTF-16 code units of JSON is stored inside its record. A longer one is split into chunks of that size, each below 3 MiB of UTF-8, so a snapshot may exceed the 16 MiB document limit of NoSQLDB and MongoDB. Chunks are written before the record swaps to them, in small transactions that check the namespace and the record's current write token. The chunks a record no longer references are removed by a later write of that record, in bounded transactions that also touch the record. A writer that crashes between writing its chunks and swapping the record leaves only unreferenced chunks, never a record that points at missing ones. They are written against the record's current write token, which a concurrent writer may still win with, so the write after the next one removes them, or the deletion of the session or namespace does.
|
|
25
25
|
|
|
26
|
-
`stores.purgeNamespace(storageKey)` is the durable deletion of a scope: it removes every record and
|
|
26
|
+
`stores.purgeNamespace(storageKey)` is the durable deletion of a scope: it removes every record, chunk and change page of that storage namespace in every domain, including the scope snapshot and the project-management tombstones that deleting its sessions keeps. Deleting every session alone leaves those records, keyed by the storage key. Stop new admission for every alias of the scope first. A plain purge must then wait until `harness.retireScope()` has returned on every harness that owns the scope: after a completed plain purge a harness that still holds it can write records back, because the purge removed the revisions its writes would conflict with.
|
|
27
27
|
|
|
28
28
|
```typescript
|
|
29
29
|
await harness.retireScope(scopeId);
|
|
30
30
|
await stores.purgeNamespace(storageKey);
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
When a harness may still hold the scope, such as one on another server, purge with `{ fence: true }`. The
|
|
33
|
+
When a harness may still hold the scope, such as one on another server, purge with `{ fence: true }`. The first small transaction atomically fences the namespace and marks its purge in progress in `flexharness_namespaces`, before deleting any payload. Every later write, including a chunk insertion, a change append or a replacement of an existing record, checks and touches the namespace in its transaction. A write that races the fence either commits first and is removed with the rest, or is refused with `FlexHarnessStorePurgedError` (a save whose expected revision is already gone may instead report its domain's conflict). The fence is permanent, so the storage key is never usable again, and a later purge without the fence does not lift it. FlexHarness reports a refused write of a session operation through `externalErrorProjector`, like every other store failure there.
|
|
34
34
|
|
|
35
35
|
```typescript
|
|
36
36
|
await stores.purgeNamespace(storageKey, { fence: true });
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
The purge's durable `purgeToken` makes the entire namespace logically absent: reads return `undefined` as soon as the marker commits, including while payload still exists or cleanup is interrupted. A plain purge uses the same temporary marker and refuses writes until cleanup completes, but does not install a permanent fence. Cleanup deletes heads first, then chunks and change pages, in transactions of at most four payload documents. Each payload document is below about 3 MiB, leaving room below NoSQLDB's 32 MiB transaction budget even for a namespace or one snapshot much larger than that budget. Each batch checks and touches the purge marker, so an old concurrent cleanup cannot delete a new generation after a completed plain purge. The final transaction clears the in-progress marker, retaining a permanent fence when present. Namespace cleanup also removes its subordinate deletion markers.
|
|
40
|
+
|
|
41
|
+
If cleanup fails or the process stops, physical payload may remain, but readers never hydrate it as valid state and writers cannot resurrect it. Retry the same purge from any prepared store, including after a restart; it resumes the remaining work. A retry without `{ fence: true }` preserves an already-installed fence. Repeated completed purges are idempotent. A failure before the first marker commits leaves the namespace unchanged; an error in a later, definitely aborted batch does not undo the already-committed marker or batches.
|
|
42
|
+
|
|
43
|
+
Deleting a record, a session's records in one domain, or the project-management namespace uses the same protocol with a marker restricted to that target. Reads of the target return `undefined` while it is being deleted; unrelated targets remain usable. A provider that deletes several domains processes them in order, so interruption may leave later domains intact; retry its `deleteSession()` to finish. These markers are temporary, and creation becomes possible again after successful cleanup. Unreferenced chunks and retired change pages are also swept in bounded batches guarded by the record's head token.
|
|
44
|
+
|
|
45
|
+
All writes in one namespace now contend on its namespace document, not only creations; the driver retries transaction conflicts. Upgrade every writer sharing a database to this implementation before using batched deletion: earlier adapters do not recognize in-progress deletion markers or fence their existing-record writes.
|
|
40
46
|
|
|
41
47
|
A projection and an Agent event history are each stored as a base plus the changes saved since. FlexHarness saves a projection as an `IFlexProjectionChange` and an Agent event history as an `IAgentEventChange`, which carry only the entries that changed, and the store appends the change to the record's head in the compare-and-swap that commits it, so a save reads and writes the head alone and costs the change rather than the whole session. The head holds the latest changes up to 16 Ki characters of JSON; the save that would exceed that first writes them, with its own change, as one page in `flexharness_changes`, and empties them in its swap. The base lives in chunks whatever its length, so the head each change swaps holds no payload; a session's first save creates the record together with a base of up to one chunk in one transaction. The changes are kept while together they stay no longer than the base, or than 64 Ki characters of JSON while the base is shorter, and no single change is longer than one chunk. The save that would exceed that compacts the record instead: it reads the record, applies the change, checks the resulting snapshot in full and writes it as the new base. The earlier base and its pages are removed like unreferenced chunks, by the record's next compaction, which finds those pages by their log instead of scanning the current one. A record therefore holds its base and changes no longer than it or that minimum, plus the earlier base and its pages until the next compaction. A session whose history shrank, after an undo branch for example, keeps its larger base until its changes outgrow it. A read applies every change to the base and checks the result in full, so it costs the base and every change since, and a record whose pages are incomplete fails with `FlexHarnessStoreFormatError`. A page a crashed writer left behind without swapping the head onto it is removed with the rest of its log, or by the record's deletion.
|
|
42
48
|
|
|
@@ -44,7 +50,7 @@ A save of a scope, permission or job snapshot checks its expected revision again
|
|
|
44
50
|
|
|
45
51
|
A projection or Agent event record that an earlier version wrote whole is read whole, and its next save rewrites it as a base. Versions before 12.0.0 cannot read a base: they fail closed on such a record with `FlexHarnessStoreFormatError` instead of reading the session without its changes, so rolling back to them is unsupported for every session 12.x has saved, as is running them beside 12.x on one database, and they leave the `flexharness_changes` documents in place.
|
|
46
52
|
|
|
47
|
-
A
|
|
53
|
+
A write or deletion transaction with an unknown commit outcome rejects with `FlexHarnessStoreCommitUncertainError` (operation `write` or `delete`). FlexHarness then fences the namespace, as it does for the JSON file stores. Every mutation uses the transaction's reported commit outcome, rather than inferring uncertainty from an error-class allowlist: an engine capacity rejection or any other error before commit is a definite failure and keeps its original error. A deadline before commit is definite too; an interrupted commit whose reply was not confirmed remains uncertain. For batched cleanup, this classification describes the failed transaction, not the earlier batches that were already acknowledged; its durable deletion marker still requires a retry to finish physical cleanup.
|
|
48
54
|
|
|
49
55
|
## Issue Reporting and Security
|
|
50
56
|
|
|
@@ -27,10 +27,10 @@ export class FlexTypedRequestError extends Error {
|
|
|
27
27
|
/**
|
|
28
28
|
* The tool catalog of a typedrequest surface: every included method becomes a catalog method of
|
|
29
29
|
* its path, declared as its contract writes it and validated against its schema, and runs through
|
|
30
|
-
* `router.routeAndAddResponse()` in process, its request
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* `onCall`.
|
|
30
|
+
* `router.routeAndAddResponse()` in process, its request what `params` makes of the model's
|
|
31
|
+
* parameters with the omitted properties its request has as `request` fills them, and its result
|
|
32
|
+
* what `response` makes of the answer. A refusal rejects with `FlexTypedRequestError`. A method
|
|
33
|
+
* `readOnly` marks is declared read-only and not gated by `onCall`.
|
|
34
34
|
*/
|
|
35
35
|
export function catalogFromTypedRequests<TScope = unknown>(
|
|
36
36
|
surface: IFlexTypedRequestSurface,
|
|
@@ -39,6 +39,12 @@ export function catalogFromTypedRequests<TScope = unknown>(
|
|
|
39
39
|
if (typeof options?.router?.routeAndAddResponse !== 'function') {
|
|
40
40
|
throw new plugins.FlexHarnessValidationError('catalogFromTypedRequests needs a router with routeAndAddResponse().');
|
|
41
41
|
}
|
|
42
|
+
const unlisted = surface.methods.find((method) => !Array.isArray(method.omitted));
|
|
43
|
+
if (unlisted) {
|
|
44
|
+
throw new plugins.FlexHarnessValidationError(
|
|
45
|
+
`The surface's method ${unlisted.method} has no omitted list, as one an earlier version described; describe it again with this version's describeTypedRequests().`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
42
48
|
const aliases = options.aliases ?? {};
|
|
43
49
|
const known = new Set(surface.methods.map((method) => method.method));
|
|
44
50
|
const unknown = Object.keys(aliases).filter((method) => !known.has(method));
|
|
@@ -58,12 +64,24 @@ export function catalogFromTypedRequests<TScope = unknown>(
|
|
|
58
64
|
params: Record<string, unknown>,
|
|
59
65
|
context: IFlexCatalogRunContext<TScope>,
|
|
60
66
|
): Promise<unknown> => {
|
|
61
|
-
const
|
|
67
|
+
const mapped = options.params ? await options.params(method, params, context) : params;
|
|
68
|
+
if (typeof mapped !== 'object' || mapped === null || Array.isArray(mapped)) {
|
|
69
|
+
throw new plugins.FlexHarnessValidationError(`The options' params() made no request object for ${method.method}.`);
|
|
70
|
+
}
|
|
71
|
+
// An omitted property is the binding's alone: the model's value never reaches the router.
|
|
72
|
+
const request: Record<string, unknown> = { ...mapped };
|
|
73
|
+
for (const key of surface.omit) delete request[key];
|
|
74
|
+
if (method.omitted.length > 0) {
|
|
75
|
+
const filled = options.request ? await options.request(method, context) : {};
|
|
76
|
+
for (const key of method.omitted) {
|
|
77
|
+
if (Object.hasOwn(filled, key)) request[key] = filled[key];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
62
80
|
const localData = options.localData ? await options.localData(method, context) : undefined;
|
|
63
81
|
const answer = await router.routeAndAddResponse(
|
|
64
82
|
{
|
|
65
83
|
method: method.method,
|
|
66
|
-
request
|
|
84
|
+
request,
|
|
67
85
|
response: {},
|
|
68
86
|
correlation: { id: plugins.crypto.randomUUID(), phase: 'request' },
|
|
69
87
|
},
|