edytor 3.0.0 → 5.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 (231) hide show
  1. package/README.md +2 -2
  2. package/dist/block/block.svelte.js +6 -21
  3. package/dist/block/inlineBlock.svelte.js +4 -6
  4. package/dist/clipboard/moveText.js +6 -8
  5. package/dist/cloudflare/DocumentRoom.d.ts +23 -12
  6. package/dist/cloudflare/DocumentRoom.js +10 -6
  7. package/dist/cloudflare/index.d.ts +1 -1
  8. package/dist/cloudflare/room/access.d.ts +8 -3
  9. package/dist/cloudflare/room/access.js +44 -20
  10. package/dist/cloudflare/room/admission.js +28 -5
  11. package/dist/cloudflare/room/comments.js +28 -1
  12. package/dist/cloudflare/room/context.d.ts +139 -129
  13. package/dist/cloudflare/room/context.js +11 -4
  14. package/dist/cloudflare/room/forged.d.ts +14 -0
  15. package/dist/cloudflare/room/forged.js +30 -2
  16. package/dist/cloudflare/room/frames.d.ts +4 -0
  17. package/dist/cloudflare/room/frames.js +5 -1
  18. package/dist/cloudflare/room/purge.d.ts +6 -3
  19. package/dist/cloudflare/room/purge.js +45 -19
  20. package/dist/cloudflare/room/replicas.d.ts +4 -2
  21. package/dist/cloudflare/room/replicas.js +8 -3
  22. package/dist/cloudflare/room/suggest.d.ts +7 -0
  23. package/dist/cloudflare/room/suggest.js +143 -0
  24. package/dist/cloudflare/room/updates.d.ts +3 -4
  25. package/dist/cloudflare/room/updates.js +6 -8
  26. package/dist/cloudflare/routeDocumentSocket.d.ts +10 -1
  27. package/dist/cloudflare/routeDocumentSocket.js +5 -3
  28. package/dist/components/Content.svelte +9 -1
  29. package/dist/crdt/anchors.js +3 -2
  30. package/dist/crdt/attribution/attribution.d.ts +34 -12
  31. package/dist/crdt/attribution/attribution.js +59 -21
  32. package/dist/crdt/attribution/block.d.ts +3 -3
  33. package/dist/crdt/attribution/block.js +7 -8
  34. package/dist/crdt/attribution/index.d.ts +1 -1
  35. package/dist/crdt/attribution/index.js +1 -1
  36. package/dist/crdt/data.d.ts +62 -31
  37. package/dist/crdt/data.js +211 -103
  38. package/dist/crdt/doc/capability.js +9 -13
  39. package/dist/crdt/doc/delete.d.ts +1 -1
  40. package/dist/crdt/doc/delete.js +13 -30
  41. package/dist/crdt/doc/funnel.d.ts +1 -1
  42. package/dist/crdt/doc/funnel.js +34 -26
  43. package/dist/crdt/doc/gate.d.ts +3 -3
  44. package/dist/crdt/doc/history.d.ts +31 -9
  45. package/dist/crdt/doc/history.js +204 -73
  46. package/dist/crdt/doc/layout.js +16 -27
  47. package/dist/crdt/doc/meta.js +17 -23
  48. package/dist/crdt/doc/moves.d.ts +1 -1
  49. package/dist/crdt/doc/moves.js +52 -32
  50. package/dist/crdt/doc/reads.d.ts +2 -2
  51. package/dist/crdt/doc/reads.js +20 -30
  52. package/dist/crdt/doc/seed.js +2 -2
  53. package/dist/crdt/doc/split-merge.js +12 -15
  54. package/dist/crdt/doc/steps.d.ts +25 -13
  55. package/dist/crdt/doc/steps.js +55 -125
  56. package/dist/crdt/doc/suggest.d.ts +167 -0
  57. package/dist/crdt/doc/suggest.js +579 -0
  58. package/dist/crdt/doc/types.d.ts +21 -9
  59. package/dist/crdt/document.d.ts +26 -6
  60. package/dist/crdt/document.js +40 -15
  61. package/dist/crdt/edytor-doc.d.ts +410 -390
  62. package/dist/crdt/edytor-doc.js +143 -95
  63. package/dist/crdt/engine-api.d.ts +39 -1
  64. package/dist/crdt/engine.d.ts +2 -2
  65. package/dist/crdt/engine.js +4 -5
  66. package/dist/crdt/flow.d.ts +2 -2
  67. package/dist/crdt/flow.js +29 -37
  68. package/dist/crdt/index.d.ts +134 -127
  69. package/dist/crdt/index.js +4 -1
  70. package/dist/crdt/operations.d.ts +12 -3
  71. package/dist/crdt/parts.d.ts +43 -0
  72. package/dist/crdt/parts.js +44 -0
  73. package/dist/crdt/placement/display.d.ts +6 -31
  74. package/dist/crdt/placement/display.js +41 -146
  75. package/dist/crdt/placement/keys.d.ts +63 -0
  76. package/dist/crdt/placement/keys.js +95 -0
  77. package/dist/crdt/placement/model.d.ts +119 -118
  78. package/dist/crdt/placement/model.js +242 -142
  79. package/dist/crdt/placement/resolve.d.ts +123 -35
  80. package/dist/crdt/placement/resolve.js +188 -153
  81. package/dist/crdt/placement/slots.d.ts +134 -0
  82. package/dist/crdt/placement/slots.js +152 -0
  83. package/dist/crdt/protocol.d.ts +1 -1
  84. package/dist/crdt/protocol.js +1 -1
  85. package/dist/crdt/protocols/auth.d.ts +11 -1
  86. package/dist/crdt/protocols/auth.js +17 -1
  87. package/dist/crdt/protocols/comments.d.ts +16 -1
  88. package/dist/crdt/protocols/comments.js +9 -1
  89. package/dist/crdt/providers/index.d.ts +6 -4
  90. package/dist/crdt/providers/index.js +3 -1
  91. package/dist/crdt/providers/room.d.ts +16 -2
  92. package/dist/crdt/providers/room.js +64 -17
  93. package/dist/crdt/providers/websocket.d.ts +6 -4
  94. package/dist/crdt/providers/websocket.js +14 -7
  95. package/dist/crdt/purge.d.ts +24 -9
  96. package/dist/crdt/purge.js +266 -126
  97. package/dist/crdt/restore.d.ts +4 -3
  98. package/dist/crdt/restore.js +27 -34
  99. package/dist/crdt/schema.d.ts +30 -21
  100. package/dist/crdt/schema.js +39 -23
  101. package/dist/crdt/storage.d.ts +1 -1
  102. package/dist/crdt/storage.js +1 -1
  103. package/dist/crdt/structs.d.ts +17 -15
  104. package/dist/crdt/structs.js +10 -22
  105. package/dist/crdt/text/index/cache.js +4 -0
  106. package/dist/crdt/text/index/claims.js +13 -23
  107. package/dist/crdt/text/index/fold.d.ts +2 -3
  108. package/dist/crdt/text/index/fold.js +194 -44
  109. package/dist/crdt/text/index/layout.d.ts +2 -4
  110. package/dist/crdt/text/index/layout.js +14 -30
  111. package/dist/crdt/text/index/placement.d.ts +26 -18
  112. package/dist/crdt/text/index/placement.js +420 -157
  113. package/dist/crdt/text/index/records.d.ts +3 -3
  114. package/dist/crdt/text/index/records.js +24 -31
  115. package/dist/crdt/text/index/shared.d.ts +11 -3
  116. package/dist/crdt/text/index/shared.js +29 -17
  117. package/dist/crdt/text/index/state.d.ts +21 -17
  118. package/dist/crdt/text/index/state.js +15 -17
  119. package/dist/crdt/text/index/streams.d.ts +10 -5
  120. package/dist/crdt/text/index/streams.js +78 -27
  121. package/dist/crdt/text/items.d.ts +10 -3
  122. package/dist/crdt/text/items.js +7 -0
  123. package/dist/crdt/text/model.d.ts +87 -62
  124. package/dist/crdt/text/model.js +91 -169
  125. package/dist/crdt/text/ranges.d.ts +1 -1
  126. package/dist/crdt/text/ranges.js +3 -3
  127. package/dist/crdt/text/rows.d.ts +13 -7
  128. package/dist/crdt/text/rows.js +24 -7
  129. package/dist/crdt/text/runs.d.ts +51 -42
  130. package/dist/crdt/text/runs.js +24 -7
  131. package/dist/crdt/text/streams.d.ts +26 -13
  132. package/dist/crdt/text/streams.js +41 -14
  133. package/dist/crdt/vendor/yjs/UPSTREAM.md +651 -21
  134. package/dist/crdt/vendor/yjs/dts/global.d.ts +2 -0
  135. package/dist/crdt/vendor/yjs/dts/index.d.ts +1 -1
  136. package/dist/crdt/vendor/yjs/dts/structs/AbstractStruct.d.ts +7 -0
  137. package/dist/crdt/vendor/yjs/dts/structs/Item.d.ts +197 -10
  138. package/dist/crdt/vendor/yjs/dts/utils/Doc.d.ts +6 -3
  139. package/dist/crdt/vendor/yjs/dts/utils/StructStore.d.ts +5 -18
  140. package/dist/crdt/vendor/yjs/dts/utils/Transaction.d.ts +15 -1
  141. package/dist/crdt/vendor/yjs/dts/utils/UpdateDecoder.d.ts +14 -6
  142. package/dist/crdt/vendor/yjs/dts/utils/UpdateEncoder.d.ts +33 -6
  143. package/dist/crdt/vendor/yjs/dts/utils/ids.d.ts +1 -65
  144. package/dist/crdt/vendor/yjs/dts/utils/marks.d.ts +6 -1
  145. package/dist/crdt/vendor/yjs/dts/ynode.d.ts +54 -7
  146. package/dist/crdt/vendor/yjs/global.d.ts +2 -0
  147. package/dist/crdt/vendor/yjs/src/index.js +1 -1
  148. package/dist/crdt/vendor/yjs/src/structs/AbstractStruct.js +10 -0
  149. package/dist/crdt/vendor/yjs/src/structs/Item.js +649 -51
  150. package/dist/crdt/vendor/yjs/src/utils/BlockSet.js +18 -9
  151. package/dist/crdt/vendor/yjs/src/utils/Doc.js +5 -3
  152. package/dist/crdt/vendor/yjs/src/utils/StructStore.js +5 -0
  153. package/dist/crdt/vendor/yjs/src/utils/Transaction.js +36 -13
  154. package/dist/crdt/vendor/yjs/src/utils/UpdateDecoder.js +88 -18
  155. package/dist/crdt/vendor/yjs/src/utils/UpdateEncoder.js +119 -32
  156. package/dist/crdt/vendor/yjs/src/utils/encoding.js +13 -1
  157. package/dist/crdt/vendor/yjs/src/utils/ids.js +7 -7
  158. package/dist/crdt/vendor/yjs/src/utils/marks.js +193 -8
  159. package/dist/crdt/vendor/yjs/src/utils/redo.js +1 -1
  160. package/dist/crdt/vendor/yjs/src/utils/transaction-helpers.js +1 -1
  161. package/dist/crdt/vendor/yjs/src/utils/updates.js +3 -3
  162. package/dist/crdt/vendor/yjs/src/ynode.js +199 -12
  163. package/dist/edytor.svelte.d.ts +1 -1
  164. package/dist/edytor.svelte.js +103 -75
  165. package/dist/events/beforeInputCommands.d.ts +2 -2
  166. package/dist/events/beforeInputCommands.js +2 -2
  167. package/dist/events/nativeInteractiveControl.d.ts +11 -1
  168. package/dist/events/nativeInteractiveControl.js +41 -1
  169. package/dist/events/onBeforeInput.js +3 -3
  170. package/dist/events/onFocus.js +9 -11
  171. package/dist/events/onPageHide.d.ts +3 -0
  172. package/dist/events/onPageHide.js +16 -0
  173. package/dist/export/forms.d.ts +50 -50
  174. package/dist/index.d.ts +4 -2
  175. package/dist/index.js +1 -1
  176. package/dist/labels.d.ts +8 -0
  177. package/dist/labels.js +6 -1
  178. package/dist/plugins/comments/CommentsController.svelte.d.ts +5 -1
  179. package/dist/plugins/comments/CommentsController.svelte.js +14 -4
  180. package/dist/plugins/find/search.js +2 -2
  181. package/dist/plugins/index.d.ts +1 -0
  182. package/dist/plugins/index.js +1 -0
  183. package/dist/plugins/suggestions/SuggestionBars.svelte +40 -23
  184. package/dist/plugins/suggestions/SuggestionBars.svelte.d.ts +3 -1
  185. package/dist/plugins/suggestions/SuggestionRuns.svelte +77 -0
  186. package/dist/plugins/suggestions/SuggestionRuns.svelte.d.ts +9 -0
  187. package/dist/plugins/suggestions/shared.svelte.d.ts +58 -0
  188. package/dist/plugins/suggestions/shared.svelte.js +421 -0
  189. package/dist/plugins/suggestions/suggestionsPlugin.d.ts +15 -4
  190. package/dist/selection/domSelection.d.ts +2 -0
  191. package/dist/selection/domSelection.js +6 -2
  192. package/dist/selection/pointer.svelte.js +75 -48
  193. package/dist/selection/replaceSelection.js +2 -6
  194. package/dist/selection/selection.svelte.d.ts +3 -3
  195. package/dist/selection/selection.svelte.js +39 -32
  196. package/dist/selection/selection.utils.d.ts +14 -0
  197. package/dist/selection/selection.utils.js +28 -3
  198. package/dist/session/commands.js +21 -11
  199. package/dist/session/composition.svelte.d.ts +5 -3
  200. package/dist/session/composition.svelte.js +21 -17
  201. package/dist/session/handles.d.ts +4 -17
  202. package/dist/session/handles.js +11 -37
  203. package/dist/session/navigation.js +6 -9
  204. package/dist/session/selection.d.ts +11 -5
  205. package/dist/session/selection.js +14 -48
  206. package/dist/session/suggestions.svelte.d.ts +38 -9
  207. package/dist/session/suggestions.svelte.js +37 -18
  208. package/dist/session/virtual.d.ts +1 -1
  209. package/dist/surface/attributes.d.ts +5 -0
  210. package/dist/surface/attributes.js +2 -2
  211. package/dist/surface/cells.d.ts +2 -26
  212. package/dist/surface/cells.js +2 -33
  213. package/dist/surface/observer.svelte.d.ts +6 -1
  214. package/dist/surface/observer.svelte.js +130 -83
  215. package/dist/surface/pin.svelte.d.ts +9 -9
  216. package/dist/surface/pin.svelte.js +15 -22
  217. package/dist/surface/projector.svelte.d.ts +15 -3
  218. package/dist/surface/projector.svelte.js +67 -22
  219. package/dist/text/text.svelte.js +14 -19
  220. package/dist/themes/notion.css +29 -0
  221. package/package.json +4 -2
  222. package/dist/crdt/placement/rank.d.ts +0 -87
  223. package/dist/crdt/placement/rank.js +0 -332
  224. package/dist/crdt/placement/source.d.ts +0 -43
  225. package/dist/crdt/placement/source.js +0 -66
  226. package/dist/crdt/text/delete-index.d.ts +0 -129
  227. package/dist/crdt/text/delete-index.js +0 -365
  228. package/dist/crdt/text/deletes.d.ts +0 -76
  229. package/dist/crdt/text/deletes.js +0 -362
  230. package/dist/crdt/text/index/anchored.d.ts +0 -17
  231. package/dist/crdt/text/index/anchored.js +0 -197
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  Edytor aims to be for Svelte what Slate.js is for React: a heavily customizable editor with an API to build any kind of collaborative rich text editor.
19
19
 
20
- > **3.0.** Edytor `3.0.0` is on npm as `edytor`: its API follows semantic versioning, and every change is listed in the changelog. It has no production track record yet: read the [limitations](https://edytor.dev/docs/reference/limitations) before you build on it. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
20
+ > **5.0.** Edytor `5.0.0` is on npm as `edytor`: its API follows semantic versioning, and every change is listed in the changelog. It has no production track record yet: read the [limitations](https://edytor.dev/docs/reference/limitations) before you build on it. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
21
21
 
22
22
  ## Features
23
23
 
@@ -40,7 +40,7 @@ Edytor aims to be for Svelte what Slate.js is for React: a heavily customizable
40
40
 
41
41
  ## Install
42
42
 
43
- Edytor `3.0.0` is on npm under `latest`:
43
+ Edytor `5.0.0` is on npm under `latest`:
44
44
 
45
45
  ```bash
46
46
  pnpm add edytor
@@ -1,5 +1,6 @@
1
1
  import { cleanups } from '../utils.js';
2
2
  import { Text } from '../text/text.svelte.js';
3
+ import { segmentAt } from '../crdt/parts.js';
3
4
  /** Mark an element inside a block's markup as non-editable chrome (`block.void`, a preview's too). */
4
5
  export const voidChrome = (node) => {
5
6
  node.setAttribute('data-edytor-void', `true`);
@@ -63,12 +64,9 @@ export class Block {
63
64
  get content() {
64
65
  this.#track();
65
66
  const handles = this.edytor.idToBlock;
66
- let ordinal = 0;
67
67
  return handles
68
68
  .parts(this.id)
69
- .map((part) => part.kind === 'text'
70
- ? handles.text(this.id, ordinal++)
71
- : handles.atom(this.id, part.item.id));
69
+ .map((part) => part.kind === 'text' ? handles.text(this.id, part.segOrd) : handles.atom(this.id, part.id));
72
70
  }
73
71
  get type() {
74
72
  if (this.isRoot)
@@ -299,23 +297,10 @@ export class Block {
299
297
  * last text's end).
300
298
  */
301
299
  textAtOffset = (offset) => {
302
- let at = 0;
303
- let hit = null;
304
- for (const part of this.content) {
305
- if (!(part instanceof Text)) {
306
- if (offset <= at)
307
- return hit;
308
- at += 1;
309
- }
310
- else if (offset <= at + part.length) {
311
- return { text: part, offset: Math.max(0, offset - at) };
312
- }
313
- else {
314
- hit = { text: part, offset: part.length };
315
- at += part.length;
316
- }
317
- }
318
- return hit;
300
+ this.#track();
301
+ const { start, end, segOrd } = segmentAt(this.edytor.idToBlock.parts(this.id), offset);
302
+ const text = this.edytor.idToBlock.text(this.id, segOrd);
303
+ return { text, offset: Math.min(Math.max(0, offset - start), end - start) };
319
304
  };
320
305
  /** The declared view state (`open` on a `details`) the block's last element held. */
321
306
  #viewState = [];
@@ -27,9 +27,9 @@ export class InlineBlock {
27
27
  get #item() {
28
28
  if ($effect.tracking())
29
29
  this.edytor.cells?.get(this.blockId);
30
- const parts = this.edytor.idToBlock.parts(this.blockId);
31
- const part = parts.find((part) => part.kind === 'inline' && part.item.id === this.id);
32
- return part?.kind === 'inline' ? part.item : undefined;
30
+ return this.edytor.idToBlock
31
+ .parts(this.blockId)
32
+ .find((part) => part.kind === 'inline' && part.id === this.id);
33
33
  }
34
34
  get selected() {
35
35
  return this.edytor.selection.selectedInlineBlock.has(this);
@@ -66,9 +66,8 @@ export class InlineBlock {
66
66
  node.dataset.edytorId = this.id;
67
67
  node.dataset.edytorInlineBlock = this.type;
68
68
  this.#attachedNodes.add(node);
69
- this.edytor.nodeToInlineBlock.set(node, this);
70
69
  this.node = node;
71
- const release = this.edytor.surface.register(node, 'atom', this.blockId);
70
+ const release = this.edytor.surface.register(node, 'atom', this.blockId, undefined, undefined, this);
72
71
  const selectInlineBlock = (event) => {
73
72
  if (event.button !== 0)
74
73
  return;
@@ -100,7 +99,6 @@ export class InlineBlock {
100
99
  if (this.node === node) {
101
100
  this.node = this.#attachedNodes.values().next().value;
102
101
  }
103
- this.edytor.nodeToInlineBlock.delete(node);
104
102
  release();
105
103
  }
106
104
  };
@@ -74,14 +74,12 @@ export const dropText = (edytor, drag, to) => {
74
74
  /** The text and offset an anchor resolves to. */
75
75
  const point = (anchor) => {
76
76
  const hit = anchor && facade.resolveAnchor(anchor);
77
- const at = hit && edytor.idToBlock.get(hit.blockId)?.textAtOffset(hit.offset);
78
- return at ? [at.text, at.offset] : [null, 0];
77
+ return hit && edytor.idToBlock.has(hit.blockId)
78
+ ? { block: hit.blockId, offset: hit.offset }
79
+ : null;
79
80
  };
80
- const [startText, startOffset] = point(placed.from);
81
- const [endText, endOffset] = point(placed.to);
82
- if (startText && endText)
83
- edytor.selection.selectText(startText, startOffset, endText, endOffset);
84
- else if (endText)
85
- edytor.selection.selectText(endText, endOffset);
81
+ const [from, at] = [point(placed.from), point(placed.to)];
82
+ if (at)
83
+ edytor.selection.selectAt(from ?? at, at);
86
84
  return true;
87
85
  };
@@ -106,6 +106,7 @@ import * as E from '../crdt/protocol.js';
106
106
  import type { PurgeReport } from '../crdt/purge.js';
107
107
  import { type HistoryEntry, type HistoryOptions, type KVLike, type R2BucketLike } from './history.js';
108
108
  import type { LockOptions } from './locks.js';
109
+ import type { DocumentAccessLevel } from './routeDocumentSocket.js';
109
110
  import type { CommitResult, ExportedBlocks, ImportReceipt, ImportRequest, LateEditBatch, MoveNamespace } from './move.js';
110
111
  import type { AwarenessEntry } from '../crdt/protocol.js';
111
112
  import type { DocumentSemanticsConfig, DocumentOperations, JSONBlock, JSONDoc, YDoc } from '../crdt/index.js';
@@ -121,9 +122,11 @@ export declare const DEFAULT_SAVE_AFTER = 2000;
121
122
  * Room quotas, each refused with `4413` (`quota: <name>`). The
122
123
  * document's size: what its records hold (uncompressed) and the updates the
123
124
  * engine holds waiting. An isolate has 128 MB, and the live document takes
124
- * 18 to 46 bytes of heap per stored byte (prose to one short line per
125
- * block, its index included; `bench/room-memory.mjs`): 2 MiB is
126
- * about 100 MB at worst. Raise it for documents of long text.
125
+ * 15 to 77 bytes of heap per stored byte in Node (prose to one short line
126
+ * per block, its index included; `bench/room-memory.mjs`; a Worker takes
127
+ * less): 2 MiB is about 150 to 160 MB at worst, a list of some 23,000 short
128
+ * blocks (1.25 MiB keeps that near 100 MB). Raise it for documents of long
129
+ * text, lower it for long lists of short blocks.
127
130
  */
128
131
  export declare const DEFAULT_MAX_DOCUMENT_BYTES: number;
129
132
  /** The share of the document quota past which the room logs its size, by default. */
@@ -256,7 +259,13 @@ export type SocketIdentity = {
256
259
  user: string;
257
260
  /** `null` until bound — by `authorize`, or by the socket's first presence entry. */
258
261
  replica: number | null;
262
+ /** It writes nothing to the document (a `read` or `comment` access). */
259
263
  readOnly: boolean;
264
+ /**
265
+ * Less than `write`, more than `read`: `suggest` writes only suggestions
266
+ * (`room.access.suggest`), `comment` only comments (`room.access.comment`).
267
+ */
268
+ access?: 'suggest' | 'comment';
260
269
  /**
261
270
  * When its credential expires (ms since the epoch, the room's clock):
262
271
  * the room closes it `4401` then (`room.access`). `null` or absent: never.
@@ -272,7 +281,7 @@ export type Attachment = SocketIdentity & {
272
281
  comments?: boolean;
273
282
  };
274
283
  export type Refusal = {
275
- reason: 'generation' | 'schema' | 'malformed' | 'container' | 'identity' | 'replica' | 'read-only' | 'waiting' | 'storage' | 'internal' | 'quota' | 'access' | 'presence' | 'comments' | 'mark' | 'forged' | 'denied' | 'orphan' | 'relayed' | 'history';
284
+ reason: 'generation' | 'schema' | 'malformed' | 'container' | 'identity' | 'replica' | 'read-only' | 'waiting' | 'storage' | 'internal' | 'quota' | 'access' | 'presence' | 'comments' | 'mark' | 'suggest-only' | 'forged' | 'denied' | 'orphan' | 'relayed' | 'history';
276
285
  detail: unknown;
277
286
  };
278
287
  /** A duration series: how many, their total and longest (ms; see {@link RoomMetrics}). */
@@ -364,7 +373,7 @@ export type RoomLogEntry = {
364
373
  user: string;
365
374
  } | ({
366
375
  edytor: 'purge';
367
- horizon: number;
376
+ horizon: number | null;
368
377
  bytes: number;
369
378
  } & PurgeReport) | {
370
379
  edytor: 'quota';
@@ -801,12 +810,14 @@ export declare class AttachedDocument {
801
810
  * The purge task, also over RPC (`room.purge.timing`): record an epoch
802
811
  * (the state vector, now) when the document changed since the last one,
803
812
  * purge what was deleted before the horizon — the newest epoch at least
804
- * `purgeAfterDays` old — when it is newer than the last purged, and
805
- * re-arm for the next epoch to pass it. Returns what the purge wrote, or
806
- * `null` when nothing was past the horizon.
813
+ * `purgeAfterDays` old — when it is newer than the last purged, with what
814
+ * the sessions whose history ended deleted (`hist.release.end`; with no
815
+ * epoch due, those alone, `horizon: null`), and re-arm for the next epoch
816
+ * to pass it. Returns what the purge wrote, or `null` when nothing was
817
+ * past the horizon and no session had ended.
807
818
  */
808
819
  purge(): (PurgeReport & {
809
- horizon: number;
820
+ horizon: number | null;
810
821
  }) | null;
811
822
  /** The room's counters, also over RPC — see {@link RoomMetrics}. */
812
823
  metrics(): RoomMetrics;
@@ -845,7 +856,7 @@ export declare class AttachedDocument {
845
856
  * socket `4403` (`access revoked`), as {@link closeUser}. Your `authorize` must decide the
846
857
  * same from then on. Returns how many sockets it changed.
847
858
  */
848
- setAccess(userId: string, access: 'write' | 'read' | 'none'): {
859
+ setAccess(userId: string, access: DocumentAccessLevel | 'none'): {
849
860
  sockets: number;
850
861
  };
851
862
  /** Is `ws` one of this document's sockets? */
@@ -968,7 +979,7 @@ export declare class DocumentRoom<Env = DocumentRoomEnv> extends DurableObject<E
968
979
  };
969
980
  /** Run the purge task now, also over RPC — see {@link AttachedDocument.purge}. */
970
981
  purge(): (PurgeReport & {
971
- horizon: number;
982
+ horizon: number | null;
972
983
  }) | null;
973
984
  /** Every comment thread, also over RPC — see {@link AttachedDocument.listComments}. */
974
985
  listComments(): CommentSnapshot;
@@ -979,7 +990,7 @@ export declare class DocumentRoom<Env = DocumentRoomEnv> extends DurableObject<E
979
990
  sockets: number;
980
991
  };
981
992
  /** Change a user's access on its sockets, also over RPC — see {@link AttachedDocument.setAccess}. */
982
- setAccess(userId: string, access: 'write' | 'read' | 'none'): {
993
+ setAccess(userId: string, access: DocumentAccessLevel | 'none'): {
983
994
  sockets: number;
984
995
  };
985
996
  /** Compaction, also over RPC. */
@@ -130,9 +130,11 @@ export const DEFAULT_SAVE_AFTER = 2000;
130
130
  * Room quotas, each refused with `4413` (`quota: <name>`). The
131
131
  * document's size: what its records hold (uncompressed) and the updates the
132
132
  * engine holds waiting. An isolate has 128 MB, and the live document takes
133
- * 18 to 46 bytes of heap per stored byte (prose to one short line per
134
- * block, its index included; `bench/room-memory.mjs`): 2 MiB is
135
- * about 100 MB at worst. Raise it for documents of long text.
133
+ * 15 to 77 bytes of heap per stored byte in Node (prose to one short line
134
+ * per block, its index included; `bench/room-memory.mjs`; a Worker takes
135
+ * less): 2 MiB is about 150 to 160 MB at worst, a list of some 23,000 short
136
+ * blocks (1.25 MiB keeps that near 100 MB). Raise it for documents of long
137
+ * text, lower it for long lists of short blocks.
136
138
  */
137
139
  export const DEFAULT_MAX_DOCUMENT_BYTES = 2 * 1024 * 1024;
138
140
  /** The share of the document quota past which the room logs its size, by default. */
@@ -480,9 +482,11 @@ export class AttachedDocument {
480
482
  * The purge task, also over RPC (`room.purge.timing`): record an epoch
481
483
  * (the state vector, now) when the document changed since the last one,
482
484
  * purge what was deleted before the horizon — the newest epoch at least
483
- * `purgeAfterDays` old — when it is newer than the last purged, and
484
- * re-arm for the next epoch to pass it. Returns what the purge wrote, or
485
- * `null` when nothing was past the horizon.
485
+ * `purgeAfterDays` old — when it is newer than the last purged, with what
486
+ * the sessions whose history ended deleted (`hist.release.end`; with no
487
+ * epoch due, those alone, `horizon: null`), and re-arm for the next epoch
488
+ * to pass it. Returns what the purge wrote, or `null` when nothing was
489
+ * past the horizon and no session had ended.
486
490
  */
487
491
  purge() {
488
492
  return this.room.purge.tick();
@@ -10,4 +10,4 @@ export { type PurgeReport } from '../crdt/purge.js';
10
10
  export { COMMENT_ROW_BYTES, COMMENT_STATUS, MAX_COMMENT_LENGTH, MAX_COMMENTS_PER_THREAD, MAX_THREADS, commentAnchors, commentBytes, decideComment, type ThreadComment, type CommentActor, type CommentChange, type CommentOutcome, type CommentRefusal, type CommentRequest, type CommentRun, type CommentSnapshot, type CommentThread } from '../crdt/protocols/comments.js';
11
11
  export { lockedBlocks, type LockOptions } from './locks.js';
12
12
  export { moveBlocksBetweenRooms, forwardLateEdits, DEFAULT_MOVE_GRACE_DAYS, type CommitResult, type ExportedBlocks, type ImportReceipt, type ImportRequest, type LateEdit, type LateEditBatch, type MoveDestination, type MovedState, type MoveNamespace, type MoveRoom } from './move.js';
13
- export { routeDocumentSocket, routeDocumentHistory, routeDocumentComments, requestedReplica, type AuthorizeDocumentSocket, type ExpiredCredential, type DocumentNamespace, type DocumentIdentity, type RouteDocumentOptions } from './routeDocumentSocket.js';
13
+ export { routeDocumentSocket, routeDocumentHistory, routeDocumentComments, requestedReplica, type AuthorizeDocumentSocket, type ExpiredCredential, type DocumentNamespace, type DocumentAccessLevel, type DocumentIdentity, type RouteDocumentOptions } from './routeDocumentSocket.js';
@@ -1,4 +1,5 @@
1
1
  import { type RoomContext } from './context.js';
2
+ import type { DocumentAccessLevel } from '../routeDocumentSocket.js';
2
3
  /** A Yjs client id (a non-negative safe integer), or `null`. */
3
4
  export declare const parseReplica: (raw: unknown) => number | null;
4
5
  export declare class RoomAccess {
@@ -11,15 +12,19 @@ export declare class RoomAccess {
11
12
  /** The open sockets of `user`. */
12
13
  private socketsOf;
13
14
  /** Close `sockets` with `code`, their departure announced, the change logged (`access`). */
14
- endAccess(sockets: WebSocket[], access: 'expired' | 'closed' | 'write' | 'none', code: number, reason: string): void;
15
+ endAccess(sockets: WebSocket[], access: 'expired' | 'closed' | 'none' | DocumentAccessLevel, code: number, reason: string): void;
15
16
  /** Revoke: close every socket of `userId` (`AttachedDocument.closeUser`). */
16
17
  closeUser(userId: string, code?: number, reason?: string): {
17
18
  sockets: number;
18
19
  };
19
20
  /** Close every socket of `userId`, logged as `access`. */
20
21
  private revoke;
21
- /** Change `userId`'s access on its open sockets (`AttachedDocument.setAccess`). */
22
- setAccess(userId: string, access: 'write' | 'read' | 'none'): {
22
+ /**
23
+ * Change `userId`'s access on its open sockets (`AttachedDocument.setAccess`):
24
+ * a downgrade to `read` in place, any other change by a `1012` close
25
+ * the provider redials (so `authorize` grants the new access).
26
+ */
27
+ setAccess(userId: string, access: DocumentAccessLevel | 'none'): {
23
28
  sockets: number;
24
29
  };
25
30
  /**
@@ -8,7 +8,7 @@
8
8
  import { CLOSE } from '../../crdt/providers/room.js';
9
9
  import { COMMENTS_HEADER, HISTORY_HEADER, HISTORY_KEY_HEADER, IDENTITY_HEADERS, PROBE_HEADER, SOCKET_TAG, closedSocket, noTimers } from './shared.js';
10
10
  import { STORAGE_FAILURE } from './context.js';
11
- import { readOnlyFrame, step1Frame } from './frames.js';
11
+ import { accessFrame, readOnlyFrame, step1Frame } from './frames.js';
12
12
  import { StorageFault } from './replicas.js';
13
13
  import { stateVector } from './updates.js';
14
14
  /** The longest close reason the WebSocket API accepts, in UTF-8 bytes. */
@@ -22,6 +22,14 @@ export const parseReplica = (raw) => {
22
22
  const n = typeof raw === 'string' && /^\d{1,16}$/.test(raw) ? Number(raw) : raw;
23
23
  return typeof n === 'number' && Number.isSafeInteger(n) && n >= 0 ? n : null;
24
24
  };
25
+ const LEVELS = ['read', 'comment', 'suggest', 'write'];
26
+ /** An access level as a socket's identity carries it (`readOnly`, and `access` between read and write). */
27
+ const levelOf = (level) => ({
28
+ readOnly: level === 'read' || level === 'comment',
29
+ ...((level === 'suggest' || level === 'comment') && { access: level })
30
+ });
31
+ /** A socket identity's level. */
32
+ const levelIn = (identity) => identity.access ?? (identity.readOnly ? 'read' : 'write');
25
33
  /** The user id `routeDocumentSocket` encoded into its header, or `null`. */
26
34
  const readUser = (raw) => {
27
35
  try {
@@ -40,14 +48,16 @@ const readIdentity = (headers) => {
40
48
  const expiresAt = rawExpires === null ? null : Number(rawExpires);
41
49
  if (!user || user.length > 256 || (rawReplica && replica === null))
42
50
  return null;
43
- if (access !== 'read' && access !== 'write')
51
+ if (!LEVELS.includes(access))
44
52
  return null;
45
53
  if (expiresAt !== null && !Number.isFinite(expiresAt))
46
54
  return null;
47
- const identity = { user, replica, readOnly: access === 'read' };
48
- if (expiresAt !== null)
49
- identity.expiresAt = expiresAt;
50
- return identity;
55
+ return {
56
+ user,
57
+ replica,
58
+ ...levelOf(access),
59
+ ...(expiresAt !== null && { expiresAt })
60
+ };
51
61
  };
52
62
  export class RoomAccess {
53
63
  room;
@@ -131,10 +141,20 @@ export class RoomAccess {
131
141
  room.refuseContainer(server);
132
142
  }
133
143
  else {
134
- // A read-only socket is never asked for its state: it has nothing
135
- // to give. It gets the read-only notice instead (not a refusal),
136
- // so its provider tracks nothing.
137
- room.send(server, identity.readOnly ? readOnlyFrame() : step1Frame(doc));
144
+ // Every socket is told its access as it joins (`room.access.suggest`),
145
+ // before anything else it reads. A read-only socket is never asked
146
+ // for its state: it has nothing to give. It gets the read-only
147
+ // notice instead (not a refusal), so its provider tracks nothing,
148
+ // and its level after it.
149
+ const level = levelIn(identity);
150
+ if (identity.readOnly) {
151
+ room.send(server, readOnlyFrame());
152
+ room.send(server, accessFrame(level));
153
+ }
154
+ else {
155
+ room.send(server, accessFrame(level));
156
+ room.send(server, step1Frame(doc));
157
+ }
138
158
  if (room.state.presence.size > 0)
139
159
  room.send(server, room.presence.snapshot());
140
160
  }
@@ -183,26 +203,30 @@ export class RoomAccess {
183
203
  return { sockets: sockets.length };
184
204
  });
185
205
  }
186
- /** Change `userId`'s access on its open sockets (`AttachedDocument.setAccess`). */
206
+ /**
207
+ * Change `userId`'s access on its open sockets (`AttachedDocument.setAccess`):
208
+ * a downgrade to `read` in place, any other change by a `1012` close
209
+ * the provider redials (so `authorize` grants the new access).
210
+ */
187
211
  setAccess(userId, access) {
188
212
  const room = this.room;
189
- if (access !== 'write' && access !== 'read' && access !== 'none') {
213
+ if (access !== 'none' && !LEVELS.includes(access)) {
190
214
  throw new RangeError(`setAccess: unknown access ${String(access)}`);
191
215
  }
192
216
  if (access === 'none')
193
217
  return this.revoke(userId, 'none', CLOSE.denied, 'access revoked');
194
218
  return noTimers(() => {
195
- const sockets = this.socketsOf(userId).filter(({ attachment }) => attachment.readOnly !== (access === 'read'));
196
- if (access === 'write') {
197
- this.endAccess(sockets.map(({ ws }) => ws), 'write', CLOSE.accessChanged, 'access changed');
198
- return { sockets: sockets.length };
199
- }
200
- for (const { ws, attachment } of sockets) {
219
+ const sockets = this.socketsOf(userId).filter(({ attachment }) => levelIn(attachment) !== access);
220
+ const inPlace = sockets.filter(({ attachment }) => access === 'read' && levelIn(attachment) === 'write');
221
+ const closed = sockets.filter((socket) => !inPlace.includes(socket));
222
+ this.endAccess(closed.map(({ ws }) => ws), access, CLOSE.accessChanged, 'access changed');
223
+ for (const { ws, attachment } of inPlace) {
201
224
  ws.serializeAttachment({ ...attachment, readOnly: true });
202
225
  room.send(ws, readOnlyFrame());
226
+ room.send(ws, accessFrame('read'));
203
227
  }
204
- if (sockets.length > 0) {
205
- room.note({ reason: 'access', detail: { user: userId, access, sockets: sockets.length } });
228
+ if (inPlace.length > 0) {
229
+ room.note({ reason: 'access', detail: { user: userId, access, sockets: inPlace.length } });
206
230
  }
207
231
  return { sockets: sockets.length };
208
232
  });
@@ -14,8 +14,9 @@ import * as E from '../../crdt/protocol.js';
14
14
  import { ChunkError, CLOSE } from '../../crdt/providers/room.js';
15
15
  import { MAX_WAITING_DELETES, noTimers } from './shared.js';
16
16
  import { STORAGE_FAILURE, now, sync, tally } from './context.js';
17
- import { attributionWrites, BINDING_PREFIX, forgedAttributionDeletes, forgedDeletes, forgedWriters } from './forged.js';
18
- import { MalformedFrame, decode, readOnlyDenialFrame, savedFrame, step1Frame, storedStep2, updateFrame } from './frames.js';
17
+ import { attributionWrites, BINDING_PREFIX, forgedAttributionDeletes, forgedDeletes, forgedWriters, selfBound } from './forged.js';
18
+ import { suggestOnly } from './suggest.js';
19
+ import { MalformedFrame, decode, readOnlyDenialFrame, savedFrame, suggestOnlyDenialFrame, step1Frame, storedStep2, updateFrame } from './frames.js';
19
20
  import { StorageFault } from './replicas.js';
20
21
  import { addIds, deletesUpdate, forgetWaiting, freedBy, heldDeletes, newWriters, pendingDeletes, rangeCount, releasedWaiting, replacedEntries, stateVector, storedStruct, unheldDeletes, withoutClients } from './updates.js';
21
22
  /**
@@ -350,7 +351,8 @@ export class Admission {
350
351
  }
351
352
  const sv = stateVector(doc);
352
353
  const orphans = new Set();
353
- const stripped = replicas.attribute(attachment, newWriters(decoded, sv), sv, orphans);
354
+ const bound = selfBound(doc, decoded.structs, attachment.user);
355
+ const stripped = replicas.attribute(attachment, newWriters(decoded, sv), sv, orphans, bound);
354
356
  // Per-writer block marks: only `n` writes or deletes `del.<n>` /
355
357
  // `wd.<n>`. A client writing another's is stripped whole, as a client
356
358
  // under another user's id is; a delete of another's mark is dropped.
@@ -374,6 +376,23 @@ export class Admission {
374
376
  detail: { user: attachment.user, writers: [...forgers], ranges: rangeCount(forgedMarks) }
375
377
  });
376
378
  }
379
+ // A user who may only suggest writes only suggestions (`room.access.suggest`).
380
+ const suggest = attachment.access === 'suggest'
381
+ ? suggestOnly(doc, decoded, stripped, (client) => mayDelete(client), attachment.replica)
382
+ : null;
383
+ if (suggest && (suggest.strip.size > 0 || !suggest.deletes.isEmpty())) {
384
+ for (const client of suggest.strip)
385
+ stripped.add(client);
386
+ room.note({
387
+ reason: 'suggest-only',
388
+ detail: {
389
+ user: attachment.user,
390
+ writers: [...suggest.strip],
391
+ ranges: rangeCount(suggest.deletes)
392
+ }
393
+ });
394
+ room.send(ws, suggestOnlyDenialFrame());
395
+ }
377
396
  // Attribution (`room.attribution.trust`): a replica binds only
378
397
  // itself, to its verified user, and a user writes only its own
379
398
  // profile. Another's entry is collected (its clock kept as a GC), a
@@ -405,9 +424,13 @@ export class Admission {
405
424
  detail: { user: attachment.user, ranges: rangeCount(overflow) }
406
425
  });
407
426
  }
408
- const dropped = overflow === null && forgedMarks.isEmpty() && forgedEntries.ids.isEmpty()
427
+ const suggestDeletes = suggest?.deletes ?? Y.createIdSet();
428
+ const dropped = overflow === null &&
429
+ forgedMarks.isEmpty() &&
430
+ forgedEntries.ids.isEmpty() &&
431
+ suggestDeletes.isEmpty()
409
432
  ? null
410
- : addIds(addIds(addIds(Y.createIdSet(), overflow ?? Y.createIdSet()), forgedMarks), forgedEntries.ids);
433
+ : addIds(addIds(addIds(addIds(Y.createIdSet(), overflow ?? Y.createIdSet()), forgedMarks), forgedEntries.ids), suggestDeletes);
411
434
  const admitted = stripped.size === 0 && dropped === null && attribution.collected.size === 0
412
435
  ? update
413
436
  : withoutClients(decoded, stripped, doc, dropped ?? undefined, attribution.collected);
@@ -213,6 +213,9 @@ export class RoomComments {
213
213
  this.broadcast(decided.change);
214
214
  if (decided.change.type === 'removed')
215
215
  this.#removeAnchors(decided.change.thread.id);
216
+ // A user who may comment but not write: the room writes the thread's anchor (`room.access.comment`).
217
+ if (decided.change.type === 'added' && actor.anchors && request.op === 'add')
218
+ this.#writeAnchors(decided.change.thread.id, request.anchors ?? []);
216
219
  room.log({
217
220
  edytor: 'comment',
218
221
  type: decided.change.type,
@@ -232,6 +235,27 @@ export class RoomComments {
232
235
  }
233
236
  return outcome;
234
237
  }
238
+ /** A new thread's anchor over `spans` (anchors), written by the room: one room transaction. */
239
+ #writeAnchors(id, spans) {
240
+ const room = this.room;
241
+ if (room.live === null || room.busy || spans.length === 0)
242
+ return;
243
+ try {
244
+ const mark = `${COMMENT_MARK}:${id}`;
245
+ room.transact((facade) => {
246
+ for (const { start, end } of spans) {
247
+ const [from, to] = [start, end].map((a) => facade.resolveAnchor(a));
248
+ if (from && to && from.blockId === to.blockId && to.offset > from.offset)
249
+ facade.formatRange(from.blockId, from.offset, to.offset - from.offset, {
250
+ [mark]: { id }
251
+ });
252
+ }
253
+ });
254
+ }
255
+ catch (error) {
256
+ room.log({ edytor: 'fault', reason: 'internal', detail: `comment anchor: ${String(error)}` });
257
+ }
258
+ }
235
259
  /** A removed thread's anchor: its marks leave the document, as one room transaction. */
236
260
  #removeAnchors(id) {
237
261
  const room = this.room;
@@ -322,9 +346,12 @@ export class RoomComments {
322
346
  const request = parseCommentRequest(json);
323
347
  if (request === null)
324
348
  return new Response('invalid request', { status: 400 });
349
+ // A user who may comment but not write: the room writes what its view may not (the anchor).
350
+ const anchors = identity.access === 'comment' || identity.access === 'suggest';
325
351
  const outcome = await this.apply(request, {
326
352
  user: identity.user,
327
- readOnly: identity.readOnly
353
+ readOnly: identity.readOnly && !anchors,
354
+ ...(anchors && { anchors })
328
355
  });
329
356
  if (outcome.status === 'refused')
330
357
  return new Response(outcome.message, { status: COMMENT_STATUS[outcome.reason] });