@fluidframework/id-compressor 3.1.0 → 3.3.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 (102) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/api-report/id-compressor.legacy.beta.api.md +0 -49
  3. package/dist/idCompressor.d.ts +69 -19
  4. package/dist/idCompressor.d.ts.map +1 -1
  5. package/dist/idCompressor.js +365 -57
  6. package/dist/idCompressor.js.map +1 -1
  7. package/dist/index.d.ts +2 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +3 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/legacy.d.ts +1 -12
  12. package/dist/packageVersion.d.ts +1 -1
  13. package/dist/packageVersion.js +1 -1
  14. package/dist/packageVersion.js.map +1 -1
  15. package/dist/test/idCompressor.perf.spec.js.map +1 -1
  16. package/dist/test/idCompressor.spec.js.map +1 -1
  17. package/dist/test/idCompressorTestUtilities.d.ts.map +1 -1
  18. package/dist/test/idCompressorTestUtilities.js +8 -3
  19. package/dist/test/idCompressorTestUtilities.js.map +1 -1
  20. package/dist/test/sharding.fuzz.spec.d.ts +6 -0
  21. package/dist/test/sharding.fuzz.spec.d.ts.map +1 -0
  22. package/dist/test/sharding.fuzz.spec.js.map +1 -0
  23. package/dist/test/sharding.spec.d.ts +6 -0
  24. package/dist/test/sharding.spec.d.ts.map +1 -0
  25. package/dist/test/sharding.spec.js.map +1 -0
  26. package/dist/test/snapshots/summary.spec.js.map +1 -1
  27. package/dist/test/types/validateIdCompressorPrevious.generated.js.map +1 -1
  28. package/dist/types/idCompressor.d.ts +108 -2
  29. package/dist/types/idCompressor.d.ts.map +1 -1
  30. package/dist/types/idCompressor.js +15 -0
  31. package/dist/types/idCompressor.js.map +1 -1
  32. package/dist/types/index.d.ts +2 -1
  33. package/dist/types/index.d.ts.map +1 -1
  34. package/dist/types/index.js +3 -0
  35. package/dist/types/index.js.map +1 -1
  36. package/dist/types/persisted-types/0.0.1.d.ts +4 -4
  37. package/dist/types/persisted-types/0.0.1.js.map +1 -1
  38. package/dist/utilities.d.ts +1 -1
  39. package/dist/utilities.js +1 -1
  40. package/dist/utilities.js.map +1 -1
  41. package/lib/idCompressor.d.ts +69 -19
  42. package/lib/idCompressor.d.ts.map +1 -1
  43. package/lib/idCompressor.js +365 -57
  44. package/lib/idCompressor.js.map +1 -1
  45. package/lib/index.d.ts +2 -1
  46. package/lib/index.d.ts.map +1 -1
  47. package/lib/index.js +1 -0
  48. package/lib/index.js.map +1 -1
  49. package/lib/legacy.d.ts +1 -12
  50. package/lib/packageVersion.d.ts +1 -1
  51. package/lib/packageVersion.js +1 -1
  52. package/lib/packageVersion.js.map +1 -1
  53. package/lib/test/idCompressor.perf.spec.js.map +1 -1
  54. package/lib/test/idCompressor.spec.js.map +1 -1
  55. package/lib/test/idCompressorTestUtilities.d.ts.map +1 -1
  56. package/lib/test/idCompressorTestUtilities.js +8 -3
  57. package/lib/test/idCompressorTestUtilities.js.map +1 -1
  58. package/lib/test/sharding.fuzz.spec.d.ts +6 -0
  59. package/lib/test/sharding.fuzz.spec.d.ts.map +1 -0
  60. package/lib/test/sharding.fuzz.spec.js.map +1 -0
  61. package/lib/test/sharding.spec.d.ts +6 -0
  62. package/lib/test/sharding.spec.d.ts.map +1 -0
  63. package/lib/test/sharding.spec.js.map +1 -0
  64. package/lib/test/snapshots/summary.spec.js.map +1 -1
  65. package/lib/test/types/validateIdCompressorPrevious.generated.js.map +1 -1
  66. package/lib/types/idCompressor.d.ts +108 -2
  67. package/lib/types/idCompressor.d.ts.map +1 -1
  68. package/lib/types/idCompressor.js +14 -1
  69. package/lib/types/idCompressor.js.map +1 -1
  70. package/lib/types/index.d.ts +2 -1
  71. package/lib/types/index.d.ts.map +1 -1
  72. package/lib/types/index.js +1 -1
  73. package/lib/types/index.js.map +1 -1
  74. package/lib/types/persisted-types/0.0.1.d.ts +4 -4
  75. package/lib/types/persisted-types/0.0.1.js.map +1 -1
  76. package/lib/utilities.d.ts +1 -1
  77. package/lib/utilities.js +1 -1
  78. package/lib/utilities.js.map +1 -1
  79. package/package.json +42 -9
  80. package/src/idCompressor.ts +497 -73
  81. package/src/index.ts +3 -0
  82. package/src/packageVersion.ts +1 -1
  83. package/src/test/idCompressorTestUtilities.ts +16 -3
  84. package/src/test/snapshots/v2/compressor-with-finalized-range-from-one-client +1 -0
  85. package/src/test/snapshots/v2/compressor-with-finalized-range-from-one-client-client2 +1 -0
  86. package/src/test/snapshots/v2/compressors-with-finalized-ranges-from-two-clients +1 -0
  87. package/src/test/snapshots/v2/compressors-with-finalized-ranges-from-two-clients-client2 +1 -0
  88. package/src/test/snapshots/v2/empty-compressor-summary +1 -0
  89. package/src/test/snapshots/v2/expansion-semantics +1 -0
  90. package/src/test/snapshots/v2/expansion-semantics-client2 +1 -0
  91. package/src/test/snapshots/v3/compressor-with-finalized-range-from-one-client +1 -0
  92. package/src/test/snapshots/v3/compressor-with-finalized-range-from-one-client-client2 +1 -0
  93. package/src/test/snapshots/v3/compressors-with-finalized-ranges-from-two-clients +1 -0
  94. package/src/test/snapshots/v3/compressors-with-finalized-ranges-from-two-clients-client2 +1 -0
  95. package/src/test/snapshots/v3/empty-compressor-summary +1 -0
  96. package/src/test/snapshots/v3/expansion-semantics +1 -0
  97. package/src/test/snapshots/v3/expansion-semantics-client2 +1 -0
  98. package/src/test/types/validateIdCompressorPrevious.generated.ts +13 -1
  99. package/src/types/idCompressor.ts +119 -2
  100. package/src/types/index.ts +10 -1
  101. package/src/types/persisted-types/0.0.1.ts +4 -4
  102. package/src/utilities.ts +1 -1
@@ -42,6 +42,7 @@ import {
42
42
  lastFinalizedLocal,
43
43
  } from "./sessions.js";
44
44
  import type {
45
+ ShardToken,
45
46
  IIdCompressor,
46
47
  IIdCompressorCore,
47
48
  IdCreationRange,
@@ -52,7 +53,9 @@ import type {
52
53
  SessionId,
53
54
  SessionSpaceCompressedId,
54
55
  StableId,
56
+ ShardSynchronizationToken,
55
57
  } from "./types/index.js";
58
+ import { SerializationVersion } from "./types/index.js";
56
59
  import {
57
60
  createSessionId,
58
61
  genCountFromLocalId,
@@ -63,12 +66,6 @@ import {
63
66
  subtractNumericUuids,
64
67
  } from "./utilities.js";
65
68
 
66
- /**
67
- * The version of IdCompressor that is currently persisted.
68
- * This should not be changed without careful consideration to compatibility.
69
- */
70
- const currentWrittenVersion = 2;
71
-
72
69
  function rangeFinalizationError(expectedStart: number, actualStart: number): LoggingError {
73
70
  return new LoggingError("Ranges finalized out of order", {
74
71
  expectedStart,
@@ -76,6 +73,8 @@ function rangeFinalizationError(expectedStart: number, actualStart: number): Log
76
73
  });
77
74
  }
78
75
 
76
+ const MAX_STRIDE_LENGTH = 1000000;
77
+
79
78
  /**
80
79
  * See {@link IIdCompressor} and {@link IIdCompressorCore}
81
80
  */
@@ -93,13 +92,51 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
93
92
  // The number of IDs generated by the local session
94
93
  private localGenCount = 0;
95
94
 
95
+ /**
96
+ * The sharding state of this compressor, if it has been sharded or is a shard itself.
97
+ * When defined, this compressor operates in "sharding mode" where:
98
+ * - IDs are generated using stride-based allocation (no eager finals)
99
+ * - The normalizer backfills all IDs in each stride cycle
100
+ * - Normal operation resumes when all children are disposed
101
+ */
102
+ private shardingState?:
103
+ | {
104
+ /**
105
+ * Current stride - used when this compressor has active children.
106
+ * Changes each time shard() is called.
107
+ */
108
+ currentStride: number;
109
+
110
+ /**
111
+ * Original stride - the stride this compressor was created with.
112
+ * Used when this compressor has no children (is a leaf).
113
+ * IMMUTABLE after creation.
114
+ */
115
+ originalStride: number;
116
+
117
+ /**
118
+ * Set of unique child shard IDs (UUIDs) currently active.
119
+ * Each child is assigned a UUID when created via shard().
120
+ * Safe across serialization/deserialization cycles.
121
+ */
122
+ activeChildIds: Set<SessionId>;
123
+
124
+ /**
125
+ * This compressor's unique shard ID within its parent (if it's a child).
126
+ * Undefined for root compressors.
127
+ * Assigned by parent when creating this shard.
128
+ */
129
+ shardId?: SessionId;
130
+ }
131
+ | undefined;
132
+
96
133
  // #endregion
97
134
 
98
135
  // #region Final state
99
136
 
100
137
  /**
101
138
  * The gen count to be annotated on the range returned by the next call to `takeNextCreationRange`.
102
- * This is advanced to `generatedIdCount` + 1 each time it is called.
139
+ * This is advanced to `localGenCount` + 1 each time it is called.
103
140
  * On the other hand, when `resetUnfinalizedCreationRange` is called,
104
141
  * this is moved back to the start of the unfinalized range, to ensure those IDs are included in the next range.
105
142
  */
@@ -131,6 +168,12 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
131
168
  public constructor(
132
169
  localSessionIdOrDeserialized: SessionId | Sessions,
133
170
  private readonly logger: TelemetryLoggerExt | undefined,
171
+ /**
172
+ * The version this compressor will write out.
173
+ * This applies to both `serialize` and creation range functions.
174
+ * This number also determines what features are available (e.g. sharding support starts at V3).
175
+ */
176
+ private readonly writeVersion: SerializationVersion,
134
177
  ) {
135
178
  if (typeof localSessionIdOrDeserialized === "string") {
136
179
  this.localSessionId = localSessionIdOrDeserialized;
@@ -164,30 +207,42 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
164
207
  return lastFinalizedFinal(
165
208
  this.ongoingGhostSession.cluster,
166
209
  ) as unknown as SessionSpaceCompressedId;
167
- } else {
168
- this.localGenCount++;
169
- const lastCluster = this.localSession.getLastCluster();
170
- if (lastCluster === undefined) {
171
- this.telemetryLocalIdCount++;
172
- return this.generateNextLocalId();
173
- }
210
+ }
174
211
 
175
- // If there exists a cluster of final IDs already claimed by the local session that still has room in it,
176
- // it is known prior to range sequencing what a local ID's corresponding final ID will be.
177
- // In this case, it is safe to return the final ID immediately. This is guaranteed to be safe because
178
- // any op that the local session sends that contains one of those final IDs are guaranteed to arrive to
179
- // collaborators *after* the one containing the creation range.
180
- const clusterOffset = this.localGenCount - genCountFromLocalId(lastCluster.baseLocalId);
181
- if (lastCluster.capacity > clusterOffset) {
182
- this.telemetryEagerFinalIdCount++;
183
- // Space in the cluster: eager final
184
- return ((lastCluster.baseFinalId as number) +
185
- clusterOffset) as SessionSpaceCompressedId;
186
- }
187
- // No space in the cluster, return next local
212
+ // In sharding mode, always generate local IDs with stride-based allocation.
213
+ // Eager finals are disabled because shards backfill IDs not in their stride cycle.
214
+ // If another shard generated them as eager finals, ingesting IDs from another shard
215
+ // would fail depending on timing.
216
+ if (this.shardingState !== undefined) {
217
+ this.telemetryLocalIdCount++;
218
+ const { currentStride } = this.shardingState;
219
+ this.normalizer.addLocalRange(this.localGenCount + 1, currentStride);
220
+ this.localGenCount += currentStride;
221
+ return localIdFromGenCount(this.localGenCount);
222
+ }
223
+
224
+ // Normal ID generation
225
+ this.localGenCount++;
226
+ const lastCluster = this.localSession.getLastCluster();
227
+ if (lastCluster === undefined) {
188
228
  this.telemetryLocalIdCount++;
189
229
  return this.generateNextLocalId();
190
230
  }
231
+
232
+ // If there exists a cluster of final IDs already claimed by the local session that still has room in it,
233
+ // it is known prior to range sequencing what a local ID's corresponding final ID will be.
234
+ // In this case, it is safe to return the final ID immediately. This is guaranteed to be safe because
235
+ // any op that the local session sends that contains one of those final IDs are guaranteed to arrive to
236
+ // collaborators *after* the one containing the creation range.
237
+ const clusterOffset = this.localGenCount - genCountFromLocalId(lastCluster.baseLocalId);
238
+ if (lastCluster.capacity > clusterOffset) {
239
+ this.telemetryEagerFinalIdCount++;
240
+ // Space in the cluster: eager final
241
+ return ((lastCluster.baseFinalId as number) + clusterOffset) as SessionSpaceCompressedId;
242
+ }
243
+ // No space in the cluster, return next local
244
+ this.telemetryLocalIdCount++;
245
+ return this.generateNextLocalId();
191
246
  }
192
247
 
193
248
  public generateDocumentUniqueId():
@@ -210,6 +265,9 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
210
265
  * {@inheritdoc IIdCompressorCore.beginGhostSession}
211
266
  */
212
267
  public beginGhostSession(ghostSessionId: SessionId, ghostSessionCallback: () => void): void {
268
+ if (this.shardingState) {
269
+ throw new Error("Cannot start a ghost session while sharded.");
270
+ }
213
271
  this.startGhostSession(ghostSessionId);
214
272
  try {
215
273
  ghostSessionCallback();
@@ -218,6 +276,202 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
218
276
  }
219
277
  }
220
278
 
279
+ /**
280
+ * {@inheritdoc IIdCompressorCore.shard}
281
+ */
282
+ public shard(newShardCount: number): SerializedIdCompressorWithOngoingSession[] {
283
+ if (!Number.isSafeInteger(newShardCount) || newShardCount <= 0) {
284
+ throw new Error("Shard count must be a positive safe integer");
285
+ }
286
+ if (this.ongoingGhostSession) {
287
+ throw new Error("Cannot shard during ghost session");
288
+ }
289
+ if (this.writeVersion < SerializationVersion.V3) {
290
+ throw new Error(
291
+ `Sharding requires document version ${SerializationVersion.V3} or higher, but this document is version ${this.writeVersion}`,
292
+ );
293
+ }
294
+
295
+ const currentStride = this.shardingState?.currentStride ?? 1;
296
+ const newStride = currentStride * (newShardCount + 1);
297
+
298
+ // ID usage is exponential in stride count. Limit to practical size to avoid overflow.
299
+ if (newStride > MAX_STRIDE_LENGTH) {
300
+ throw new Error("Sharding limit reached.");
301
+ }
302
+ assert(Number.isSafeInteger(newStride), 0xd52 /* Shard stride must be a safe integer */);
303
+
304
+ if (this.shardingState === undefined) {
305
+ // First time sharding - initialize state
306
+ this.shardingState = {
307
+ currentStride: newStride,
308
+ originalStride: 1,
309
+ activeChildIds: new Set(),
310
+ };
311
+ } else {
312
+ this.shardingState.currentStride = newStride;
313
+ }
314
+
315
+ // Cache to avoid repeating per child
316
+ const serialized = this.serialize(true);
317
+ const { localGenCount: parentGenCount } = this;
318
+
319
+ const shards: SerializedIdCompressorWithOngoingSession[] = [];
320
+
321
+ // Children are positioned at consecutive positions within the parents current stride cycle
322
+ // e.g. if parent owns the "evens" space (-2, -4, -6, ...) and is sharded into 3 (two children)
323
+ // while at -8, then the child shards will start at -10 and -12 with a stride of 6
324
+ for (let i = 1; i <= newShardCount; i++) {
325
+ const childShardId = createSessionId();
326
+ this.shardingState.activeChildIds.add(childShardId);
327
+
328
+ const childLocalGenCount = parentGenCount + i * currentStride;
329
+
330
+ // This code results in a double roundtrip, but is probably plenty fast enough for scenarios that need sharding,
331
+ // as they necessarily have process boundaries to deal with.
332
+ // If this ever became an issue we can implement a fast clone of compressors
333
+ const child = deserializeIdCompressor(serialized, this.writeVersion) as IdCompressor;
334
+ const genCountJump = childLocalGenCount - parentGenCount;
335
+ assert(
336
+ child.localGenCount === parentGenCount && genCountJump > 0,
337
+ 0xd53 /* Child offsets incorrectly calculated. */,
338
+ );
339
+
340
+ child.normalizer.addLocalRange(child.localGenCount + 1, genCountJump);
341
+ child.localGenCount = childLocalGenCount;
342
+ child.shardingState = {
343
+ currentStride: newStride,
344
+ originalStride: newStride,
345
+ activeChildIds: new Set(),
346
+ shardId: childShardId,
347
+ };
348
+
349
+ shards.push(child.serialize(true));
350
+ }
351
+
352
+ return shards;
353
+ }
354
+ /**
355
+ * {@inheritdoc IIdCompressorCore.synchronizeWithShard}
356
+ */
357
+ public synchronizeWithShard(syncToken: ShardSynchronizationToken): void {
358
+ const isNowLeaf = this.synchronizeChild(syncToken);
359
+ // A disposal token additionally reclaims the child's ID space. If reclaiming the last child
360
+ // returns this compressor to a leaf and it is the root (originalStride === 1), exit sharding mode.
361
+ if (syncToken.disposed) {
362
+ assert(this.shardingState !== undefined, 0xd54 /* Must be sharded */);
363
+ if (isNowLeaf && this.shardingState.originalStride === 1) {
364
+ this.shardingState = undefined;
365
+ }
366
+ }
367
+ }
368
+
369
+ private synchronizeChild(syncToken: ShardToken): boolean {
370
+ const childDisposed = syncToken.disposed;
371
+ if (this.writeVersion < SerializationVersion.V3) {
372
+ throw new Error(
373
+ `Sharding requires document version ${SerializationVersion.V3} or higher, but this document is version ${this.writeVersion}`,
374
+ );
375
+ }
376
+ if (this.shardingState === undefined) {
377
+ throw new Error("Must be in sharding mode.");
378
+ }
379
+
380
+ const childShardId = syncToken.shardId;
381
+ const { activeChildIds, originalStride } = this.shardingState;
382
+
383
+ // Verify this child belongs to us
384
+ if (!activeChildIds.has(childShardId)) {
385
+ throw new Error(
386
+ `Cannot synchronize with child with ID ${childShardId}: not in active children set`,
387
+ );
388
+ }
389
+
390
+ // Only disposal removes the child from the active set. A plain synchronization leaves the
391
+ // child active so it can continue generating IDs and be synchronized with again later.
392
+ if (childDisposed) {
393
+ activeChildIds.delete(childShardId);
394
+ }
395
+
396
+ const isLeaf = activeChildIds.size === 0;
397
+ if (isLeaf && childDisposed) {
398
+ this.shardingState.currentStride = originalStride;
399
+ }
400
+
401
+ // Read currentStride after the possible stride reset above, so that a leaf-making disposal
402
+ // realigns the parent on its original (reclaimed) stride rather than the wider sharded stride.
403
+ const { currentStride } = this.shardingState;
404
+ const childGenCount = syncToken.localGenCount;
405
+
406
+ // Realign parent to next position in its sequence if child is ahead
407
+ if (childGenCount > this.localGenCount) {
408
+ // Find next aligned position in parent's sequence after child's genCount
409
+ const distance = childGenCount - this.localGenCount + 1;
410
+ const steps = Math.ceil(distance / currentStride);
411
+ const count = steps * currentStride;
412
+ this.normalizer.addLocalRange(this.localGenCount + 1, count);
413
+ this.localGenCount += count;
414
+ }
415
+
416
+ return isLeaf;
417
+ }
418
+
419
+ /**
420
+ * {@inheritdoc IIdCompressorCore.dispose}
421
+ */
422
+ public disposeShard(): ShardSynchronizationToken | undefined {
423
+ if (this.shardingState === undefined) {
424
+ return undefined;
425
+ } else {
426
+ if (this.shardingState.activeChildIds.size > 0) {
427
+ throw new Error("Cannot dispose a shard with active children.");
428
+ }
429
+ }
430
+
431
+ // A disposal token is a sync token that additionally signals reclamation of this shard's ID space.
432
+ const token = this.makeShardToken(true);
433
+
434
+ // Make this compressor unusable by replacing all methods with throwing stubs
435
+ // This is a one-time mutation that avoids runtime checks in every method
436
+ makeCompressorUnusable(this);
437
+
438
+ return token;
439
+ }
440
+
441
+ /**
442
+ * {@inheritdoc IIdCompressorCore.getShardSyncToken}
443
+ */
444
+ public getShardSyncToken(): ShardSynchronizationToken | undefined {
445
+ if (this.shardingState === undefined) {
446
+ return undefined;
447
+ }
448
+
449
+ return this.makeShardToken(false);
450
+ }
451
+
452
+ /**
453
+ * Builds a token describing this shard's current progress.
454
+ * @param disposed - Whether the token also signals disposal (reclamation of this shard's ID space).
455
+ */
456
+ private makeShardToken(disposed: boolean): ShardSynchronizationToken {
457
+ assert(this.shardingState !== undefined, 0xd55 /* Compressor is not sharded. */);
458
+
459
+ // Root cannot produce a token (shardId is undefined for root)
460
+ if (this.shardingState.shardId === undefined) {
461
+ throw new Error(
462
+ "Cannot get a token for a root compressor - only shards can be disposed",
463
+ );
464
+ }
465
+
466
+ const token: ShardToken = {
467
+ localGenCount: this.localGenCount,
468
+ shardId: this.shardingState.shardId,
469
+ disposed,
470
+ };
471
+
472
+ return token as unknown as ShardSynchronizationToken;
473
+ }
474
+
221
475
  private generateNextLocalId(): LocalCompressedId {
222
476
  // Must tell the normalizer that we generated a local ID
223
477
  this.normalizer.addLocalRange(this.localGenCount, 1);
@@ -225,6 +479,14 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
225
479
  }
226
480
 
227
481
  public takeNextCreationRange(): IdCreationRange {
482
+ // Child (non-root) shards must not submit their own creation ranges. All shards in a shard tree share a
483
+ // single session, and only the root shard is responsible for finalizing that session's IDs with the server.
484
+ // A child's generated IDs are reconciled with the root via shard synchronization tokens (see
485
+ // synchronizeWithShard / disposeShard), not by independently sending ranges, which would overlap and
486
+ // conflict with the root's ranges for the shared session.
487
+ if (this.shardingState?.shardId !== undefined) {
488
+ throw new Error("Cannot take a creation range from a non-root shard.");
489
+ }
228
490
  assert(
229
491
  !this.ongoingGhostSession,
230
492
  0x8a6 /* IdCompressor should not be operated normally when in a ghost session */,
@@ -577,11 +839,24 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
577
839
  sessionIndex++;
578
840
  }
579
841
  }
842
+ const sizeOfUuid = 2;
843
+ const shardingStateSize =
844
+ hasLocalState && this.writeVersion >= SerializationVersion.V3
845
+ ? this.shardingState === undefined
846
+ ? 1 // Boolean: no sharding state
847
+ : 1 + // Boolean: has sharding state
848
+ 2 + // Numbers: currentStride, originalStride (Float64)
849
+ 1 + // Number: activeChildIds count (Float64)
850
+ this.shardingState.activeChildIds.size * sizeOfUuid + // UUIDs: each is 128-bit
851
+ 1 + // Boolean: hasShardId
852
+ (this.shardingState.shardId === undefined ? 0 : sizeOfUuid) // UUID: 128-bit if present
853
+ : 0;
580
854
  const localStateSize = hasLocalState
581
855
  ? 1 + // generated ID count
582
856
  1 + // next range base genCount
583
857
  1 + // count of normalizer pairs
584
- normalizer.idRanges.size * 2 // pairs
858
+ normalizer.idRanges.size * 2 + // pairs
859
+ shardingStateSize // sharding state if present
585
860
  : 0;
586
861
  // Layout size, in 8 byte increments
587
862
  const totalSize =
@@ -589,14 +864,14 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
589
864
  1 + // hasLocalState
590
865
  1 + // session count
591
866
  1 + // cluster count
592
- sessionIndexMap.size * 2 + // session IDs
867
+ sessionIndexMap.size * sizeOfUuid + // session IDs
593
868
  finalSpace.clusters.length * 3 + // clusters: (sessionIndex, capacity, count)[]
594
869
  localStateSize; // local state, if present
595
870
 
596
871
  const serializedFloat = new Float64Array(totalSize);
597
872
  const serializedUint = new BigUint64Array(serializedFloat.buffer);
598
873
  let index = 0;
599
- index = writeNumber(serializedFloat, index, currentWrittenVersion);
874
+ index = writeNumber(serializedFloat, index, this.writeVersion);
600
875
  index = writeBoolean(serializedFloat, index, hasLocalState);
601
876
  index = writeNumber(serializedFloat, index, sessionIndexMap.size);
602
877
  index = writeNumber(serializedFloat, index, finalSpace.clusters.length);
@@ -623,6 +898,35 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
623
898
  index = writeNumber(serializedFloat, index, leadingGenCount);
624
899
  index = writeNumber(serializedFloat, index, count);
625
900
  }
901
+
902
+ // Write sharding state (version 3+)
903
+ if (this.writeVersion >= SerializationVersion.V3) {
904
+ index = writeBoolean(serializedFloat, index, this.shardingState !== undefined);
905
+ if (this.shardingState !== undefined) {
906
+ index = writeNumber(serializedFloat, index, this.shardingState.currentStride);
907
+ index = writeNumber(serializedFloat, index, this.shardingState.originalStride);
908
+
909
+ // Write activeChildIds Set as count + array of NumericUuids
910
+ index = writeNumber(serializedFloat, index, this.shardingState.activeChildIds.size);
911
+ for (const childId of this.shardingState.activeChildIds) {
912
+ index = writeNumericUuid(serializedUint, index, numericUuidFromStableId(childId));
913
+ }
914
+
915
+ // Write shardId (optional)
916
+ index = writeBoolean(
917
+ serializedFloat,
918
+ index,
919
+ this.shardingState.shardId !== undefined,
920
+ );
921
+ if (this.shardingState.shardId !== undefined) {
922
+ index = writeNumericUuid(
923
+ serializedUint,
924
+ index,
925
+ numericUuidFromStableId(this.shardingState.shardId),
926
+ );
927
+ }
928
+ }
929
+ }
626
930
  }
627
931
 
628
932
  assert(index === totalSize, 0x75b /* Serialized size was incorrectly calculated. */);
@@ -640,41 +944,45 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
640
944
  params:
641
945
  | {
642
946
  serialized: SerializedIdCompressorWithOngoingSession;
947
+ requestedWriteVersion: SerializationVersion;
643
948
  logger?: TelemetryLoggerExt | undefined;
644
949
  newSessionId?: never;
645
950
  }
646
951
  | {
647
952
  serialized: SerializedIdCompressorWithNoSession;
648
953
  newSessionId: SessionId;
954
+ requestedWriteVersion: SerializationVersion;
649
955
  logger?: TelemetryLoggerExt | undefined;
650
956
  },
651
957
  ): IdCompressor {
652
- const { serialized, newSessionId, logger } = params;
958
+ const { serialized, newSessionId: sessionId, logger, requestedWriteVersion } = params;
653
959
  const buffer = stringToBuffer(serialized, "base64");
654
960
  const index: Index = {
655
961
  index: 0,
656
962
  bufferFloat: new Float64Array(buffer),
657
963
  bufferUint: new BigUint64Array(buffer),
658
964
  };
659
- const version = readNumber(index);
660
- switch (version) {
661
- case 1: {
662
- throw new Error("IdCompressor version 1.0 is no longer supported.");
663
- }
664
- case 2: {
665
- return IdCompressor.deserialize2_0(index, newSessionId, logger);
965
+ const serializedVersion = readNumber(index) as SerializationVersion;
966
+ // If requested version is < the serialized version, we must write the version serialized
967
+ // to avoid losing data.
968
+ const writeVersion = Math.max(
969
+ requestedWriteVersion,
970
+ serializedVersion,
971
+ ) as SerializationVersion;
972
+
973
+ switch (serializedVersion) {
974
+ case 2:
975
+ case 3: {
976
+ break;
666
977
  }
667
978
  default: {
979
+ if (serializedVersion === 1) {
980
+ throw new Error("IdCompressor version 1.0 is no longer supported.");
981
+ }
668
982
  throw new Error("Unknown IdCompressor serialized version.");
669
983
  }
670
984
  }
671
- }
672
985
 
673
- static deserialize2_0(
674
- index: Index,
675
- sessionId: SessionId | undefined,
676
- logger: TelemetryLoggerExt | undefined,
677
- ): IdCompressor {
678
986
  const hasLocalState = readBoolean(index);
679
987
  const sessionCount = readNumber(index);
680
988
  const clusterCount = readNumber(index);
@@ -700,7 +1008,7 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
700
1008
  sessions.push([numeric, new Session(numeric)]);
701
1009
  }
702
1010
 
703
- const compressor = new IdCompressor(new Sessions(sessions), logger);
1011
+ const compressor = new IdCompressor(new Sessions(sessions), logger, writeVersion);
704
1012
 
705
1013
  // Clusters
706
1014
  let baseFinalId = 0;
@@ -727,6 +1035,38 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
727
1035
  for (let i = 0; i < normalizerCount; i++) {
728
1036
  compressor.normalizer.addLocalRange(readNumber(index), readNumber(index));
729
1037
  }
1038
+
1039
+ // Sharding state (version 3+)
1040
+ if (serializedVersion >= 3) {
1041
+ const hasShardingState = readBoolean(index);
1042
+ if (hasShardingState) {
1043
+ const currentStride = readNumber(index);
1044
+ const originalStride = readNumber(index);
1045
+
1046
+ // Read activeChildIds Set
1047
+ const activeChildCount = readNumber(index);
1048
+ const activeChildIds = new Set<SessionId>();
1049
+ for (let i = 0; i < activeChildCount; i++) {
1050
+ const childIdNumeric = readNumericUuid(index);
1051
+ // Safe cast: these were serialized as SessionIds
1052
+ activeChildIds.add(stableIdFromNumericUuid(childIdNumeric) as SessionId);
1053
+ }
1054
+
1055
+ // Read shardId (optional)
1056
+ const hasShardId = readBoolean(index);
1057
+ const shardId: SessionId | undefined = hasShardId
1058
+ ? (stableIdFromNumericUuid(readNumericUuid(index)) as SessionId)
1059
+ : undefined;
1060
+
1061
+ // Build sharding state object, conditionally including shardId
1062
+ compressor.shardingState = {
1063
+ currentStride,
1064
+ originalStride,
1065
+ activeChildIds,
1066
+ ...(shardId !== undefined && { shardId }),
1067
+ };
1068
+ }
1069
+ }
730
1070
  }
731
1071
 
732
1072
  assert(
@@ -747,6 +1087,34 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
747
1087
  ) {
748
1088
  return false;
749
1089
  }
1090
+ // Shard ownership affects future local ID allocation, even when the generated IDs match.
1091
+ // It is not part of finalized state, so ignore it when comparing only that state.
1092
+ if (includeLocalState) {
1093
+ const state = this.shardingState;
1094
+ const otherState = other.shardingState;
1095
+ if (state === undefined || otherState === undefined) {
1096
+ // Two unsharded compressors match here; a sharded and an unsharded compressor do not.
1097
+ if (state !== otherState) {
1098
+ return false;
1099
+ }
1100
+ } else {
1101
+ if (
1102
+ state.currentStride !== otherState.currentStride ||
1103
+ state.originalStride !== otherState.originalStride ||
1104
+ state.shardId !== otherState.shardId ||
1105
+ state.activeChildIds.size !== otherState.activeChildIds.size
1106
+ ) {
1107
+ return false;
1108
+ }
1109
+ // Child ownership is an unordered set. Equal sizes and membership imply equal sets,
1110
+ // regardless of insertion order or whether the state was deserialized.
1111
+ for (const childId of state.activeChildIds) {
1112
+ if (!otherState.activeChildIds.has(childId)) {
1113
+ return false;
1114
+ }
1115
+ }
1116
+ }
1117
+ }
750
1118
  return (
751
1119
  this.sessions.equals(other.sessions, includeLocalState) &&
752
1120
  this.finalSpace.equals(other.finalSpace)
@@ -756,73 +1124,94 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
756
1124
 
757
1125
  /**
758
1126
  * Create a new {@link IIdCompressor}.
759
- *
760
- * @legacy @beta
1127
+ * @param writeVersion - The version the compressor will write when serializing. Use SerializationVersion.V2 for base format, or SerializationVersion.V3 for sharding support.
1128
+ * @param logger - Optional telemetry logger.
1129
+ * @internal
761
1130
  */
762
- export function createIdCompressor(logger?: ITelemetryBaseLogger): IIdCompressor;
1131
+ export function createIdCompressor(
1132
+ writeVersion: SerializationVersion,
1133
+ logger?: ITelemetryBaseLogger,
1134
+ ): IIdCompressor & IIdCompressorCore;
763
1135
  /**
764
1136
  * Create a new {@link IIdCompressor}.
765
1137
  * @param sessionId - The seed ID for the compressor.
766
- *
767
- * @legacy @beta
1138
+ * @param writeVersion - The version the compressor will write when serializing. Use SerializationVersion.V2 for base format, or SerializationVersion.V3 for sharding support.
1139
+ * @param logger - Optional telemetry logger.
1140
+ * @internal
768
1141
  */
769
1142
  export function createIdCompressor(
770
1143
  sessionId: SessionId,
1144
+ writeVersion: SerializationVersion,
771
1145
  logger?: ITelemetryBaseLogger,
772
- ): IIdCompressor;
1146
+ ): IIdCompressor & IIdCompressorCore;
773
1147
  export function createIdCompressor(
774
- sessionIdOrLogger?: SessionId | ITelemetryBaseLogger,
1148
+ sessionIdOrDocumentVersion: SessionId | SerializationVersion,
1149
+ writeVersionOrLogger?: SerializationVersion | ITelemetryBaseLogger,
775
1150
  loggerOrUndefined?: ITelemetryBaseLogger,
776
1151
  ): IIdCompressor & IIdCompressorCore {
777
1152
  let localSessionId: SessionId;
1153
+ let writeVersion: SerializationVersion;
778
1154
  let logger: ITelemetryBaseLogger | undefined;
779
- if (sessionIdOrLogger === undefined) {
780
- localSessionId = createSessionId();
1155
+
1156
+ if (typeof sessionIdOrDocumentVersion === "string") {
1157
+ // Called with sessionId, writeVersion, logger?
1158
+ localSessionId = sessionIdOrDocumentVersion;
1159
+ writeVersion = writeVersionOrLogger as SerializationVersion;
1160
+ logger = loggerOrUndefined;
781
1161
  } else {
782
- if (typeof sessionIdOrLogger === "string") {
783
- localSessionId = sessionIdOrLogger;
784
- logger = loggerOrUndefined;
785
- } else {
786
- localSessionId = createSessionId();
787
- logger = sessionIdOrLogger;
788
- }
1162
+ // Called with writeVersion, logger?
1163
+ localSessionId = createSessionId();
1164
+ writeVersion = sessionIdOrDocumentVersion;
1165
+ logger = writeVersionOrLogger as ITelemetryBaseLogger | undefined;
789
1166
  }
1167
+
790
1168
  const compressor = new IdCompressor(
791
1169
  localSessionId,
792
1170
  logger === undefined ? undefined : createChildLogger({ logger }),
1171
+ writeVersion,
793
1172
  );
794
1173
  return compressor;
795
1174
  }
796
1175
 
797
1176
  /**
798
1177
  * Deserializes the supplied state into an ID compressor.
799
- *
800
- * @legacy @beta
1178
+ * @param serialized - The serialized compressor state with an ongoing session.
1179
+ * @param writeVersion - The version the compressor will write when serializing. This determines what features are available (e.g., V3 enables sharding support).
1180
+ * @param logger - Optional telemetry logger.
1181
+ * @internal
801
1182
  */
802
1183
  export function deserializeIdCompressor(
803
1184
  serialized: SerializedIdCompressorWithOngoingSession,
1185
+ writeVersion: SerializationVersion,
804
1186
  logger?: ITelemetryLoggerExt,
805
- ): IIdCompressor;
1187
+ ): IIdCompressor & IIdCompressorCore;
806
1188
  /**
807
1189
  * Deserializes the supplied state into an ID compressor.
808
- *
809
- * @legacy @beta
1190
+ * @param serialized - The serialized compressor state without a session.
1191
+ * @param newSessionId - The session ID to use for the deserialized compressor.
1192
+ * @param writeVersion - The version the compressor will write when serializing. This determines what features are available (e.g., V3 enables sharding support).
1193
+ * @param logger - Optional telemetry logger.
1194
+ * @internal
810
1195
  */
811
1196
  export function deserializeIdCompressor(
812
1197
  serialized: SerializedIdCompressorWithNoSession,
813
1198
  newSessionId: SessionId,
1199
+ writeVersion: SerializationVersion,
814
1200
  logger?: ITelemetryLoggerExt,
815
- ): IIdCompressor;
1201
+ ): IIdCompressor & IIdCompressorCore;
816
1202
  export function deserializeIdCompressor(
817
1203
  serialized: SerializedIdCompressor | SerializedIdCompressorWithNoSession,
818
- sessionIdOrLogger: SessionId | ITelemetryLoggerExt | undefined,
1204
+ sessionIdOrDocumentVersion: SessionId | SerializationVersion,
1205
+ writeVersionOrLogger?: SerializationVersion | ITelemetryLoggerExt,
819
1206
  loggerOrUndefined?: ITelemetryLoggerExt,
820
1207
  ): IIdCompressor & IIdCompressorCore {
821
- if (typeof sessionIdOrLogger === "string") {
1208
+ if (typeof sessionIdOrDocumentVersion === "string") {
1209
+ // Called with (serialized, sessionId, writeVersion, logger?)
822
1210
  return IdCompressor.deserialize({
823
1211
  serialized: serialized as SerializedIdCompressorWithNoSession,
824
1212
  logger: extractTelemetryLoggerExt<{ PossiblyUndefined: true }>(loggerOrUndefined),
825
- newSessionId: sessionIdOrLogger,
1213
+ newSessionId: sessionIdOrDocumentVersion,
1214
+ requestedWriteVersion: writeVersionOrLogger as SerializationVersion,
826
1215
  });
827
1216
  }
828
1217
 
@@ -830,18 +1219,53 @@ export function deserializeIdCompressor(
830
1219
  loggerOrUndefined === undefined,
831
1220
  0xc2d /* logger would be in sessionIdOrLogger in this codepath */,
832
1221
  );
1222
+ // Called with (serialized, writeVersion, logger?)
833
1223
  return IdCompressor.deserialize({
834
1224
  serialized: serialized as SerializedIdCompressorWithOngoingSession,
835
- logger: extractTelemetryLoggerExt<{ PossiblyUndefined: true }>(sessionIdOrLogger),
1225
+ logger: extractTelemetryLoggerExt<{ PossiblyUndefined: true }>(
1226
+ writeVersionOrLogger as ITelemetryLoggerExt | undefined,
1227
+ ),
1228
+ requestedWriteVersion: sessionIdOrDocumentVersion,
836
1229
  });
837
1230
  }
838
1231
 
1232
+ /**
1233
+ * Throws an error indicating the compressor has been disposed.
1234
+ */
1235
+ const throwDisposed = (): never => {
1236
+ throw new Error(
1237
+ "This compressor has been disposed via disposeShard() and can no longer be used.",
1238
+ );
1239
+ };
1240
+
1241
+ /**
1242
+ * Makes an IdCompressor instance unusable by replacing all its methods with functions that throw.
1243
+ * This is called once when disposeShard() is invoked to make the compressor permanently unusable.
1244
+ * @param compressor - The compressor instance to make unusable
1245
+ */
1246
+ function makeCompressorUnusable(compressor: IdCompressor): void {
1247
+ // Get all method names from the prototype (where class methods live)
1248
+ const proto = Object.getPrototypeOf(compressor) as object;
1249
+ for (const key of Reflect.ownKeys(proto)) {
1250
+ if (key !== "constructor" && typeof Reflect.get(proto, key) === "function") {
1251
+ // Shadow the method only on this instance so other compressors remain usable.
1252
+ // Omitting `enumerable` keeps new overrides non-enumerable, like class methods.
1253
+ // Reflection supports string and symbol keys without adding an index signature.
1254
+ Object.defineProperty(compressor, key, {
1255
+ value: throwDisposed,
1256
+ configurable: true,
1257
+ writable: true,
1258
+ });
1259
+ }
1260
+ }
1261
+ }
1262
+
839
1263
  /**
840
1264
  * Serializes an ID compressor.
841
1265
  * @param compressor - The compressor to serialize.
842
1266
  * @param withSession - If true, the serialized state will include local session
843
1267
  * state (for stashing). If false, only finalized state is included (for summaries).
844
- * @legacy @beta
1268
+ * @internal
845
1269
  */
846
1270
  export function serializeIdCompressor(
847
1271
  compressor: IIdCompressor,
@@ -852,7 +1276,7 @@ export function serializeIdCompressor(
852
1276
  * @param compressor - The compressor to serialize.
853
1277
  * @param withSession - If true, the serialized state will include local session
854
1278
  * state (for stashing). If false, only finalized state is included (for summaries).
855
- * @legacy @beta
1279
+ * @internal
856
1280
  */
857
1281
  export function serializeIdCompressor(
858
1282
  compressor: IIdCompressor,