@yorkie-js/sdk 0.7.7 → 0.7.8

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/README.md CHANGED
@@ -10,7 +10,7 @@ To get started using Yorkie JavaScript SDK, see: https://yorkie.dev/docs/js-sdk
10
10
 
11
11
  ## Contributing
12
12
 
13
- See [CONTRIBUTING](CONTRIBUTING.md) for details on submitting patches and the contribution workflow.
13
+ See [CONTRIBUTING](../../CONTRIBUTING.md) for details on submitting patches and the contribution workflow.
14
14
 
15
15
  ## Contributors ✨
16
16
 
@@ -93,20 +93,30 @@ declare interface Attachable {
93
93
  /**
94
94
  * `AttachChannelOptions` are user-settable options used when attaching channels.
95
95
  */
96
- declare interface AttachChannelOptions {
96
+ export declare interface AttachChannelOptions {
97
97
  /**
98
- * `isRealtime` determines whether to automatically watch channel changes
99
- * and send heartbeats. If false (manual mode), the client must call sync()
100
- * explicitly to refresh the TTL.
101
- * Default is true for backward compatibility.
98
+ * `syncMode` selects how the channel keeps presence in sync with the server.
99
+ * Default is `SyncMode.Realtime`.
100
+ * - `SyncMode.Realtime`: open a watch stream and run the heartbeat. Required
101
+ * to receive broadcast events.
102
+ * - `SyncMode.Polling`: heartbeat-only. No watch stream is opened. The
103
+ * heartbeat refreshes TTL and brings the latest sessionCount. Recommended
104
+ * for large channels where broadcast is not needed.
105
+ * - `SyncMode.Manual`: no automatic activity. Caller must invoke `sync()`.
102
106
  */
103
- isRealtime?: boolean;
107
+ syncMode?: SyncMode;
108
+ /**
109
+ * `channelHeartbeatInterval` overrides the heartbeat interval (ms) for this
110
+ * attachment. If unset, mode-specific defaults apply: Polling=3000,
111
+ * Realtime=30000.
112
+ */
113
+ channelHeartbeatInterval?: number;
104
114
  }
105
115
 
106
116
  /**
107
117
  * `AttachOptions` are user-settable options used when attaching documents.
108
118
  */
109
- declare interface AttachOptions<R, P> {
119
+ export declare interface AttachOptions<R, P> {
110
120
  /**
111
121
  * `initialRoot` is the initial root of the document. It is used to
112
122
  * initialize the document. It is used when the fields are not set in the
@@ -121,6 +131,11 @@ declare interface AttachOptions<R, P> {
121
131
  * `syncMode` defines the synchronization mode of the document.
122
132
  */
123
133
  syncMode?: SyncMode;
134
+ /**
135
+ * `documentPollInterval` (ms) — only used when `syncMode` is `Polling`.
136
+ * Default: 3000.
137
+ */
138
+ documentPollInterval?: number;
124
139
  /**
125
140
  * `schema` is the schema of the document. It is used to validate the
126
141
  * document.
@@ -1039,6 +1054,17 @@ export declare class Client {
1039
1054
  * `changeSyncMode` changes the synchronization mode of the given document.
1040
1055
  */
1041
1056
  changeSyncMode<R, P extends Indexable>(doc: Document_2<R, P>, syncMode: SyncMode): Promise<Document_2<R, P>>;
1057
+ /**
1058
+ * `changeSyncMode` changes the synchronization mode of the given channel.
1059
+ */
1060
+ changeSyncMode(channel: Channel, syncMode: SyncMode): Promise<Channel>;
1061
+ private changeDocumentSyncMode;
1062
+ /**
1063
+ * `assertValidChannelSyncMode` rejects sync modes that are not valid for
1064
+ * channels. `RealtimePushOnly` and `RealtimeSyncOff` are document-only.
1065
+ */
1066
+ private assertValidChannelSyncMode;
1067
+ private changeChannelSyncMode;
1042
1068
  /**
1043
1069
  * `sync` pushes local changes of the attached documents to the server and
1044
1070
  * receives changes of the remote replica from the server then apply them to
@@ -1997,12 +2023,28 @@ declare class CRDTTree extends CRDTElement implements GCParent {
1997
2023
  * `edit` edits the tree with the given range and content.
1998
2024
  * If the content is undefined, the range will be removed.
1999
2025
  */
2000
- edit(range: [CRDTTreePos, CRDTTreePos], contents: Array<CRDTTreeNode> | undefined, splitLevel: number, editedAt: TimeTicket, issueTimeTicket: (() => TimeTicket) | undefined, versionVector?: VersionVector): [Array<TreeChange>, Array<GCPair>, DataSize, Array<CRDTTreeNode>, number];
2026
+ edit(range: [CRDTTreePos, CRDTTreePos], contents: Array<CRDTTreeNode> | undefined, splitLevel: number, editedAt: TimeTicket, issueTimeTicket: (() => TimeTicket) | undefined, versionVector?: VersionVector): [
2027
+ Array<TreeChange>,
2028
+ Array<GCPair>,
2029
+ DataSize,
2030
+ Array<CRDTTreeNode>,
2031
+ number,
2032
+ number,
2033
+ Set<string>
2034
+ ];
2001
2035
  /**
2002
2036
  * `editT` edits the given range with the given value.
2003
2037
  * This method uses indexes instead of a pair of TreePos for testing.
2004
2038
  */
2005
- editT(range: [number, number], contents: Array<CRDTTreeNode> | undefined, splitLevel: number, editedAt: TimeTicket, issueTimeTicket: () => TimeTicket): [Array<TreeChange>, Array<GCPair>, DataSize, Array<CRDTTreeNode>, number];
2039
+ editT(range: [number, number], contents: Array<CRDTTreeNode> | undefined, splitLevel: number, editedAt: TimeTicket, issueTimeTicket: () => TimeTicket): [
2040
+ Array<TreeChange>,
2041
+ Array<GCPair>,
2042
+ DataSize,
2043
+ Array<CRDTTreeNode>,
2044
+ number,
2045
+ number,
2046
+ Set<string>
2047
+ ];
2006
2048
  /**
2007
2049
  * `move` move the given source range to the given target range.
2008
2050
  */
@@ -3035,7 +3077,12 @@ declare class Document_2<R, P extends Indexable = Indexable> implements Attachab
3035
3077
  * `applySnapshot` applies the given snapshot into this document.
3036
3078
  */
3037
3079
  applySnapshot(serverSeq: bigint, snapshotVector: VersionVector, snapshot?: Uint8Array, clientSeq?: number): void;
3038
- private clearHistory;
3080
+ /**
3081
+ * `clearHistory` flushes both undo and redo stacks. This is used
3082
+ * after applying a snapshot or initialRoot so that setup operations
3083
+ * are not reachable via undo.
3084
+ */
3085
+ clearHistory(): void;
3039
3086
  /**
3040
3087
  * `applyChanges` applies the given changes into this document.
3041
3088
  */
@@ -6005,7 +6052,8 @@ declare interface SubscribeFn<T> {
6005
6052
  }
6006
6053
 
6007
6054
  /**
6008
- * `SyncMode` defines synchronization modes for the PushPullChanges API.
6055
+ * `SyncMode` defines synchronization modes for the PushPullChanges API
6056
+ * (documents) and the RefreshChannel heartbeat (channels).
6009
6057
  */
6010
6058
  export declare enum SyncMode {
6011
6059
  /**
@@ -6024,7 +6072,15 @@ export declare enum SyncMode {
6024
6072
  * `RealtimeSyncOff` mode indicates that changes are not automatically pushed or pulled,
6025
6073
  * but the watch stream is kept active.
6026
6074
  */
6027
- RealtimeSyncOff = "realtime-syncoff"
6075
+ RealtimeSyncOff = "realtime-syncoff",
6076
+ /**
6077
+ * `Polling` mode runs the sync loop without opening a watch stream.
6078
+ * - For Channel: heartbeat refreshes TTL and brings sessionCount.
6079
+ * - For Document: PushPullChanges runs at the polling interval. Remote
6080
+ * changes arrive on the next tick (latency = interval). Not suitable
6081
+ * for collaborative editing — use Realtime for that.
6082
+ */
6083
+ Polling = "polling"
6028
6084
  }
6029
6085
 
6030
6086
  /**
@@ -358,7 +358,7 @@ function isScalarZeroValue(type, value) {
358
358
  }
359
359
  }
360
360
  const IMPLICIT$3 = 2;
361
- const unsafeLocal = Symbol.for("reflect unsafe local");
361
+ const unsafeLocal = /* @__PURE__ */ Symbol.for("reflect unsafe local");
362
362
  function unsafeOneofCase(target, oneof) {
363
363
  const c = target[oneof.localName].case;
364
364
  if (c === void 0) {
@@ -584,7 +584,7 @@ function convertObjectValues(obj, fn) {
584
584
  }
585
585
  return ret;
586
586
  }
587
- const tokenZeroMessageField = Symbol();
587
+ const tokenZeroMessageField = /* @__PURE__ */ Symbol();
588
588
  const messagePrototypes = /* @__PURE__ */ new WeakMap();
589
589
  function createZeroMessage(desc) {
590
590
  let msg;
@@ -686,7 +686,7 @@ class FieldError extends Error {
686
686
  function isFieldError(arg) {
687
687
  return arg instanceof Error && errorNames.includes(arg.name) && "field" in arg && typeof arg.field == "function";
688
688
  }
689
- const symbol = Symbol.for("@bufbuild/protobuf/text-encoding");
689
+ const symbol = /* @__PURE__ */ Symbol.for("@bufbuild/protobuf/text-encoding");
690
690
  function getTextEncoding() {
691
691
  if (globalThis[symbol] == void 0) {
692
692
  const te = new globalThis.TextEncoder();
@@ -4054,7 +4054,7 @@ function readScalarField(msg, field, json) {
4054
4054
  msg.set(field, scalarValue);
4055
4055
  }
4056
4056
  }
4057
- const tokenIgnoredUnknownEnum = Symbol();
4057
+ const tokenIgnoredUnknownEnum = /* @__PURE__ */ Symbol();
4058
4058
  function readEnum(desc, json, ignoreUnknownFields, nullAsZeroValue) {
4059
4059
  if (json === null) {
4060
4060
  if (desc.typeName == "google.protobuf.NullValue") {
@@ -4080,7 +4080,7 @@ function readEnum(desc, json, ignoreUnknownFields, nullAsZeroValue) {
4080
4080
  }
4081
4081
  throw new Error(`cannot decode ${desc} from JSON: ${formatVal(json)}`);
4082
4082
  }
4083
- const tokenNull = Symbol();
4083
+ const tokenNull = /* @__PURE__ */ Symbol();
4084
4084
  function scalarFromJson(field, json, nullAsZeroValue) {
4085
4085
  if (json === null) {
4086
4086
  if (nullAsZeroValue) {
@@ -5901,6 +5901,8 @@ class YorkieError extends Error {
5901
5901
  this.message = message;
5902
5902
  this.toString = () => `[code=${this.code}]: ${this.message}`;
5903
5903
  }
5904
+ code;
5905
+ message;
5904
5906
  name = "YorkieError";
5905
5907
  stack;
5906
5908
  }
@@ -12530,8 +12532,10 @@ class CRDTTree extends CRDTElement {
12530
12532
  break;
12531
12533
  }
12532
12534
  if (next.parent && next.parent === toParent) {
12533
- collectFromLeft = next;
12534
- collectFromParent = toParent;
12535
+ if (toLeft !== toParent) {
12536
+ collectFromLeft = next;
12537
+ collectFromParent = toParent;
12538
+ }
12535
12539
  break;
12536
12540
  }
12537
12541
  current = next;
@@ -12594,6 +12598,7 @@ class CRDTTree extends CRDTElement {
12594
12598
  tokensToBeRemoved,
12595
12599
  editedAt
12596
12600
  );
12601
+ const mergeLevel = toBeMergedNodes.length;
12597
12602
  const pairs = [];
12598
12603
  for (const node of nodesToBeRemoved) {
12599
12604
  if (node.remove(editedAt)) {
@@ -12720,7 +12725,15 @@ class CRDTTree extends CRDTElement {
12720
12725
  }
12721
12726
  }
12722
12727
  }
12723
- return [changes, pairs, diff, nodesToBeRemoved, fromIdx];
12728
+ return [
12729
+ changes,
12730
+ pairs,
12731
+ diff,
12732
+ nodesToBeRemoved,
12733
+ fromIdx,
12734
+ mergeLevel,
12735
+ preTombstoned
12736
+ ];
12724
12737
  }
12725
12738
  /**
12726
12739
  * `editT` edits the given range with the given value.
@@ -13161,11 +13174,35 @@ class CRDTTree extends CRDTElement {
13161
13174
  return [prev, prev.isText ? TokenType.Text : TokenType.End];
13162
13175
  }
13163
13176
  }
13164
- function clearRemovedAt(node) {
13165
- traverseAll(node, (n) => {
13177
+ function cloneAndDropPreTombstoned(node, preTombstoned) {
13178
+ const clone = node.deepcopy();
13179
+ filterChildren(clone, preTombstoned);
13180
+ traverseAll(clone, (n) => {
13166
13181
  n.removedAt = void 0;
13167
- n.visibleSize = n.totalSize;
13182
+ if (n.isText) {
13183
+ n.visibleSize = n.value.length;
13184
+ n.totalSize = n.value.length;
13185
+ return;
13186
+ }
13187
+ let size = 0;
13188
+ for (const child of n._children) size += child.paddedSize();
13189
+ n.visibleSize = size;
13190
+ n.totalSize = size;
13168
13191
  });
13192
+ return clone;
13193
+ }
13194
+ function filterChildren(node, preTombstoned) {
13195
+ const all = node._children;
13196
+ if (!all) return;
13197
+ const kept = [];
13198
+ for (const child of all) {
13199
+ if (preTombstoned.has(child.id.toIDString())) {
13200
+ continue;
13201
+ }
13202
+ filterChildren(child, preTombstoned);
13203
+ kept.push(child);
13204
+ }
13205
+ node._children = kept;
13169
13206
  }
13170
13207
  class TreeEditOperation extends Operation {
13171
13208
  fromPos;
@@ -13237,7 +13274,15 @@ class TreeEditOperation extends Operation {
13237
13274
  this.toPos = tree.findPos(this.toIdx);
13238
13275
  }
13239
13276
  }
13240
- const [changes, pairs, diff, removedNodes, preEditFromIdx] = tree.edit(
13277
+ const [
13278
+ changes,
13279
+ pairs,
13280
+ diff,
13281
+ removedNodes,
13282
+ preEditFromIdx,
13283
+ mergeLevel,
13284
+ preTombstoned
13285
+ ] = tree.edit(
13241
13286
  [this.fromPos, this.toPos],
13242
13287
  this.contents?.map((content) => content.deepcopy()),
13243
13288
  this.splitLevel,
@@ -13272,7 +13317,13 @@ class TreeEditOperation extends Operation {
13272
13317
  let reverseOp;
13273
13318
  const isPureSplit = this.splitLevel > 0 && !this.contents?.length && removedNodes.length === 0;
13274
13319
  if (this.splitLevel === 0) {
13275
- reverseOp = this.toReverseOperation(tree, removedNodes, preEditFromIdx);
13320
+ reverseOp = this.toReverseOperation(
13321
+ tree,
13322
+ removedNodes,
13323
+ preEditFromIdx,
13324
+ preTombstoned,
13325
+ mergeLevel
13326
+ );
13276
13327
  } else if (isPureSplit) {
13277
13328
  reverseOp = this.toSplitReverseOperation(tree, preEditFromIdx);
13278
13329
  }
@@ -13310,7 +13361,7 @@ class TreeEditOperation extends Operation {
13310
13361
  * @param removedNodes - Nodes that were removed by this edit
13311
13362
  * @param preEditFromIdx - The from index captured BEFORE the edit
13312
13363
  */
13313
- toReverseOperation(tree, removedNodes, preEditFromIdx) {
13364
+ toReverseOperation(tree, removedNodes, preEditFromIdx, preTombstoned, mergeLevel) {
13314
13365
  if (this.redoSplitLevel !== void 0 && this.redoSplitLevel > 0) {
13315
13366
  const splitRedoFromPos = tree.findPos(preEditFromIdx);
13316
13367
  const splitRedoOp = TreeEditOperation.create(
@@ -13329,19 +13380,36 @@ class TreeEditOperation extends Operation {
13329
13380
  );
13330
13381
  return splitRedoOp;
13331
13382
  }
13383
+ if (mergeLevel && mergeLevel > 0) {
13384
+ const splitFromPos = tree.findPos(preEditFromIdx);
13385
+ const splitUndoOp = TreeEditOperation.create(
13386
+ this.getParentCreatedAt(),
13387
+ splitFromPos,
13388
+ splitFromPos,
13389
+ void 0,
13390
+ // no inserted content — split creates boundaries
13391
+ mergeLevel,
13392
+ // splitLevel = number of merged boundaries
13393
+ void 0,
13394
+ // executedAt assigned at undo time
13395
+ true,
13396
+ // isUndoOp
13397
+ preEditFromIdx,
13398
+ preEditFromIdx
13399
+ );
13400
+ return splitUndoOp;
13401
+ }
13332
13402
  const insertedContentSize = this.contents ? this.contents.reduce((sum, node) => sum + node.paddedSize(), 0) : 0;
13333
13403
  const maxNeededIdx = preEditFromIdx + insertedContentSize;
13334
13404
  if (maxNeededIdx > tree.getSize()) {
13335
13405
  return void 0;
13336
13406
  }
13337
13407
  const topLevelRemoved = removedNodes.filter(
13338
- (node) => !node.parent || !removedNodes.includes(node.parent)
13408
+ (node) => !preTombstoned.has(node.id.toIDString()) && (!node.parent || !removedNodes.includes(node.parent))
13339
13409
  );
13340
- const reverseContents = topLevelRemoved.length > 0 ? topLevelRemoved.map((n) => {
13341
- const clone = n.deepcopy();
13342
- clearRemovedAt(clone);
13343
- return clone;
13344
- }) : void 0;
13410
+ const reverseContents = topLevelRemoved.length > 0 ? topLevelRemoved.map(
13411
+ (n) => cloneAndDropPreTombstoned(n, preTombstoned)
13412
+ ) : void 0;
13345
13413
  const reverseFromPos = tree.findPos(preEditFromIdx);
13346
13414
  let reverseToPos;
13347
13415
  if (insertedContentSize > 0) {
@@ -20160,6 +20228,11 @@ class Document {
20160
20228
  }
20161
20229
  ]);
20162
20230
  }
20231
+ /**
20232
+ * `clearHistory` flushes both undo and redo stacks. This is used
20233
+ * after applying a snapshot or initialRoot so that setup operations
20234
+ * are not reachable via undo.
20235
+ */
20163
20236
  clearHistory() {
20164
20237
  this.internalHistory.clearRedo();
20165
20238
  this.internalHistory.clearUndo();
@@ -20619,10 +20692,7 @@ class Document {
20619
20692
  }
20620
20693
  const ops = isUndo ? this.internalHistory.popUndo() : this.internalHistory.popRedo();
20621
20694
  if (!ops) {
20622
- throw new YorkieError(
20623
- Code.ErrRefused,
20624
- `There is no operation to be ${isUndo ? "undone" : "redone"}`
20625
- );
20695
+ return;
20626
20696
  }
20627
20697
  this.ensureClone();
20628
20698
  const ctx = ChangeContext.create(
@@ -20715,6 +20785,8 @@ class Attachment {
20715
20785
  syncMode;
20716
20786
  changeEventReceived;
20717
20787
  lastHeartbeatTime;
20788
+ pollInterval;
20789
+ pollIntervalPinned;
20718
20790
  reconnectStreamDelay;
20719
20791
  cancelled;
20720
20792
  watchStream;
@@ -20722,13 +20794,15 @@ class Attachment {
20722
20794
  watchAbortController;
20723
20795
  syncPromise;
20724
20796
  _detaching = false;
20725
- constructor(reconnectStreamDelay, resource, resourceID, syncMode) {
20797
+ constructor(reconnectStreamDelay, resource, resourceID, syncMode, pollInterval = 0, pollIntervalPinned = false) {
20726
20798
  this.reconnectStreamDelay = reconnectStreamDelay;
20727
20799
  this.resource = resource;
20728
20800
  this.resourceID = resourceID;
20729
20801
  this.syncMode = syncMode;
20730
20802
  this.changeEventReceived = syncMode !== void 0 ? false : void 0;
20731
20803
  this.lastHeartbeatTime = Date.now();
20804
+ this.pollInterval = pollInterval;
20805
+ this.pollIntervalPinned = pollIntervalPinned;
20732
20806
  this.cancelled = false;
20733
20807
  }
20734
20808
  /**
@@ -20748,6 +20822,9 @@ class Attachment {
20748
20822
  if (this.syncMode === SyncMode.RealtimePushOnly) {
20749
20823
  return this.resource.hasLocalChanges();
20750
20824
  }
20825
+ if (this.syncMode === SyncMode.Polling) {
20826
+ return Date.now() - this.lastHeartbeatTime >= this.pollInterval;
20827
+ }
20751
20828
  return this.syncMode !== SyncMode.Manual && (this.resource.hasLocalChanges() || (this.changeEventReceived ?? false));
20752
20829
  }
20753
20830
  /**
@@ -20761,7 +20838,8 @@ class Attachment {
20761
20838
  if (this.syncMode === SyncMode.Manual) {
20762
20839
  return false;
20763
20840
  }
20764
- return Date.now() - this.lastHeartbeatTime >= heartbeatInterval;
20841
+ const interval = this.pollInterval > 0 ? this.pollInterval : heartbeatInterval;
20842
+ return Date.now() - this.lastHeartbeatTime >= interval;
20765
20843
  }
20766
20844
  /**
20767
20845
  * `updateHeartbeatTime` updates the last heartbeat time.
@@ -20840,6 +20918,16 @@ class Attachment {
20840
20918
  }
20841
20919
  }
20842
20920
  }
20921
+ /**
20922
+ * `resetCancelled` clears the cancelled flag so the watch loop can run again
20923
+ * after a previous cancellation (e.g., after changeSyncMode back to Realtime).
20924
+ * Caller must invoke `runWatchLoop` immediately after to claim the stream slot;
20925
+ * `doLoop`'s `if (this.watchStream)` guard prevents double-stream creation if a
20926
+ * delayed `onDisconnect` callback from the previously-cancelled stream races.
20927
+ */
20928
+ resetCancelled() {
20929
+ this.cancelled = false;
20930
+ }
20843
20931
  /**
20844
20932
  * `cancelWatchStream` cancels the watch stream.
20845
20933
  */
@@ -20874,7 +20962,7 @@ function createAuthInterceptor(apiKey, token) {
20874
20962
  };
20875
20963
  }
20876
20964
  const name = "@yorkie-js/sdk";
20877
- const version = "0.7.7";
20965
+ const version = "0.7.8";
20878
20966
  const pkg = {
20879
20967
  name,
20880
20968
  version
@@ -21157,6 +21245,7 @@ var SyncMode = /* @__PURE__ */ ((SyncMode2) => {
21157
21245
  SyncMode2["Realtime"] = "realtime";
21158
21246
  SyncMode2["RealtimePushOnly"] = "realtime-pushonly";
21159
21247
  SyncMode2["RealtimeSyncOff"] = "realtime-syncoff";
21248
+ SyncMode2["Polling"] = "polling";
21160
21249
  return SyncMode2;
21161
21250
  })(SyncMode || {});
21162
21251
  var ClientStatus = /* @__PURE__ */ ((ClientStatus2) => {
@@ -21169,6 +21258,7 @@ var ClientCondition = /* @__PURE__ */ ((ClientCondition2) => {
21169
21258
  ClientCondition2["WatchLoop"] = "WatchLoop";
21170
21259
  return ClientCondition2;
21171
21260
  })(ClientCondition || {});
21261
+ const DefaultPollingIntervalMs = 3e3;
21172
21262
  const DefaultClientOptions = {
21173
21263
  rpcAddr: "https://api.yorkie.dev",
21174
21264
  syncLoopDuration: 50,
@@ -21364,6 +21454,14 @@ class Client {
21364
21454
  doc.setActor(this.id);
21365
21455
  doc.update((_, p) => p.set(opts.initialPresence || {}));
21366
21456
  const syncMode = opts.syncMode ?? "realtime";
21457
+ if (opts.documentPollInterval !== void 0 && opts.documentPollInterval <= 0) {
21458
+ throw new YorkieError(
21459
+ Code.ErrInvalidArgument,
21460
+ "documentPollInterval must be greater than 0"
21461
+ );
21462
+ }
21463
+ const pollIntervalPinned = opts.documentPollInterval !== void 0;
21464
+ const pollInterval = pollIntervalPinned ? opts.documentPollInterval : syncMode === "polling" ? DefaultPollingIntervalMs : 0;
21367
21465
  return this.enqueueTask(async () => {
21368
21466
  try {
21369
21467
  const res = await this.rpcClient.attachDocument(
@@ -21393,10 +21491,12 @@ class Client {
21393
21491
  this.reconnectStreamDelay,
21394
21492
  doc,
21395
21493
  res.documentId,
21396
- syncMode
21494
+ syncMode,
21495
+ pollInterval,
21496
+ pollIntervalPinned
21397
21497
  )
21398
21498
  );
21399
- if (syncMode !== "manual") {
21499
+ if (syncMode !== "manual" && syncMode !== "polling") {
21400
21500
  await this.runWatchLoop(doc.getKey());
21401
21501
  }
21402
21502
  logger.info(`[AD] c:"${this.getKey()}" attaches d:"${doc.getKey()}"`);
@@ -21412,6 +21512,7 @@ class Client {
21412
21512
  }
21413
21513
  });
21414
21514
  }
21515
+ doc.clearHistory();
21415
21516
  return doc;
21416
21517
  } catch (err) {
21417
21518
  logger.error(`[AD] c:"${this.getKey()}" err :`, err);
@@ -21522,12 +21623,23 @@ class Client {
21522
21623
  channel.setSessionID(res.sessionId);
21523
21624
  channel.updateSessionCount(Number(res.sessionCount), 0);
21524
21625
  channel.applyStatus(ChannelStatus.Attached);
21525
- const syncMode = opts.isRealtime !== false ? "realtime" : "manual";
21626
+ const syncMode = opts.syncMode ?? "realtime";
21627
+ this.assertValidChannelSyncMode(syncMode);
21628
+ if (opts.channelHeartbeatInterval !== void 0 && opts.channelHeartbeatInterval <= 0) {
21629
+ throw new YorkieError(
21630
+ Code.ErrInvalidArgument,
21631
+ "channelHeartbeatInterval must be greater than 0"
21632
+ );
21633
+ }
21634
+ const pollIntervalPinned = opts.channelHeartbeatInterval !== void 0;
21635
+ const pollInterval = pollIntervalPinned ? opts.channelHeartbeatInterval : syncMode === "polling" ? DefaultPollingIntervalMs : this.channelHeartbeatInterval;
21526
21636
  const attachment = new Attachment(
21527
21637
  this.reconnectStreamDelay,
21528
21638
  channel,
21529
21639
  res.sessionId,
21530
- syncMode
21640
+ syncMode,
21641
+ pollInterval,
21642
+ pollIntervalPinned
21531
21643
  );
21532
21644
  channel.subscribe("local-broadcast", (event) => {
21533
21645
  const { topic, payload, options } = event;
@@ -21603,9 +21715,17 @@ class Client {
21603
21715
  return this.enqueueTask(task);
21604
21716
  }
21605
21717
  /**
21606
- * `changeSyncMode` changes the synchronization mode of the given document.
21718
+ * `changeSyncMode` changes the synchronization mode of the given resource.
21607
21719
  */
21608
- async changeSyncMode(doc, syncMode) {
21720
+ async changeSyncMode(resource, syncMode) {
21721
+ return this.enqueueTask(async () => {
21722
+ if (resource instanceof Channel2) {
21723
+ return this.changeChannelSyncMode(resource, syncMode);
21724
+ }
21725
+ return this.changeDocumentSyncMode(resource, syncMode);
21726
+ });
21727
+ }
21728
+ async changeDocumentSyncMode(doc, syncMode) {
21609
21729
  if (!this.isActive()) {
21610
21730
  throw new YorkieError(
21611
21731
  Code.ErrClientNotActivated,
@@ -21623,19 +21743,66 @@ class Client {
21623
21743
  if (prevSyncMode === syncMode) {
21624
21744
  return doc;
21625
21745
  }
21626
- attachment.changeSyncMode(syncMode);
21627
- if (syncMode === "manual") {
21746
+ if (syncMode === "manual" || syncMode === "polling") {
21628
21747
  attachment.cancelWatchStream();
21629
- return doc;
21630
21748
  }
21749
+ attachment.changeSyncMode(syncMode);
21631
21750
  if (syncMode === "realtime") {
21632
21751
  attachment.changeEventReceived = true;
21633
21752
  }
21634
- if (prevSyncMode === "manual") {
21753
+ if (!attachment.pollIntervalPinned) {
21754
+ attachment.pollInterval = syncMode === "polling" ? DefaultPollingIntervalMs : 0;
21755
+ }
21756
+ if ((prevSyncMode === "manual" || prevSyncMode === "polling") && syncMode !== "manual" && syncMode !== "polling") {
21757
+ attachment.resetCancelled();
21635
21758
  await this.runWatchLoop(doc.getKey());
21636
21759
  }
21637
21760
  return doc;
21638
21761
  }
21762
+ /**
21763
+ * `assertValidChannelSyncMode` rejects sync modes that are not valid for
21764
+ * channels. `RealtimePushOnly` and `RealtimeSyncOff` are document-only.
21765
+ */
21766
+ assertValidChannelSyncMode(syncMode) {
21767
+ if (syncMode !== "manual" && syncMode !== "realtime" && syncMode !== "polling") {
21768
+ throw new YorkieError(
21769
+ Code.ErrInvalidArgument,
21770
+ `invalid channel sync mode: ${syncMode}`
21771
+ );
21772
+ }
21773
+ }
21774
+ async changeChannelSyncMode(channel, syncMode) {
21775
+ if (!this.isActive()) {
21776
+ throw new YorkieError(
21777
+ Code.ErrClientNotActivated,
21778
+ `${this.key} is not active`
21779
+ );
21780
+ }
21781
+ const attachment = this.attachmentMap.get(channel.getKey());
21782
+ if (!attachment) {
21783
+ throw new YorkieError(
21784
+ Code.ErrNotAttached,
21785
+ `${channel.getKey()} is not attached`
21786
+ );
21787
+ }
21788
+ const prevSyncMode = attachment.syncMode;
21789
+ if (prevSyncMode === syncMode) {
21790
+ return channel;
21791
+ }
21792
+ this.assertValidChannelSyncMode(syncMode);
21793
+ if (prevSyncMode === "realtime") {
21794
+ attachment.cancelWatchStream();
21795
+ }
21796
+ attachment.changeSyncMode(syncMode);
21797
+ if (!attachment.pollIntervalPinned) {
21798
+ attachment.pollInterval = syncMode === "polling" ? DefaultPollingIntervalMs : syncMode === "realtime" ? this.channelHeartbeatInterval : 0;
21799
+ }
21800
+ if (syncMode === "realtime") {
21801
+ attachment.resetCancelled();
21802
+ await this.runWatchLoop(channel.getKey());
21803
+ }
21804
+ return channel;
21805
+ }
21639
21806
  /**
21640
21807
  * `sync` implementation that handles both Document and Channel.
21641
21808
  */
@@ -22480,6 +22647,7 @@ class Client {
22480
22647
  return doc;
22481
22648
  }
22482
22649
  doc.applyChangePack(respPack);
22650
+ attachment.updateHeartbeatTime();
22483
22651
  attachment.resource.publish([
22484
22652
  {
22485
22653
  type: DocEventType.SyncStatusChanged,