@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.
@@ -176,14 +176,16 @@ export class FlexChangePageDocument extends plugins.SmartDataDbDoc<FlexChangePag
176
176
  }
177
177
 
178
178
  /**
179
- * One storage namespace. Every record creation in the namespace increments `creationCount` in
180
- * its transaction, so a concurrent fenced purge, which sets `fenced`, conflicts with it. A fenced
181
- * namespace accepts no new record.
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
- const head = await this.readHead(resolved);
448
- return head.text === undefined ? undefined : this.parse(resolved, head.text, validate);
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
- return (await this.loadLog(this.resolve(address), log)).value;
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
- /** Deletes one record with its chunks and change pages. */
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.deleteInTransaction(resolved.path, { recordId: resolved.recordId, domain: resolved.domain });
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
- await this.deleteInTransaction(
558
- `${FLEX_RECORD_COLLECTION}/${domains.join('+')}/owner/${ownerKey}`,
559
- { ownerKey, domain: { $in: [...domains] } },
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
- * 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.
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.deleteInTransaction(
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
- * 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.
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.deleteInTransaction(
591
+ await this.deleteBatched(
592
592
  `${FLEX_RECORD_COLLECTION}/all/namespace/${namespaceKey}`,
593
+ namespaceKey,
594
+ namespaceKey,
593
595
  { namespaceKey },
594
- (transaction) => this.namespaces.atomicUpdate(
595
- { namespaceKey },
596
- { $set: { fenced: true }, $setOnInsert: { creationCount: 0 } },
597
- { upsert: true, session: transaction },
598
- ),
596
+ true,
599
597
  );
600
598
  }
601
599
 
602
- /** Deletes the selected records, chunks and change pages, after `namespace` changes the namespace document. */
603
- private async deleteInTransaction(
600
+ private async deleteBatched(
604
601
  path: string,
605
- selector:
606
- | { recordId: string; domain: TFlexRecordDomain }
607
- | { ownerKey: string; domain: { $in: TFlexRecordDomain[] } }
608
- | { namespaceKey: string; domain?: TFlexRecordDomain },
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(async (transaction) => {
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, 'delete', { cause: error });
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
- let swapped: boolean;
805
- try {
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
- { timeoutMS: this.timeoutMs },
826
- );
827
- swapped = outcome.matchedCount === 1;
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.chunks.atomicDeleteMany({ blobId }, { timeoutMS: this.timeoutMs });
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
- let swapped: boolean;
878
- try {
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
- { timeoutMS: this.timeoutMs },
1002
+ { session: transaction },
885
1003
  );
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;
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
- try {
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 session.withTransaction(async (transaction) => {
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
- }, { timeoutMS: this.timeoutMs });
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
- { timeoutMS: this.timeoutMs },
1010
- );
1011
- return outcome.matchedCount === 1;
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.chunks.insert(chunk, { timeoutMS: this.timeoutMs });
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 session = this.db.createSession();
1058
- try {
1059
- await session.withTransaction(async (transaction) => {
1060
- const touched = await this.records.atomicUpdate(
1061
- { recordId: current.recordId, writeToken: current.writeToken },
1062
- { $inc: { sweepCount: 1 } },
1063
- { session: transaction },
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 a transaction that also touches the record, and deleting a session or purging a namespace removes its records and chunks in one transaction. 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.
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 chunk of that storage namespace in every domain, including the scope snapshot and the project-management tombstones that deleting its sessions keeps, in one transaction. 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: a harness that still holds it can write records back, because the purge removed the revisions its writes would conflict with.
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 same transaction fences the namespace in `flexharness_namespaces`: every later record creation in it, from any process, is refused with `FlexHarnessStorePurgedError`, and a write that extends a purged record conflicts, so nothing a stale harness writes lands. A creation that races the purge either commits first and is removed with the rest, or is refused. 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.
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
- Every record creation is a transaction that checks the namespace's fence document and writes to it, so concurrent creations in one namespace contend on that document and the driver retries the ones that conflict.
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 save that may have reached the database without a confirmation, such as a timed-out or interrupted record write or a 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.
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 the model's parameters with the omitted
31
- * properties `request` fills and its result what `response` makes of the answer. A refusal rejects
32
- * with `FlexTypedRequestError`. A method `readOnly` marks is declared read-only and not gated by
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 filled = options.request ? await options.request(method, context) : {};
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: { ...params, ...filled },
84
+ request,
67
85
  response: {},
68
86
  correlation: { id: plugins.crypto.randomUUID(), phase: 'request' },
69
87
  },