@liveblocks/core 3.24.1 → 3.24.3-livetextreplay

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -431,6 +431,11 @@ type UpdateTextOp = {
431
431
  readonly baseVersion: number;
432
432
  readonly version?: number;
433
433
  readonly ops: TextOperation[];
434
+ /** Server-only: accepted ops after the requested base version when reconciling. */
435
+ readonly history?: {
436
+ readonly version: number;
437
+ readonly ops: TextOperation[];
438
+ }[];
434
439
  };
435
440
  type DeleteCrdtOp = {
436
441
  readonly opId?: string;
@@ -970,6 +975,13 @@ type PrivateLiveTextApi = PrivateLiveNodeApi & {
970
975
  * the pending ops are re-expressed over the remote op in turn ("after"
971
976
  * order), keeping them in server coordinates at all times.
972
977
  *
978
+ * Reconnect replay: keep the pre-disconnect confirmed state and re-send only
979
+ * the in-flight op at that version. The server returns the intervening
980
+ * accepted operations with its acknowledgement, including the original op
981
+ * if it was already stored. Replaying that history through the normal OT
982
+ * paths rebases the pending edits exactly, without applying them twice to a
983
+ * snapshot. Queued edits stay queued until their predecessor is acknowledged.
984
+ *
973
985
  * @example
974
986
  * const text = new LiveText("Hello");
975
987
  * text.insert(5, " world");
@@ -1253,6 +1265,24 @@ type LiveNode = LiveStructure | LiveRegister<Json>;
1253
1265
  * value or a Live storage data structure (LiveMap, LiveList, etc.)
1254
1266
  */
1255
1267
  type LsonObject = Record<string, Lson | undefined>;
1268
+ /**
1269
+ * The Json object an Lson object serializes to: every value converted with
1270
+ * `ToJson`, optional keys staying optional.
1271
+ */
1272
+ type JsonObjectOf<O extends LsonObject> = {
1273
+ readonly [K in keyof O]: ToJson<Exclude<O[K], undefined>> | (undefined extends O[K] ? undefined : never);
1274
+ };
1275
+ /**
1276
+ * The Json value a plain Lson object serializes to: an opaque record collapses
1277
+ * to ReadonlyJsonObject, anything else is mapped key by key.
1278
+ *
1279
+ * Inferring V keeps this conditional resolvable, which ToJson's variance
1280
+ * depends on. Deferring it makes unrelated assignments fail, such as
1281
+ * LiveList<never> to Lson.
1282
+ */
1283
+ type JsonOfLsonObject<L extends LsonObject> = string extends keyof L ? L extends Record<string, infer V> ? Record<string, V> extends L ? [
1284
+ Json
1285
+ ] extends [V] ? ReadonlyJsonObject : JsonObjectOf<L> : JsonObjectOf<L> : JsonObjectOf<L> : JsonObjectOf<L>;
1256
1286
  /**
1257
1287
  * Helper type to convert any valid Lson type to the equivalent Json type.
1258
1288
  *
@@ -1268,13 +1298,9 @@ type LsonObject = Record<string, Lson | undefined>;
1268
1298
  * ToJson<LiveObject<{ a: number, b: LiveList<string>, c?: number }>>
1269
1299
  * // { readonly a: null, readonly b: readonly string[], readonly c?: number }
1270
1300
  */
1271
- type ToJson<L extends Lson | LsonObject> = L extends LiveList<infer I extends Lson> ? Lson extends I ? readonly ReadonlyJson[] : readonly ToJson<I>[] : L extends LiveObject<infer O extends LsonObject> ? LsonObject extends O ? ReadonlyJsonObject : {
1272
- readonly [K in keyof O]: ToJson<Exclude<O[K], undefined>> | (undefined extends O[K] ? undefined : never);
1273
- } : L extends LiveMap<infer KS extends string, infer V extends Lson> ? Lson extends V ? ReadonlyJsonObject : {
1301
+ type ToJson<L extends Lson | LsonObject> = L extends LiveList<infer I extends Lson> ? Lson extends I ? readonly ReadonlyJson[] : readonly ToJson<I>[] : L extends LiveObject<infer O extends LsonObject> ? JsonOfLsonObject<O> : L extends LiveMap<infer KS extends string, infer V extends Lson> ? Lson extends V ? ReadonlyJsonObject : {
1274
1302
  readonly [K in KS]: ToJson<V>;
1275
- } : L extends LiveText ? LiveTextData : L extends LiveFile ? LiveFileData : L extends LsonObject ? string extends keyof L ? ReadonlyJsonObject : {
1276
- readonly [K in keyof L]: ToJson<Exclude<L[K], undefined>> | (undefined extends L[K] ? undefined : never);
1277
- } : L extends Json ? L : never;
1303
+ } : L extends LiveText ? LiveTextData : L extends LiveFile ? LiveFileData : L extends LsonObject ? JsonOfLsonObject<L> : L extends Json ? L : never;
1278
1304
 
1279
1305
  /**
1280
1306
  * Read-only query surface over {@link UnacknowledgedOps}, handed to CRDTs so
@@ -1890,7 +1916,7 @@ type NotificationChannelSettings = {
1890
1916
  * Base definition of notification settings.
1891
1917
  * Plain means it's a simple object coming from the remote backend.
1892
1918
  *
1893
- * It's the raw settings object where somme channels cannot exists
1919
+ * It's the raw settings object where some channels cannot exists
1894
1920
  * because there are no notification kinds enabled on the dashboard.
1895
1921
  * And this object isn't yet proxied by the creator factory `createNotificationSettings`.
1896
1922
  */
@@ -3175,6 +3201,8 @@ type UpdatePresenceClientMsg<P extends JsonObject> = {
3175
3201
  type UpdateStorageClientMsg = {
3176
3202
  readonly type: ClientMsgCode.UPDATE_STORAGE;
3177
3203
  readonly ops: ClientWireOp[];
3204
+ /** Include authoritative history in text acknowledgements after a storage resync. */
3205
+ readonly includeTextHistory?: true;
3178
3206
  };
3179
3207
  type FetchStorageClientMsg = {
3180
3208
  readonly type: ClientMsgCode.FETCH_STORAGE;
package/dist/index.d.ts CHANGED
@@ -431,6 +431,11 @@ type UpdateTextOp = {
431
431
  readonly baseVersion: number;
432
432
  readonly version?: number;
433
433
  readonly ops: TextOperation[];
434
+ /** Server-only: accepted ops after the requested base version when reconciling. */
435
+ readonly history?: {
436
+ readonly version: number;
437
+ readonly ops: TextOperation[];
438
+ }[];
434
439
  };
435
440
  type DeleteCrdtOp = {
436
441
  readonly opId?: string;
@@ -970,6 +975,13 @@ type PrivateLiveTextApi = PrivateLiveNodeApi & {
970
975
  * the pending ops are re-expressed over the remote op in turn ("after"
971
976
  * order), keeping them in server coordinates at all times.
972
977
  *
978
+ * Reconnect replay: keep the pre-disconnect confirmed state and re-send only
979
+ * the in-flight op at that version. The server returns the intervening
980
+ * accepted operations with its acknowledgement, including the original op
981
+ * if it was already stored. Replaying that history through the normal OT
982
+ * paths rebases the pending edits exactly, without applying them twice to a
983
+ * snapshot. Queued edits stay queued until their predecessor is acknowledged.
984
+ *
973
985
  * @example
974
986
  * const text = new LiveText("Hello");
975
987
  * text.insert(5, " world");
@@ -1253,6 +1265,24 @@ type LiveNode = LiveStructure | LiveRegister<Json>;
1253
1265
  * value or a Live storage data structure (LiveMap, LiveList, etc.)
1254
1266
  */
1255
1267
  type LsonObject = Record<string, Lson | undefined>;
1268
+ /**
1269
+ * The Json object an Lson object serializes to: every value converted with
1270
+ * `ToJson`, optional keys staying optional.
1271
+ */
1272
+ type JsonObjectOf<O extends LsonObject> = {
1273
+ readonly [K in keyof O]: ToJson<Exclude<O[K], undefined>> | (undefined extends O[K] ? undefined : never);
1274
+ };
1275
+ /**
1276
+ * The Json value a plain Lson object serializes to: an opaque record collapses
1277
+ * to ReadonlyJsonObject, anything else is mapped key by key.
1278
+ *
1279
+ * Inferring V keeps this conditional resolvable, which ToJson's variance
1280
+ * depends on. Deferring it makes unrelated assignments fail, such as
1281
+ * LiveList<never> to Lson.
1282
+ */
1283
+ type JsonOfLsonObject<L extends LsonObject> = string extends keyof L ? L extends Record<string, infer V> ? Record<string, V> extends L ? [
1284
+ Json
1285
+ ] extends [V] ? ReadonlyJsonObject : JsonObjectOf<L> : JsonObjectOf<L> : JsonObjectOf<L> : JsonObjectOf<L>;
1256
1286
  /**
1257
1287
  * Helper type to convert any valid Lson type to the equivalent Json type.
1258
1288
  *
@@ -1268,13 +1298,9 @@ type LsonObject = Record<string, Lson | undefined>;
1268
1298
  * ToJson<LiveObject<{ a: number, b: LiveList<string>, c?: number }>>
1269
1299
  * // { readonly a: null, readonly b: readonly string[], readonly c?: number }
1270
1300
  */
1271
- type ToJson<L extends Lson | LsonObject> = L extends LiveList<infer I extends Lson> ? Lson extends I ? readonly ReadonlyJson[] : readonly ToJson<I>[] : L extends LiveObject<infer O extends LsonObject> ? LsonObject extends O ? ReadonlyJsonObject : {
1272
- readonly [K in keyof O]: ToJson<Exclude<O[K], undefined>> | (undefined extends O[K] ? undefined : never);
1273
- } : L extends LiveMap<infer KS extends string, infer V extends Lson> ? Lson extends V ? ReadonlyJsonObject : {
1301
+ type ToJson<L extends Lson | LsonObject> = L extends LiveList<infer I extends Lson> ? Lson extends I ? readonly ReadonlyJson[] : readonly ToJson<I>[] : L extends LiveObject<infer O extends LsonObject> ? JsonOfLsonObject<O> : L extends LiveMap<infer KS extends string, infer V extends Lson> ? Lson extends V ? ReadonlyJsonObject : {
1274
1302
  readonly [K in KS]: ToJson<V>;
1275
- } : L extends LiveText ? LiveTextData : L extends LiveFile ? LiveFileData : L extends LsonObject ? string extends keyof L ? ReadonlyJsonObject : {
1276
- readonly [K in keyof L]: ToJson<Exclude<L[K], undefined>> | (undefined extends L[K] ? undefined : never);
1277
- } : L extends Json ? L : never;
1303
+ } : L extends LiveText ? LiveTextData : L extends LiveFile ? LiveFileData : L extends LsonObject ? JsonOfLsonObject<L> : L extends Json ? L : never;
1278
1304
 
1279
1305
  /**
1280
1306
  * Read-only query surface over {@link UnacknowledgedOps}, handed to CRDTs so
@@ -1890,7 +1916,7 @@ type NotificationChannelSettings = {
1890
1916
  * Base definition of notification settings.
1891
1917
  * Plain means it's a simple object coming from the remote backend.
1892
1918
  *
1893
- * It's the raw settings object where somme channels cannot exists
1919
+ * It's the raw settings object where some channels cannot exists
1894
1920
  * because there are no notification kinds enabled on the dashboard.
1895
1921
  * And this object isn't yet proxied by the creator factory `createNotificationSettings`.
1896
1922
  */
@@ -3175,6 +3201,8 @@ type UpdatePresenceClientMsg<P extends JsonObject> = {
3175
3201
  type UpdateStorageClientMsg = {
3176
3202
  readonly type: ClientMsgCode.UPDATE_STORAGE;
3177
3203
  readonly ops: ClientWireOp[];
3204
+ /** Include authoritative history in text acknowledgements after a storage resync. */
3205
+ readonly includeTextHistory?: true;
3178
3206
  };
3179
3207
  type FetchStorageClientMsg = {
3180
3208
  readonly type: ClientMsgCode.FETCH_STORAGE;
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ var __export = (target, all) => {
6
6
 
7
7
  // src/version.ts
8
8
  var PKG_NAME = "@liveblocks/core";
9
- var PKG_VERSION = "3.24.1";
9
+ var PKG_VERSION = "3.24.3-livetextreplay";
10
10
  var PKG_FORMAT = "esm";
11
11
 
12
12
  // src/dupe-detection.ts
@@ -3628,11 +3628,11 @@ var HttpClient = class {
3628
3628
  const response = await this.#fetchPolyfill(url2, {
3629
3629
  ...options,
3630
3630
  headers: {
3631
- // These headers are default, but can be overriden by custom headers
3631
+ // These headers are default, but can be overridden by custom headers
3632
3632
  "Content-Type": "application/json; charset=utf-8",
3633
3633
  // Possible header overrides
3634
3634
  ...options?.headers,
3635
- // Cannot be overriden by custom headers
3635
+ // Cannot be overridden by custom headers
3636
3636
  Authorization: `Bearer ${getBearerTokenFromAuthValue(authValue)}`,
3637
3637
  "X-LB-Client": PKG_VERSION || "dev"
3638
3638
  }
@@ -3678,7 +3678,7 @@ var HttpClient = class {
3678
3678
  }
3679
3679
  /**
3680
3680
  * Makes a GET request and returns the raw response.
3681
- * Won't throw if the reponse is a non-2xx.
3681
+ * Won't throw if the response is a non-2xx.
3682
3682
  * @deprecated Ideally, use .get() instead.
3683
3683
  */
3684
3684
  async rawGet(endpoint, authValue, params, options) {
@@ -3686,7 +3686,7 @@ var HttpClient = class {
3686
3686
  }
3687
3687
  /**
3688
3688
  * Makes a POST request and returns the raw response.
3689
- * Won't throw if the reponse is a non-2xx.
3689
+ * Won't throw if the response is a non-2xx.
3690
3690
  * @deprecated Ideally, use .post() instead.
3691
3691
  */
3692
3692
  async rawPost(endpoint, authValue, body) {
@@ -3697,7 +3697,7 @@ var HttpClient = class {
3697
3697
  }
3698
3698
  /**
3699
3699
  * Makes a DELETE request and returns the raw response.
3700
- * Won't throw if the reponse is a non-2xx.
3700
+ * Won't throw if the response is a non-2xx.
3701
3701
  * @deprecated Ideally, use .delete() instead.
3702
3702
  */
3703
3703
  async rawDelete(endpoint, authValue) {
@@ -3705,14 +3705,14 @@ var HttpClient = class {
3705
3705
  }
3706
3706
  /**
3707
3707
  * Makes a GET request, and return the JSON response.
3708
- * Will throw if the reponse is a non-2xx.
3708
+ * Will throw if the response is a non-2xx.
3709
3709
  */
3710
3710
  async get(endpoint, authValue, params, options) {
3711
3711
  return await this.#fetch(endpoint, authValue, options, params);
3712
3712
  }
3713
3713
  /**
3714
3714
  * Makes a POST request, and return the JSON response.
3715
- * Will throw if the reponse is a non-2xx.
3715
+ * Will throw if the response is a non-2xx.
3716
3716
  */
3717
3717
  async post(endpoint, authValue, body, options, params) {
3718
3718
  return await this.#fetch(
@@ -3728,14 +3728,14 @@ var HttpClient = class {
3728
3728
  }
3729
3729
  /**
3730
3730
  * Makes a DELETE request, and return the JSON response.
3731
- * Will throw if the reponse is a non-2xx.
3731
+ * Will throw if the response is a non-2xx.
3732
3732
  */
3733
3733
  async delete(endpoint, authValue) {
3734
3734
  return await this.#fetch(endpoint, authValue, { method: "DELETE" });
3735
3735
  }
3736
3736
  /**
3737
3737
  * Makes a PUT request for a Blob body, and return the JSON response.
3738
- * Will throw if the reponse is a non-2xx.
3738
+ * Will throw if the response is a non-2xx.
3739
3739
  */
3740
3740
  async putBlob(endpoint, authValue, blob, params, options) {
3741
3741
  return await this.#fetch(
@@ -9850,6 +9850,9 @@ var LiveText = class _LiveText extends AbstractCrdt {
9850
9850
  /** Local edits made while an op is in flight; sent after the ack. */
9851
9851
  #queuedOps = [];
9852
9852
  #acceptedOps = [];
9853
+ /** Wait for replay history before applying post-snapshot remote ops. */
9854
+ #reconnecting = false;
9855
+ #bufferedRemoteOps = [];
9853
9856
  /**
9854
9857
  * Creates a new LiveText document.
9855
9858
  *
@@ -10152,8 +10155,6 @@ var LiveText = class _LiveText extends AbstractCrdt {
10152
10155
  #applyLocal(op, source) {
10153
10156
  const mutableOp = op;
10154
10157
  if (op.opId !== void 0 && op.opId === this.#inFlightOpId) {
10155
- this.#inFlightOps = [...this.#inFlightOps, ...this.#queuedOps];
10156
- this.#queuedOps = [];
10157
10158
  mutableOp.baseVersion = this.#version;
10158
10159
  mutableOp.ops = [...this.#inFlightOps];
10159
10160
  return { modified: false };
@@ -10192,6 +10193,9 @@ var LiveText = class _LiveText extends AbstractCrdt {
10192
10193
  }
10193
10194
  /** Server acknowledgement of our in-flight op. */
10194
10195
  #applyAck(op, source) {
10196
+ if (this.#reconnecting) {
10197
+ return this.#reconcileAck(op, source);
10198
+ }
10195
10199
  const ackedVersion = op.version ?? Math.max(this.#version, op.baseVersion + 1);
10196
10200
  const predicted = this.#inFlightOps;
10197
10201
  const opId = this.#inFlightOpId;
@@ -10214,7 +10218,9 @@ var LiveText = class _LiveText extends AbstractCrdt {
10214
10218
  node: this,
10215
10219
  version: ackedVersion,
10216
10220
  updates: rebuilt.changes,
10217
- source
10221
+ // A correction was not applied by the originating editor. It
10222
+ // must not be filtered out as an echo of a local edit.
10223
+ source: REMOTE
10218
10224
  }
10219
10225
  };
10220
10226
  }
@@ -10224,9 +10230,67 @@ var LiveText = class _LiveText extends AbstractCrdt {
10224
10230
  this.#flushQueued();
10225
10231
  return result;
10226
10232
  }
10233
+ /** Recover the missing server timeline, preserving the normal OT invariants. */
10234
+ #reconcileAck(op, source) {
10235
+ const history = nn(
10236
+ op.history,
10237
+ "LiveText replay acknowledgement requires history"
10238
+ );
10239
+ this.#reconnecting = false;
10240
+ const buffered = this.#bufferedRemoteOps;
10241
+ this.#bufferedRemoteOps = [];
10242
+ const changes = [];
10243
+ const collect = (result) => {
10244
+ if (result.modified && result.modified.type === "LiveText") {
10245
+ changes.push(...result.modified.updates);
10246
+ }
10247
+ };
10248
+ for (const entry of history) {
10249
+ if (entry.version <= this.#version) continue;
10250
+ if (entry.version === op.version) {
10251
+ collect(this.#applyAck(op, source));
10252
+ } else {
10253
+ collect(
10254
+ this.#applyRemote(
10255
+ {
10256
+ type: OpCode.UPDATE_TEXT,
10257
+ id: op.id,
10258
+ baseVersion: entry.version - 1,
10259
+ version: entry.version,
10260
+ ops: entry.ops
10261
+ },
10262
+ REMOTE
10263
+ )
10264
+ );
10265
+ }
10266
+ }
10267
+ if (this.#inFlightOpId === op.opId) {
10268
+ collect(this.#applyAck(op, source));
10269
+ }
10270
+ for (const remote of buffered) {
10271
+ collect(this.#applyRemote(remote, REMOTE));
10272
+ }
10273
+ return changes.length === 0 ? { modified: false } : {
10274
+ reverse: [],
10275
+ modified: {
10276
+ type: "LiveText",
10277
+ node: this,
10278
+ version: this.#version,
10279
+ updates: changes,
10280
+ source: REMOTE
10281
+ }
10282
+ };
10283
+ }
10227
10284
  /** An accepted op from another client (or a server-fabricated fix op). */
10228
10285
  #applyRemote(op, source) {
10229
10286
  const version = op.version ?? this.#version + 1;
10287
+ if (op.version !== void 0 && op.version <= this.#version) {
10288
+ return { modified: false };
10289
+ }
10290
+ if (this.#reconnecting) {
10291
+ this.#bufferedRemoteOps.push(op);
10292
+ return { modified: false };
10293
+ }
10230
10294
  this.#confirmed = applyTextOperationsToSegments(this.#confirmed, op.ops);
10231
10295
  const [overInFlight, inFlight] = transformTextOperationsX(
10232
10296
  op.ops,
@@ -10332,14 +10396,17 @@ var LiveText = class _LiveText extends AbstractCrdt {
10332
10396
  return { appliedOps, changes };
10333
10397
  }
10334
10398
  /**
10335
- * Reconcile this node against an authoritative storage snapshot (e.g.
10336
- * after a reconnect). The confirmed state and version are replaced by the
10337
- * snapshot's; pending (in-flight + queued) ops are preserved on top and
10338
- * will be re-sent by the offline-ops replay.
10399
+ * Reconcile against an authoritative snapshot. With pending edits, retain
10400
+ * the current document and confirmed version until replay history arrives:
10401
+ * a snapshot alone cannot re-express the queue over unseen operations.
10339
10402
  *
10340
10403
  * @internal
10341
10404
  */
10342
10405
  _resyncText(data, version, source) {
10406
+ if (this.#inFlightOpId !== void 0) {
10407
+ this.#reconnecting = true;
10408
+ return void 0;
10409
+ }
10343
10410
  this.#confirmed = dataToSegments(data);
10344
10411
  this.#version = version;
10345
10412
  this.#acceptedOps = [];
@@ -10369,6 +10436,8 @@ var LiveText = class _LiveText extends AbstractCrdt {
10369
10436
  this.#inFlightOpId = void 0;
10370
10437
  this.#inFlightOps = [];
10371
10438
  this.#queuedOps = [];
10439
+ this.#bufferedRemoteOps = [];
10440
+ this.#reconnecting = false;
10372
10441
  }
10373
10442
  #recordAccepted(version, ops, opId) {
10374
10443
  if (this.#acceptedOps.some((entry) => entry.version === version)) {
@@ -12537,7 +12606,8 @@ function createRoom(options, config) {
12537
12606
  const result = applyLocalOps(unackedOps);
12538
12607
  messages.push({
12539
12608
  type: ClientMsgCode.UPDATE_STORAGE,
12540
- ops: result.opsToEmit
12609
+ ops: result.opsToEmit,
12610
+ includeTextHistory: true
12541
12611
  });
12542
12612
  notify(result.updates);
12543
12613
  sendMessages(messages);
@@ -13721,7 +13791,7 @@ ${dumpPool(
13721
13791
  updateSubscriptionSettings,
13722
13792
  markInboxNotificationAsRead
13723
13793
  },
13724
- // Explictly make the internal field non-enumerable, to avoid aggressive
13794
+ // Explicitly make the internal field non-enumerable, to avoid aggressive
13725
13795
  // freezing when used with Immer
13726
13796
  kInternal,
13727
13797
  { enumerable: false }