agl 40.5.1 → 41.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/changelog.md +43 -0
  2. package/dist_serve/bundle.js +3514 -3357
  3. package/dist_serve/bundle.js.map +2 -2
  4. package/dist_serve/bundle.js.third-party-notices.json +58 -32
  5. package/dist_serve/bundle.js.third-party-notices.txt +108 -33
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/aglapi/aglapisurface.document.js +64 -37
  8. package/dist_ts/classes.aglhome.d.ts +5 -0
  9. package/dist_ts/classes.aglhome.js +2 -1
  10. package/dist_ts/classes.authstore.js +5 -1
  11. package/dist_ts/classes.controller.d.ts +8 -0
  12. package/dist_ts/classes.controller.js +119 -22
  13. package/dist_ts/classes.controllereventdelivery.d.ts +72 -0
  14. package/dist_ts/classes.controllereventdelivery.js +233 -0
  15. package/dist_ts/classes.flexmodels.public.js +4 -1
  16. package/dist_ts/classes.flexprojectionreader.d.ts +6 -0
  17. package/dist_ts/classes.flexprojectionreader.js +29 -5
  18. package/dist_ts/classes.flexservice.d.ts +2 -1
  19. package/dist_ts/classes.flexservice.js +34 -6
  20. package/dist_ts/classes.flexstore.d.ts +41 -4
  21. package/dist_ts/classes.flexstore.js +266 -203
  22. package/dist_ts/classes.inferencegateway.d.ts +35 -1
  23. package/dist_ts/classes.inferencegateway.js +86 -26
  24. package/dist_ts/classes.inferenceservice.d.ts +4 -2
  25. package/dist_ts/classes.inferenceservice.js +5 -4
  26. package/dist_ts/classes.proxycontrol.d.ts +58 -4
  27. package/dist_ts/classes.proxycontrol.js +23 -44
  28. package/dist_ts/classes.proxycontrolclient.d.ts +15 -5
  29. package/dist_ts/classes.proxycontrolclient.js +67 -17
  30. package/dist_ts/classes.proxydaemon.d.ts +22 -3
  31. package/dist_ts/classes.proxydaemon.js +73 -12
  32. package/dist_ts/classes.proxyleaseholder.d.ts +43 -14
  33. package/dist_ts/classes.proxyleaseholder.js +66 -22
  34. package/dist_ts/classes.proxystore.d.ts +9 -0
  35. package/dist_ts/classes.proxystore.js +15 -7
  36. package/dist_ts/classes.ticketnotifier.js +4 -2
  37. package/dist_ts/classes.ticketstore.d.ts +50 -2
  38. package/dist_ts/classes.ticketstore.js +195 -78
  39. package/dist_ts/classes.workercallerbindings.d.ts +66 -0
  40. package/dist_ts/classes.workercallerbindings.js +211 -0
  41. package/dist_ts/classes.workercallers.d.ts +63 -2
  42. package/dist_ts/classes.workercallers.js +430 -65
  43. package/dist_ts/driver/classes.drivereventrelay.d.ts +13 -1
  44. package/dist_ts/driver/classes.drivereventrelay.js +99 -6
  45. package/dist_ts/driver/functions.driverinstructions.d.ts +1 -1
  46. package/dist_ts/driver/functions.driverinstructions.js +1 -1
  47. package/dist_ts/driver/functions.drivertools.d.ts +11 -2
  48. package/dist_ts/driver/functions.drivertools.js +19 -9
  49. package/dist_ts/fleet/classes.boardpump.d.ts +8 -2
  50. package/dist_ts/fleet/classes.boardpump.js +38 -20
  51. package/dist_ts/fleet/classes.ticketeffects.d.ts +17 -2
  52. package/dist_ts/fleet/classes.ticketeffects.js +16 -10
  53. package/dist_ts/fleet/classes.workerlaunches.d.ts +12 -5
  54. package/dist_ts/fleet/classes.workerlaunches.js +25 -19
  55. package/dist_ts/fleet/classes.workers.d.ts +21 -0
  56. package/dist_ts/fleet/classes.workers.js +110 -17
  57. package/dist_ts/fleet/functions.workervalidation.js +2 -2
  58. package/dist_ts/functions.flexprivatesnapshot.d.ts +102 -0
  59. package/dist_ts/functions.flexprivatesnapshot.js +461 -0
  60. package/dist_ts/functions.inferencecli.d.ts +3 -2
  61. package/dist_ts/functions.inferencecli.js +67 -5
  62. package/dist_ts/functions.inferencecliarguments.d.ts +6 -2
  63. package/dist_ts/functions.inferencecliarguments.js +29 -2
  64. package/dist_ts/functions.inferencelaunch.d.ts +24 -0
  65. package/dist_ts/functions.inferencelaunch.js +42 -2
  66. package/dist_ts/functions.mcptickettools.d.ts +3 -0
  67. package/dist_ts/functions.mcptickettools.js +2 -2
  68. package/dist_ts/functions.proxycontract.d.ts +11 -0
  69. package/dist_ts/functions.proxycontract.js +12 -1
  70. package/dist_ts/functions.proxysocket.d.ts +6 -0
  71. package/dist_ts/functions.proxysocket.js +46 -0
  72. package/dist_ts/functions.ticketcli.d.ts +1 -1
  73. package/dist_ts/functions.ticketcli.js +8 -4
  74. package/dist_ts/functions.ticketvalidation.d.ts +3 -0
  75. package/dist_ts/functions.ticketvalidation.js +7 -4
  76. package/dist_ts/functions.workerprocessproof.d.ts +6 -0
  77. package/dist_ts/functions.workerprocessproof.js +28 -4
  78. package/dist_ts/interfaces.flexipc.d.ts +21 -2
  79. package/dist_ts/interfaces.flexipc.js +33 -16
  80. package/dist_ts_interfaces/fleetworkers.d.ts +6 -0
  81. package/dist_ts_interfaces/interfaces.d.ts +1 -1
  82. package/dist_ts_interfaces/interfaces.js +1 -1
  83. package/dist_ts_interfaces/requests.tickets.d.ts +8 -12
  84. package/dist_ts_interfaces/tickets.d.ts +37 -9
  85. package/dist_ts_interfaces/tickets.js +7 -1
  86. package/dist_ts_interfaces/workercallers.d.ts +43 -4
  87. package/package.json +7 -7
  88. package/readme.md +156 -23
  89. package/ts/00_commitinfo_data.ts +1 -1
  90. package/ts/aglapi/aglapisurface.document.ts +63 -36
  91. package/ts/classes.aglhome.ts +6 -0
  92. package/ts/classes.authstore.ts +4 -0
  93. package/ts/classes.controller.ts +141 -32
  94. package/ts/classes.controllereventdelivery.ts +289 -0
  95. package/ts/classes.flexmodels.public.ts +10 -0
  96. package/ts/classes.flexprojectionreader.ts +30 -3
  97. package/ts/classes.flexservice.ts +33 -5
  98. package/ts/classes.flexstore.ts +381 -246
  99. package/ts/classes.inferencegateway.ts +104 -23
  100. package/ts/classes.inferenceservice.ts +6 -3
  101. package/ts/classes.proxycontrol.ts +76 -39
  102. package/ts/classes.proxycontrolclient.ts +72 -20
  103. package/ts/classes.proxydaemon.ts +85 -15
  104. package/ts/classes.proxyleaseholder.ts +84 -28
  105. package/ts/classes.proxystore.ts +16 -5
  106. package/ts/classes.ticketnotifier.ts +3 -1
  107. package/ts/classes.ticketstore.ts +201 -75
  108. package/ts/classes.workercallerbindings.ts +147 -0
  109. package/ts/classes.workercallers.ts +410 -59
  110. package/ts/driver/classes.drivereventrelay.ts +89 -4
  111. package/ts/driver/functions.driverinstructions.ts +1 -1
  112. package/ts/driver/functions.drivertools.ts +22 -8
  113. package/ts/fleet/classes.boardpump.ts +51 -19
  114. package/ts/fleet/classes.ticketeffects.ts +25 -11
  115. package/ts/fleet/classes.workerlaunches.ts +23 -15
  116. package/ts/fleet/classes.workers.ts +79 -17
  117. package/ts/fleet/functions.workervalidation.ts +1 -1
  118. package/ts/functions.flexprivatesnapshot.ts +601 -0
  119. package/ts/functions.inferencecli.ts +61 -7
  120. package/ts/functions.inferencecliarguments.ts +32 -1
  121. package/ts/functions.inferencelaunch.ts +49 -2
  122. package/ts/functions.mcptickettools.ts +1 -1
  123. package/ts/functions.proxycontract.ts +12 -0
  124. package/ts/functions.proxysocket.ts +39 -0
  125. package/ts/functions.ticketcli.ts +7 -3
  126. package/ts/functions.ticketvalidation.ts +7 -3
  127. package/ts/functions.workerprocessproof.ts +27 -4
  128. package/ts/interfaces.flexipc.ts +50 -21
  129. package/ts_interfaces/fleetworkers.ts +6 -0
  130. package/ts_interfaces/interfaces.ts +1 -1
  131. package/ts_interfaces/requests.tickets.ts +9 -6
  132. package/ts_interfaces/tickets.ts +32 -1
  133. package/ts_interfaces/workercallers.ts +32 -4
  134. package/ts_web/00_commitinfo_data.ts +1 -1
  135. package/ts_web/elements.harnesscontrollerapp.ts +19 -8
  136. package/ts_web/elements.ticketboard.ts +16 -4
  137. package/ts_web/settings.general.ts +10 -4
@@ -23,7 +23,6 @@ import {
23
23
  assertFlexWriterLeaseDocument,
24
24
  flexDomainHeadId,
25
25
  flexMigrationId,
26
- flexPrivateChunkBytes,
27
26
  flexWriterLeaseId,
28
27
  initFlexPrivateModels,
29
28
  registerFlexPrivateModels,
@@ -67,6 +66,14 @@ import {
67
66
  initFlexPublicModels,
68
67
  registerFlexPublicModels,
69
68
  } from './classes.flexmodels.public.js';
69
+ import {
70
+ buildFlexPrivateSnapshot,
71
+ discardFlexPrivateChunks,
72
+ holdFlexPrivateLayout,
73
+ privateCandidateDocumentId,
74
+ releaseFlexPrivateLayout,
75
+ type IFlexPrivateLayout,
76
+ } from './functions.flexprivatesnapshot.js';
70
77
  import { isFlexWriterId } from './interfaces.flexipc.js';
71
78
 
72
79
  type TFlexScopeSnapshot = plugins.flexharness.IFlexScopeSnapshot;
@@ -285,7 +292,25 @@ interface IFlexCachedSnapshot {
285
292
  storageKey: string;
286
293
  candidateId: string;
287
294
  snapshot: unknown;
295
+ /** The candidate's serialized bytes. */
288
296
  bytes: number;
297
+ /**
298
+ * How this writer stored the candidate, chunk by chunk with the chunks' bytes: the next save
299
+ * takes every chunk its change leaves in place. Absent for a snapshot read back.
300
+ */
301
+ layout?: IFlexPrivateLayout;
302
+ /** What the entry counts against `flexSnapshotCacheBytes`. */
303
+ cost: number;
304
+ }
305
+
306
+ /** A domain's committed snapshot a save took out of the cache or read, for the save alone. */
307
+ interface IFlexDomainCheckout<TSnapshot> {
308
+ head: TStoredDomainHead;
309
+ /** Undefined when the save did not need the snapshot and none was cached. */
310
+ snapshot?: TSnapshot;
311
+ layout?: IFlexPrivateLayout;
312
+ /** Ends the save's hold on the layout; the cache holds what the save committed. */
313
+ release(): void;
289
314
  }
290
315
 
291
316
  interface IFlexCachedTranscript {
@@ -293,8 +318,19 @@ interface IFlexCachedTranscript {
293
318
  bytes: number;
294
319
  }
295
320
 
296
- /** What the snapshot and transcript caches may hold in total. */
297
- const flexSnapshotCacheBytes = 64 * 1024 * 1024;
321
+ /**
322
+ * What the snapshot cache may hold in total. An entry counts its kept chunk bytes once and its
323
+ * parsed snapshot at `flexSnapshotHeapFactor` times the snapshot's serialized bytes, so the cache
324
+ * holds at most about 170 MiB of serialized snapshots stored by this writer (3 times their size
325
+ * counted). A snapshot that does not fit even alone is not cached: its saves take the full path.
326
+ */
327
+ const flexSnapshotCacheBytes = 512 * 1024 * 1024;
328
+ /**
329
+ * The V8 heap a parsed snapshot takes per byte of its JSON, measured on realistic projections
330
+ * and Agent event logs (1.79 and 1.83 on Node 25), rounded up.
331
+ */
332
+ const flexSnapshotHeapFactor = 2;
333
+ /** What the transcript cache may hold in total. */
298
334
  const flexTranscriptCacheBytes = 16 * 1024 * 1024;
299
335
 
300
336
  interface IFlexMaintenanceCandidate {
@@ -354,28 +390,6 @@ const randomCandidateId = (): string => plugins.crypto.randomBytes(16).toString(
354
390
 
355
391
  const randomOwnerToken = (): string => plugins.crypto.randomBytes(32).toString('base64url');
356
392
 
357
- const privateCandidateDocumentId = (candidateIdArg: string): string =>
358
- `flex-private-candidate:${candidateIdArg}`;
359
-
360
- /**
361
- * Private chunks are addressed by their content within one domain incarnation, so a save stores
362
- * only the chunks it changed and shares the others with the snapshot it replaces.
363
- */
364
- const privateChunkDocumentId = (
365
- controllerIdArg: string,
366
- storageKeyArg: string,
367
- sourceArg: IFlexPrivateDomainSource,
368
- chunkDigestArg: string,
369
- ): string => `flex-private-chunk:${sha256(JSON.stringify([
370
- controllerIdArg,
371
- storageKeyArg,
372
- sourceArg.identity.domain,
373
- sourceArg.identity.sessionId ?? null,
374
- sourceArg.identity.archiveId ?? null,
375
- sourceArg.incarnationId,
376
- chunkDigestArg,
377
- ]))}`;
378
-
379
393
  const publicCandidateDocumentId = (candidateIdArg: string): string =>
380
394
  `flex-public-candidate:${candidateIdArg}`;
381
395
 
@@ -920,136 +934,6 @@ const inBatches = async <TValue>(
920
934
  }
921
935
  };
922
936
 
923
- /** The lists of a domain snapshot that grow entry by entry, stored apart from the rest. */
924
- const privateSnapshotLists: Partial<Record<TFlexDomainKind, readonly string[]>> = {
925
- projection: ['messages', 'reversionSegments'],
926
- };
927
-
928
- /** A chunk of a growing list stays under this size, so a change rewrites little of the list. */
929
- const privateListChunkBytes = 64 * 1024;
930
-
931
- /**
932
- * The JSON of a snapshot as runs of pieces. A chunk never spans two runs, and inside a run of
933
- * list entries chunks are filled from the list's start, so entries before a change keep their
934
- * chunks and a save that changes a list's newest entries rewrites only that list's last chunk
935
- * besides the small runs between the lists.
936
- */
937
- const privateSnapshotRuns = (snapshotArg: unknown, listKeysArg: readonly string[]): string[][] => {
938
- if (
939
- listKeysArg.length === 0
940
- || snapshotArg === null
941
- || typeof snapshotArg !== 'object'
942
- || Array.isArray(snapshotArg)
943
- ) return [[JSON.stringify(snapshotArg)]];
944
- const runs: string[][] = [];
945
- let frame = '{';
946
- let first = true;
947
- for (const [key, value] of Object.entries(snapshotArg)) {
948
- if (value === undefined) continue;
949
- frame += `${first ? '' : ','}${JSON.stringify(key)}:`;
950
- first = false;
951
- if (listKeysArg.includes(key) && Array.isArray(value) && value.length > 0) {
952
- runs.push([`${frame}[`]);
953
- runs.push(value.map((entry, index) => `${index === 0 ? '' : ','}${JSON.stringify(entry)}`));
954
- frame = ']';
955
- } else {
956
- frame += JSON.stringify(value);
957
- }
958
- }
959
- runs.push([`${frame}}`]);
960
- return runs;
961
- };
962
-
963
- /** Cuts the runs into chunks of at most `flexPrivateChunkBytes`, never across two runs. */
964
- const privateSnapshotChunks = (runsArg: string[][]): Buffer[] => {
965
- const chunks: Buffer[] = [];
966
- for (const run of runsArg) {
967
- let pending: Buffer[] = [];
968
- let pendingBytes = 0;
969
- const flush = (): void => {
970
- if (pending.length === 0) return;
971
- chunks.push(Buffer.concat(pending, pendingBytes));
972
- pending = [];
973
- pendingBytes = 0;
974
- };
975
- for (const piece of run) {
976
- const bytes = Buffer.from(piece, 'utf8');
977
- if (pendingBytes > 0 && pendingBytes + bytes.byteLength > privateListChunkBytes) flush();
978
- if (bytes.byteLength <= flexPrivateChunkBytes - pendingBytes) {
979
- pending.push(bytes);
980
- pendingBytes += bytes.byteLength;
981
- continue;
982
- }
983
- flush();
984
- for (let offset = 0; offset < bytes.byteLength; offset += flexPrivateChunkBytes) {
985
- chunks.push(bytes.subarray(offset, Math.min(offset + flexPrivateChunkBytes, bytes.byteLength)));
986
- }
987
- }
988
- flush();
989
- }
990
- return chunks.length === 0 ? [Buffer.alloc(0)] : chunks;
991
- };
992
-
993
- const createPrivateProjection = (
994
- controllerIdArg: string,
995
- storageKeyArg: string,
996
- candidateIdArg: string,
997
- revisionArg: number,
998
- snapshotArg: unknown,
999
- createdAtArg: string,
1000
- sourceArg: IFlexPrivateDomainSource,
1001
- ): { candidate: IFlexPrivateCandidateDocument; chunks: IFlexPrivateChunkDocument[] } => {
1002
- const identity = {
1003
- domain: sourceArg.identity.domain,
1004
- incarnationId: sourceArg.incarnationId,
1005
- ...(sourceArg.identity.sessionId !== undefined
1006
- ? { sessionId: sourceArg.identity.sessionId }
1007
- : {}),
1008
- ...(sourceArg.identity.archiveId !== undefined
1009
- ? { archiveId: sourceArg.identity.archiveId }
1010
- : {}),
1011
- };
1012
- const buffers = privateSnapshotChunks(privateSnapshotRuns(
1013
- snapshotArg,
1014
- privateSnapshotLists[sourceArg.identity.domain] ?? [],
1015
- ));
1016
- const chunks: IFlexPrivateChunkDocument[] = buffers.map((chunk, index) => {
1017
- const digest = sha256(chunk);
1018
- return {
1019
- id: privateChunkDocumentId(controllerIdArg, storageKeyArg, sourceArg, digest),
1020
- controllerId: controllerIdArg,
1021
- scopeId: storageKeyArg,
1022
- candidateId: candidateIdArg,
1023
- revision: revisionArg,
1024
- index,
1025
- sha256: digest,
1026
- bytes: chunk.byteLength,
1027
- data: chunk.toString('base64'),
1028
- createdAt: createdAtArg,
1029
- ...identity,
1030
- };
1031
- });
1032
- const complete = Buffer.concat(buffers);
1033
- const candidate: IFlexPrivateCandidateDocument = {
1034
- id: privateCandidateDocumentId(candidateIdArg),
1035
- controllerId: controllerIdArg,
1036
- scopeId: storageKeyArg,
1037
- candidateId: candidateIdArg,
1038
- revision: revisionArg,
1039
- sha256: sha256(complete),
1040
- bytes: complete.byteLength,
1041
- chunks: chunks.map(({ id, sha256: digest, bytes }, index) => ({
1042
- id, index, sha256: digest, bytes,
1043
- })),
1044
- createdAt: createdAtArg,
1045
- ...identity,
1046
- };
1047
- complete.fill(0);
1048
- assertFlexPrivateCandidateDocument(candidate);
1049
- for (const chunk of chunks) assertFlexPrivateChunkDocument(chunk);
1050
- return { candidate, chunks };
1051
- };
1052
-
1053
937
  export interface IFlexTranscriptLimits {
1054
938
  messageLimit: number;
1055
939
  messageBytesLimit: number;
@@ -1069,11 +953,39 @@ const defaultTranscriptLimits: IFlexTranscriptLimits = {
1069
953
  singleMessageBytesLimit: flexPublicSingleMessageBytesLimit,
1070
954
  };
1071
955
 
956
+ /**
957
+ * What a transcript build asks of each message: the id of its record, the size of its JSON and
958
+ * its stand-in when it is oversized. A store answers from what it cached per stored message.
959
+ */
960
+ export interface IFlexTranscriptMessageRecords {
961
+ /** The id of the public record of `messageArg` at `messageIndexArg`. */
962
+ recordId(messageIndexArg: number, messageArg: TFlexMessage): string;
963
+ /** The UTF-8 bytes of `messageArg`'s JSON. */
964
+ messageBytes(messageArg: TFlexMessage): number;
965
+ /** The public stand-in of an oversized message, within `budgetArg` serialized bytes. */
966
+ placeholder(messageArg: TFlexMessage, budgetArg: number): TFlexMessage;
967
+ }
968
+
969
+ const computedMessageRecords = (
970
+ controllerIdArg: string,
971
+ storageKeyArg: string,
972
+ ): IFlexTranscriptMessageRecords => ({
973
+ recordId: (messageIndexArg, messageArg) => publicMessageDocumentId(
974
+ controllerIdArg,
975
+ storageKeyArg,
976
+ messageIndexArg,
977
+ messageArg,
978
+ ),
979
+ messageBytes: (messageArg) => serializedBytes(messageArg).byteLength,
980
+ placeholder: placeholderMessage,
981
+ });
982
+
1072
983
  /**
1073
984
  * The public transcript of one session: its newest messages within the public bounds, a message
1074
985
  * over the single-message bound reduced to a placeholder that keeps its metadata. Records are
1075
986
  * addressed by their content, and `storedRecordsArg` names records already stored, so the
1076
- * transcript lists the stored copy of every record it shares.
987
+ * transcript lists the stored copy of every record it shares; only the records it adds are
988
+ * serialized, hashed and checked.
1077
989
  */
1078
990
  export const createProjectionTranscript = (
1079
991
  controllerIdArg: string,
@@ -1083,12 +995,13 @@ export const createProjectionTranscript = (
1083
995
  preparationArg: IFlexCandidatePreparation,
1084
996
  storedRecordsArg: ReadonlyMap<string, IFlexRecordManifest> = new Map(),
1085
997
  limitsArg: IFlexTranscriptLimits = defaultTranscriptLimits,
998
+ recordsArg: IFlexTranscriptMessageRecords = computedMessageRecords(controllerIdArg, storageKeyArg),
1086
999
  ): IFlexTranscriptBuild => {
1087
1000
  const createRecord = (
1088
1001
  messageIndexArg: number,
1089
1002
  messageArg: TFlexMessage,
1090
1003
  ): IFlexPublicMessageRecordDocument => ({
1091
- id: publicMessageDocumentId(controllerIdArg, storageKeyArg, messageIndexArg, messageArg),
1004
+ id: recordsArg.recordId(messageIndexArg, messageArg),
1092
1005
  controllerId: controllerIdArg,
1093
1006
  scopeId: storageKeyArg,
1094
1007
  candidateId: preparationArg.candidateId,
@@ -1097,6 +1010,11 @@ export const createProjectionTranscript = (
1097
1010
  message: messageArg,
1098
1011
  createdAt: preparationArg.createdAt,
1099
1012
  });
1013
+ // A record's JSON is its metadata's with the message's in place, so an oversized message is
1014
+ // never serialized whole to learn that it does not fit.
1015
+ const recordBytes = (recordArg: IFlexPublicMessageRecordDocument): number =>
1016
+ serializedBytes({ ...recordArg, message: 0 }).byteLength - 1
1017
+ + recordsArg.messageBytes(recordArg.message);
1100
1018
  const newest: Array<{ record: IFlexPublicMessageRecordDocument; manifest: IFlexRecordManifest }> = [];
1101
1019
  let selectedBytes = 0;
1102
1020
  let replaced = false;
@@ -1104,11 +1022,12 @@ export const createProjectionTranscript = (
1104
1022
  for (let messageIndex = messagesArg.length - 1; messageIndex >= oldestRetained; messageIndex--) {
1105
1023
  const message = messagesArg[messageIndex]!;
1106
1024
  let record = createRecord(messageIndex, message);
1107
- let manifest = storedRecordsArg.get(record.id) ?? recordManifest(record);
1108
- if (manifest.bytes > limitsArg.singleMessageBytesLimit) {
1025
+ let manifest = storedRecordsArg.get(record.id)
1026
+ ?? (recordBytes(record) > limitsArg.singleMessageBytesLimit ? undefined : recordManifest(record));
1027
+ if (manifest === undefined || manifest.bytes > limitsArg.singleMessageBytesLimit) {
1109
1028
  record = createRecord(
1110
1029
  messageIndex,
1111
- placeholderMessage(message, Math.floor(limitsArg.singleMessageBytesLimit * 0.8)),
1030
+ recordsArg.placeholder(message, Math.floor(limitsArg.singleMessageBytesLimit * 0.8)),
1112
1031
  );
1113
1032
  manifest = storedRecordsArg.get(record.id) ?? recordManifest(record);
1114
1033
  replaced = true;
@@ -1145,7 +1064,10 @@ export const createProjectionTranscript = (
1145
1064
  ...unaddressed,
1146
1065
  };
1147
1066
  assertFlexPublicTranscriptDocument(transcript);
1148
- for (const { record } of selected) assertFlexPublicMessageRecordDocument(record);
1067
+ // A stored record was checked when it was written; only the records this build adds are new.
1068
+ for (const { record } of selected) {
1069
+ if (!storedRecordsArg.has(record.id)) assertFlexPublicMessageRecordDocument(record);
1070
+ }
1149
1071
  return {
1150
1072
  transcript,
1151
1073
  manifest: {
@@ -1318,10 +1240,27 @@ export class FlexStore implements plugins.flexharness.IFlexHarnessStores {
1318
1240
  /**
1319
1241
  * The snapshot of each domain head this writer last committed or loaded, keyed by head id and
1320
1242
  * valid while the head names the same private candidate: a save applies its change to it
1321
- * instead of reading the snapshot back. Only heads of storage keys this writer leases enter it.
1243
+ * instead of reading the snapshot back, and reuses the chunks of a layout it committed. Only
1244
+ * heads of storage keys this writer leases enter it; least recently used entries leave first.
1322
1245
  */
1323
1246
  private readonly snapshotCache = new Map<string, IFlexCachedSnapshot>();
1324
1247
  private snapshotCacheBytes = 0;
1248
+ /** Heads whose snapshot exceeds the whole snapshot cache, logged once each. */
1249
+ private readonly uncachedSnapshotHeads = new Set<string>();
1250
+ /*
1251
+ * What the public transcript needs of each stored message object, computed once: a stored
1252
+ * message is never edited (a change replaces it), so its record id at an index of a scope, its
1253
+ * JSON size and its oversized stand-in stay valid while the object lives.
1254
+ */
1255
+ private readonly publicMessageRecordIds = new WeakMap<
1256
+ TFlexMessage,
1257
+ { storageKey: string; messageIndex: number; recordId: string }
1258
+ >();
1259
+ private readonly publicMessageBytes = new WeakMap<TFlexMessage, number>();
1260
+ private readonly publicMessagePlaceholders = new WeakMap<
1261
+ TFlexMessage,
1262
+ { budget: number; placeholder: TFlexMessage }
1263
+ >();
1325
1264
  /** Transcripts by id; they are addressed by their content, so a cached one never goes stale. */
1326
1265
  private readonly transcriptCache = new Map<string, IFlexCachedTranscript>();
1327
1266
  private transcriptCacheBytes = 0;
@@ -1958,48 +1897,59 @@ export class FlexStore implements plugins.flexharness.IFlexHarnessStores {
1958
1897
  expectedRevisionArg,
1959
1898
  actualRevisionArg,
1960
1899
  );
1961
- let stored: TFlexProjectionSnapshot | undefined;
1962
- if (change.messages.end > 0 || change.reversionSegments.end > 0) {
1963
- const current = await this.loadDomainDirect<TFlexProjectionSnapshot>(
1900
+ // A change that keeps stored entries applies to the committed snapshot; one that sends
1901
+ // whole lists needs none read.
1902
+ const keepsEntries = change.messages.end > 0 || change.reversionSegments.end > 0;
1903
+ const checkout = await this.checkoutDomain<TFlexProjectionSnapshot>(
1904
+ identity,
1905
+ (value) => this.validateProjectionSnapshot(value, sessionIdArg),
1906
+ keepsEntries,
1907
+ );
1908
+ let transcriptChanged = true;
1909
+ try {
1910
+ if (keepsEntries) {
1911
+ const actualRevision = checkout?.head.revision ?? 0;
1912
+ if (actualRevision !== expectedRevisionArg) throw conflict(actualRevision);
1913
+ }
1914
+ // The committed snapshot was validated when it was loaded or saved and is never edited, and
1915
+ // the change is this store's own copy: FlexHarness validates what the change carries and
1916
+ // reads the kept entries for their identities only, never copying the snapshot again.
1917
+ const snapshot = plugins.flexharness.createChangedProjectionSnapshot(
1918
+ keepsEntries ? checkout?.snapshot : undefined,
1919
+ change,
1920
+ sessionIdArg,
1921
+ );
1922
+ await this.saveDomain(
1923
+ lease,
1964
1924
  identity,
1965
- (value) => this.validateProjectionSnapshot(value, sessionIdArg),
1925
+ snapshot,
1926
+ expectedRevisionArg,
1927
+ conflict,
1928
+ undefined,
1929
+ undefined,
1930
+ async (preparation, current) => {
1931
+ const previous = current?.transcript
1932
+ ? await this.loadTranscript(storageKeyArg, current.transcript.id)
1933
+ : undefined;
1934
+ const build = await this.buildTranscript(
1935
+ storageKeyArg,
1936
+ sessionIdArg,
1937
+ snapshot.messages,
1938
+ preparation,
1939
+ previous,
1940
+ );
1941
+ if (current?.transcript?.id === build.transcript.id) {
1942
+ transcriptChanged = false;
1943
+ return current.transcript;
1944
+ }
1945
+ await this.writeTranscript(build.transcript, build.added);
1946
+ return build.manifest;
1947
+ },
1948
+ checkout,
1966
1949
  );
1967
- const actualRevision = current?.head.revision ?? 0;
1968
- if (actualRevision !== expectedRevisionArg) throw conflict(actualRevision);
1969
- stored = current?.snapshot;
1950
+ } finally {
1951
+ checkout?.release();
1970
1952
  }
1971
- const snapshot = this.validateProjectionSnapshot(
1972
- plugins.flexharness.applyFlexProjectionChange(stored, change),
1973
- sessionIdArg,
1974
- );
1975
- let transcriptChanged = true;
1976
- await this.saveDomain(
1977
- lease,
1978
- identity,
1979
- snapshot,
1980
- expectedRevisionArg,
1981
- conflict,
1982
- undefined,
1983
- undefined,
1984
- async (preparation, current) => {
1985
- const previous = current?.transcript
1986
- ? await this.loadTranscript(storageKeyArg, current.transcript.id)
1987
- : undefined;
1988
- const build = await this.buildTranscript(
1989
- storageKeyArg,
1990
- sessionIdArg,
1991
- snapshot.messages,
1992
- preparation,
1993
- previous,
1994
- );
1995
- if (current?.transcript?.id === build.transcript.id) {
1996
- transcriptChanged = false;
1997
- return current.transcript;
1998
- }
1999
- await this.writeTranscript(build.transcript, build.added);
2000
- return build.manifest;
2001
- },
2002
- );
2003
1953
  if (!transcriptChanged) return;
2004
1954
  if (!publishPublicProjectionArg) {
2005
1955
  // The publication no longer names this session's transcript; the next one reads every head.
@@ -2135,12 +2085,14 @@ export class FlexStore implements plugins.flexharness.IFlexHarnessStores {
2135
2085
  sessionIdArg: string,
2136
2086
  ): Promise<TAgentEventSnapshot | undefined> {
2137
2087
  this.assertBoundSession(boundSessionIdArg, sessionIdArg);
2138
- return this.runStorage(storageKeyArg, async () => (
2139
- await this.loadDomainDirect<TAgentEventSnapshot>(
2088
+ return this.runStorage(storageKeyArg, async () => {
2089
+ const loaded = await this.loadDomainDirect<TAgentEventSnapshot>(
2140
2090
  { storageKey: storageKeyArg, domain: 'agentEvents', sessionId: sessionIdArg },
2141
2091
  (value) => this.validateAgentEventSnapshot(value, sessionIdArg),
2142
- )
2143
- )?.snapshot);
2092
+ );
2093
+ // The store keeps its own copy for the next save.
2094
+ return loaded === undefined ? undefined : structuredClone(loaded.snapshot);
2095
+ });
2144
2096
  }
2145
2097
 
2146
2098
  private async saveAgentEventSnapshot(
@@ -2169,25 +2121,45 @@ export class FlexStore implements plugins.flexharness.IFlexHarnessStores {
2169
2121
  expectedRevisionArg,
2170
2122
  actualRevisionArg,
2171
2123
  );
2172
- const current = await this.loadDomainDirect<TAgentEventSnapshot>(
2124
+ const checkout = await this.checkoutDomain<TAgentEventSnapshot>(
2173
2125
  identity,
2174
2126
  (value) => this.validateAgentEventSnapshot(value, sessionIdArg),
2127
+ true,
2175
2128
  );
2176
- const actualRevision = current?.head.revision ?? 0;
2177
- if (actualRevision !== expectedRevisionArg) throw conflict(actualRevision);
2178
- const events = asStoreFormat(
2179
- 'Agent event change does not apply to the stored log',
2180
- () => plugins.flexAgent.applyAgentEventChange(current?.snapshot.events ?? [], change),
2181
- );
2182
- const snapshot = this.validateAgentEventSnapshot({
2183
- schemaVersion: 2,
2184
- sessionId: sessionIdArg,
2185
- revision: expectedRevisionArg + 1,
2186
- updatedAt: events.reduce((latest, event) => Math.max(latest, event.timestamp), 0),
2187
- events,
2188
- }, sessionIdArg);
2189
- await this.saveDomain(lease, identity, snapshot, expectedRevisionArg, conflict);
2190
- return snapshot.revision;
2129
+ try {
2130
+ const actualRevision = checkout?.head.revision ?? 0;
2131
+ if (actualRevision !== expectedRevisionArg) throw conflict(actualRevision);
2132
+ const events = asStoreFormat(
2133
+ 'Agent event change does not apply to the stored log',
2134
+ () => plugins.flexAgent.applyAgentEventChange(checkout?.snapshot?.events ?? [], change),
2135
+ );
2136
+ // The stored events were validated when they were stored and are never edited, and the
2137
+ // change is this store's own copy: the log is checked, never copied again.
2138
+ const snapshot = asStoreFormat(
2139
+ 'Invalid schema-2 Agent event snapshot',
2140
+ () => plugins.flexAgent.validateAgentEventSnapshot({
2141
+ schemaVersion: 2,
2142
+ sessionId: sessionIdArg,
2143
+ revision: expectedRevisionArg + 1,
2144
+ updatedAt: events.reduce((latest, event) => Math.max(latest, event.timestamp), 0),
2145
+ events,
2146
+ }, sessionIdArg),
2147
+ );
2148
+ await this.saveDomain(
2149
+ lease,
2150
+ identity,
2151
+ snapshot,
2152
+ expectedRevisionArg,
2153
+ conflict,
2154
+ undefined,
2155
+ undefined,
2156
+ undefined,
2157
+ checkout,
2158
+ );
2159
+ return snapshot.revision;
2160
+ } finally {
2161
+ checkout?.release();
2162
+ }
2191
2163
  });
2192
2164
  }
2193
2165
 
@@ -2396,11 +2368,19 @@ export class FlexStore implements plugins.flexharness.IFlexHarnessStores {
2396
2368
  sessionIdArg: string,
2397
2369
  ): TFlexProjectionSnapshot {
2398
2370
  const value = cloneJson(valueArg as TFlexProjectionSnapshot);
2399
- if (!Number.isSafeInteger(value?.revision) || value.revision < 0) {
2371
+ this.assertProjectionSnapshot(value, sessionIdArg);
2372
+ return value;
2373
+ }
2374
+
2375
+ /** Checks a projection snapshot in place: FlexHarness owns what a valid projection is. */
2376
+ private assertProjectionSnapshot(
2377
+ valueArg: TFlexProjectionSnapshot,
2378
+ sessionIdArg: string,
2379
+ ): void {
2380
+ if (!Number.isSafeInteger(valueArg?.revision) || valueArg.revision < 0) {
2400
2381
  throw new plugins.flexharness.FlexHarnessStoreFormatError('Invalid projection snapshot revision.');
2401
2382
  }
2402
- plugins.flexharness.assertFlexProjectionSnapshot(value, sessionIdArg);
2403
- return value;
2383
+ plugins.flexharness.assertFlexProjectionSnapshot(valueArg, sessionIdArg);
2404
2384
  }
2405
2385
 
2406
2386
  private async validatePermissionSnapshot(valueArg: unknown): Promise<TFlexPermissionSnapshot> {
@@ -2530,6 +2510,7 @@ export class FlexStore implements plugins.flexharness.IFlexHarnessStores {
2530
2510
  preparationArg: IFlexCandidatePreparation,
2531
2511
  currentArg: TStoredDomainHead | null,
2532
2512
  ) => Promise<IFlexPublicTranscriptManifest>,
2513
+ checkoutArg?: IFlexDomainCheckout<TSnapshot>,
2533
2514
  ): Promise<TStoredDomainHead> {
2534
2515
  if (!Number.isSafeInteger(revisionArg) || Number(revisionArg) < 1) {
2535
2516
  throw new plugins.flexharness.FlexHarnessStoreFormatError('Invalid domain revision.');
@@ -2542,15 +2523,30 @@ export class FlexStore implements plugins.flexharness.IFlexHarnessStores {
2542
2523
  const candidateId = randomCandidateId();
2543
2524
  const createdAt = new Date().toISOString();
2544
2525
  const incarnationId = current?.incarnationId ?? randomCandidateId();
2545
- const projection = createPrivateProjection(
2546
- this.controllerId,
2547
- identityArg.storageKey,
2526
+ const sharedChunkIds = new Set(current?.privateCandidate.chunks.map((chunk) => chunk.id) ?? []);
2527
+ // The checked-out layout describes the candidate the head names now, or it is not used.
2528
+ const previous = current && checkoutArg?.layout
2529
+ && checkoutArg.head.currentPrivateCandidateId === current.currentPrivateCandidateId
2530
+ && checkoutArg.layout.sha256 === current.privateCandidate.sha256
2531
+ && checkoutArg.layout.chunks.length === current.privateCandidate.chunks.length
2532
+ ? checkoutArg.layout
2533
+ : undefined;
2534
+ const projection = buildFlexPrivateSnapshot({
2535
+ owner: {
2536
+ controllerId: this.controllerId,
2537
+ storageKey: identityArg.storageKey,
2538
+ domain: identityArg.domain,
2539
+ ...(identityArg.sessionId !== undefined ? { sessionId: identityArg.sessionId } : {}),
2540
+ ...(identityArg.archiveId !== undefined ? { archiveId: identityArg.archiveId } : {}),
2541
+ incarnationId,
2542
+ },
2548
2543
  candidateId,
2549
2544
  revision,
2550
- snapshotArg,
2551
2545
  createdAt,
2552
- { identity: identityArg, incarnationId },
2553
- );
2546
+ snapshot: snapshotArg,
2547
+ ...(previous === undefined ? {} : { previous }),
2548
+ sharedChunkIds,
2549
+ });
2554
2550
  const manifest: IFlexPrivateCandidateManifest = {
2555
2551
  candidateId,
2556
2552
  revision,
@@ -2564,7 +2560,7 @@ export class FlexStore implements plugins.flexharness.IFlexHarnessStores {
2564
2560
  await this.writeAndVerifyPrivate(
2565
2561
  projection.candidate,
2566
2562
  projection.chunks,
2567
- new Set(current?.privateCandidate.chunks.map((chunk) => chunk.id) ?? []),
2563
+ sharedChunkIds,
2568
2564
  abortSignalArg,
2569
2565
  );
2570
2566
  abortSignalArg?.throwIfAborted();
@@ -2603,7 +2599,14 @@ export class FlexStore implements plugins.flexharness.IFlexHarnessStores {
2603
2599
  throw errorArg;
2604
2600
  }
2605
2601
  committed = true;
2606
- this.cacheSnapshot(next, snapshotArg);
2602
+ // Only the domains whose saves take changes keep a layout to build the next save from.
2603
+ this.cacheSnapshot(
2604
+ next,
2605
+ snapshotArg,
2606
+ identityArg.domain === 'projection' || identityArg.domain === 'agentEvents'
2607
+ ? projection.layout
2608
+ : undefined,
2609
+ );
2607
2610
  if (current) {
2608
2611
  await this.cleanupPrivateCandidate(
2609
2612
  current.currentPrivateCandidateId,
@@ -2618,6 +2621,7 @@ export class FlexStore implements plugins.flexharness.IFlexHarnessStores {
2618
2621
  );
2619
2622
  })();
2620
2623
  } finally {
2624
+ discardFlexPrivateChunks(projection.minted);
2621
2625
  if (!committed) this.queueOrphanMaintenance();
2622
2626
  this.activeCandidateIds.delete(candidateId);
2623
2627
  }
@@ -2654,6 +2658,48 @@ export class FlexStore implements plugins.flexharness.IFlexHarnessStores {
2654
2658
  return { head, snapshot };
2655
2659
  }
2656
2660
 
2661
+ /**
2662
+ * The committed snapshot a save changes: taken out of the cache, so the save holds its layout
2663
+ * alone, or read when `loadArg` asks for it. Undefined when the domain has no head. The caller
2664
+ * releases it once the save ended, whatever its outcome.
2665
+ */
2666
+ private async checkoutDomain<TSnapshot>(
2667
+ identityArg: IFlexDomainIdentity,
2668
+ validateArg: (valueArg: unknown) => TSnapshot | Promise<TSnapshot>,
2669
+ loadArg: boolean,
2670
+ ): Promise<IFlexDomainCheckout<TSnapshot> | undefined> {
2671
+ const head = await this.readDomainHead(identityArg);
2672
+ if (!head) return undefined;
2673
+ const entry = this.takeCachedSnapshot(head);
2674
+ if (entry) {
2675
+ let released = false;
2676
+ return {
2677
+ head,
2678
+ snapshot: entry.snapshot as TSnapshot,
2679
+ ...(entry.layout === undefined ? {} : { layout: entry.layout }),
2680
+ release: () => {
2681
+ if (released) return;
2682
+ released = true;
2683
+ if (entry.layout) releaseFlexPrivateLayout(entry.layout);
2684
+ },
2685
+ };
2686
+ }
2687
+ if (!loadArg) return { head, release: () => undefined };
2688
+ const snapshot = await this.loadPrivateCandidate(
2689
+ identityArg.storageKey,
2690
+ head.privateCandidate,
2691
+ validateArg,
2692
+ undefined,
2693
+ { identity: identityArg, incarnationId: head.incarnationId },
2694
+ );
2695
+ if ((snapshot as { revision?: unknown }).revision !== head.revision) {
2696
+ throw new plugins.flexharness.FlexHarnessStoreFormatError(
2697
+ 'Flex domain snapshot revision does not match its head.',
2698
+ );
2699
+ }
2700
+ return { head, snapshot, release: () => undefined };
2701
+ }
2702
+
2657
2703
  private async readMigration(
2658
2704
  storageKeyArg: string,
2659
2705
  optionsArg?: IFlexReadOptions,
@@ -2986,24 +3032,65 @@ export class FlexStore implements plugins.flexharness.IFlexHarnessStores {
2986
3032
  return entry.snapshot as TSnapshot;
2987
3033
  }
2988
3034
 
2989
- /** Keeps a projection or scope snapshot this writer committed or loaded, never edited after. */
2990
- private cacheSnapshot(headArg: IFlexDomainHeadDocument, snapshotArg: unknown): void {
2991
- if (headArg.domain !== 'projection' && headArg.domain !== 'scope') return;
3035
+ /**
3036
+ * Takes the cached snapshot of `headArg` out of the cache for a save, which then holds its
3037
+ * layout until it releases it; a stale entry is dropped.
3038
+ */
3039
+ private takeCachedSnapshot(headArg: IFlexDomainHeadDocument): IFlexCachedSnapshot | undefined {
3040
+ const entry = this.snapshotCache.get(headArg.id);
3041
+ if (!entry) return undefined;
3042
+ this.snapshotCache.delete(headArg.id);
3043
+ this.snapshotCacheBytes -= entry.cost;
3044
+ if (
3045
+ entry.candidateId === headArg.currentPrivateCandidateId
3046
+ && entry.bytes === headArg.privateCandidate.bytes
3047
+ && (entry.layout === undefined || entry.layout.sha256 === headArg.privateCandidate.sha256)
3048
+ ) return entry;
3049
+ if (entry.layout) releaseFlexPrivateLayout(entry.layout);
3050
+ return undefined;
3051
+ }
3052
+
3053
+ /**
3054
+ * Keeps a projection, scope or Agent event snapshot this writer committed or loaded, never
3055
+ * edited after, and the layout this writer stored it in.
3056
+ */
3057
+ private cacheSnapshot(
3058
+ headArg: IFlexDomainHeadDocument,
3059
+ snapshotArg: unknown,
3060
+ layoutArg?: IFlexPrivateLayout,
3061
+ ): void {
3062
+ if (
3063
+ headArg.domain !== 'projection'
3064
+ && headArg.domain !== 'scope'
3065
+ && headArg.domain !== 'agentEvents'
3066
+ ) return;
2992
3067
  if (!this.heldLeases.has(headArg.storageKey)) return;
2993
3068
  this.forgetCachedSnapshot(headArg.id);
2994
3069
  const bytes = headArg.privateCandidate.bytes;
2995
- if (bytes > flexSnapshotCacheBytes / 4) return;
3070
+ const cost = (layoutArg?.bytes ?? 0) + Math.ceil(bytes * flexSnapshotHeapFactor);
3071
+ if (cost > flexSnapshotCacheBytes) {
3072
+ if (!this.uncachedSnapshotHeads.has(headArg.id)) {
3073
+ this.uncachedSnapshotHeads.add(headArg.id);
3074
+ console.warn(
3075
+ `Flex snapshot ${headArg.id} of ${bytes} bytes exceeds the ${flexSnapshotCacheBytes}-byte snapshot cache; its saves read and rewrite it whole.`,
3076
+ );
3077
+ }
3078
+ return;
3079
+ }
3080
+ this.uncachedSnapshotHeads.delete(headArg.id);
3081
+ if (layoutArg) holdFlexPrivateLayout(layoutArg);
2996
3082
  this.snapshotCache.set(headArg.id, {
2997
3083
  storageKey: headArg.storageKey,
2998
3084
  candidateId: headArg.currentPrivateCandidateId,
2999
3085
  snapshot: snapshotArg,
3000
3086
  bytes,
3087
+ ...(layoutArg === undefined ? {} : { layout: layoutArg }),
3088
+ cost,
3001
3089
  });
3002
- this.snapshotCacheBytes += bytes;
3003
- for (const [id, entry] of this.snapshotCache) {
3090
+ this.snapshotCacheBytes += cost;
3091
+ for (const id of this.snapshotCache.keys()) {
3004
3092
  if (this.snapshotCacheBytes <= flexSnapshotCacheBytes) break;
3005
- this.snapshotCache.delete(id);
3006
- this.snapshotCacheBytes -= entry.bytes;
3093
+ this.forgetCachedSnapshot(id);
3007
3094
  }
3008
3095
  }
3009
3096
 
@@ -3011,7 +3098,8 @@ export class FlexStore implements plugins.flexharness.IFlexHarnessStores {
3011
3098
  const entry = this.snapshotCache.get(headIdArg);
3012
3099
  if (!entry) return;
3013
3100
  this.snapshotCache.delete(headIdArg);
3014
- this.snapshotCacheBytes -= entry.bytes;
3101
+ this.snapshotCacheBytes -= entry.cost;
3102
+ if (entry.layout) releaseFlexPrivateLayout(entry.layout);
3015
3103
  }
3016
3104
 
3017
3105
  private forgetCachedSnapshots(storageKeyArg: string): void {
@@ -3037,6 +3125,7 @@ export class FlexStore implements plugins.flexharness.IFlexHarnessStores {
3037
3125
  { id, sha256: digest, bytes },
3038
3126
  ]),
3039
3127
  );
3128
+ const records = this.transcriptMessageRecords(storageKeyArg);
3040
3129
  let build = createProjectionTranscript(
3041
3130
  this.controllerId,
3042
3131
  storageKeyArg,
@@ -3044,6 +3133,8 @@ export class FlexStore implements plugins.flexharness.IFlexHarnessStores {
3044
3133
  messagesArg,
3045
3134
  preparationArg,
3046
3135
  known,
3136
+ undefined,
3137
+ records,
3047
3138
  );
3048
3139
  const unknownIds = build.records.map((record) => record.id).filter((id) => !known.has(id));
3049
3140
  if (unknownIds.length > 0) {
@@ -3057,12 +3148,52 @@ export class FlexStore implements plugins.flexharness.IFlexHarnessStores {
3057
3148
  messagesArg,
3058
3149
  preparationArg,
3059
3150
  known,
3151
+ undefined,
3152
+ records,
3060
3153
  );
3061
3154
  }
3062
3155
  }
3063
3156
  return { ...build, added: build.records.filter((record) => !known.has(record.id)) };
3064
3157
  }
3065
3158
 
3159
+ /** The record facts of a scope's messages, from this store's per-message caches. */
3160
+ private transcriptMessageRecords(storageKeyArg: string): IFlexTranscriptMessageRecords {
3161
+ return {
3162
+ recordId: (messageIndexArg, messageArg) => {
3163
+ const cached = this.publicMessageRecordIds.get(messageArg);
3164
+ if (cached?.storageKey === storageKeyArg && cached.messageIndex === messageIndexArg) {
3165
+ return cached.recordId;
3166
+ }
3167
+ const recordId = publicMessageDocumentId(
3168
+ this.controllerId,
3169
+ storageKeyArg,
3170
+ messageIndexArg,
3171
+ messageArg,
3172
+ );
3173
+ this.publicMessageRecordIds.set(messageArg, {
3174
+ storageKey: storageKeyArg,
3175
+ messageIndex: messageIndexArg,
3176
+ recordId,
3177
+ });
3178
+ return recordId;
3179
+ },
3180
+ messageBytes: (messageArg) => {
3181
+ const cached = this.publicMessageBytes.get(messageArg);
3182
+ if (cached !== undefined) return cached;
3183
+ const bytes = serializedBytes(messageArg).byteLength;
3184
+ this.publicMessageBytes.set(messageArg, bytes);
3185
+ return bytes;
3186
+ },
3187
+ placeholder: (messageArg, budgetArg) => {
3188
+ const cached = this.publicMessagePlaceholders.get(messageArg);
3189
+ if (cached?.budget === budgetArg) return cached.placeholder;
3190
+ const placeholder = placeholderMessage(messageArg, budgetArg);
3191
+ this.publicMessagePlaceholders.set(messageArg, { budget: budgetArg, placeholder });
3192
+ return placeholder;
3193
+ },
3194
+ };
3195
+ }
3196
+
3066
3197
  /**
3067
3198
  * Stores a transcript before the records it adds, so an interrupted write leaves a transcript no
3068
3199
  * head names that still names everything the write stored, and proves both.
@@ -4374,11 +4505,15 @@ export class FlexStore implements plugins.flexharness.IFlexHarnessStores {
4374
4505
  .flatMap((chunk: unknown) => (
4375
4506
  typeof (chunk as { id?: unknown })?.id === 'string' ? [(chunk as { id: string }).id] : []
4376
4507
  ));
4377
- await FlexPrivateChunkModel.atomicDeleteMany({
4378
- ...owner,
4379
- $or: [{ candidateId: candidateIdArg }, ...(listed.length > 0 ? [{ id: { $in: listed } }] : [])],
4380
- ...(keepArg.size > 0 ? { id: { $nin: [...keepArg] } } : {}),
4381
- }, options);
4508
+ // The chunks the candidate wrote or lists, but those `keepArg` names: two deletes the indexes
4509
+ // answer (by candidate, by id), so only the chunks that go are examined, never every chunk of
4510
+ // the storage key.
4511
+ const keep = keepArg.size > 0 ? { id: { $nin: [...keepArg] } } : {};
4512
+ await FlexPrivateChunkModel.atomicDeleteMany({ ...owner, candidateId: candidateIdArg, ...keep }, options);
4513
+ const unkept = listed.filter((id) => !keepArg.has(id));
4514
+ if (unkept.length > 0) {
4515
+ await FlexPrivateChunkModel.atomicDeleteMany({ ...owner, id: { $in: unkept } }, options);
4516
+ }
4382
4517
  await FlexPrivateCandidateModel.atomicDeleteMany({ ...owner, candidateId: candidateIdArg }, options);
4383
4518
  }
4384
4519