doc.go 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. package sharedspace
  2. /*
  3. SharedSpace collaborative documents
  4. Documents are revision-numbered text bodies designed for realtime
  5. co-editing without an OT/CRDT engine: every update is a
  6. compare-and-swap against the document's current revision. A client
  7. sends the full new content together with the revision it based its
  8. edit on; if the document moved on in the meantime the update is
  9. rejected with ErrRevisionConflict and the client re-fetches, rebases
  10. its edit and retries.
  11. Each accepted update computes a splice patch (common prefix / suffix
  12. diff) which is broadcast to live subscribers so other editors can
  13. apply the change in place. The server's content is authoritative:
  14. a client that detects a revision gap simply re-fetches the document,
  15. so convergence never depends on patch application.
  16. */
  17. import (
  18. "time"
  19. "unicode/utf8"
  20. )
  21. const (
  22. //DefaultMaxDocs is the number of documents one space can hold
  23. DefaultMaxDocs = 64
  24. //MaxDocLength is the maximum document length in runes (~256KB as
  25. //UTF-8, safely below the 512KB WebSocket frame limit)
  26. MaxDocLength = 262144
  27. maxDocNameLength = 128
  28. docHistoryEntries = 32 // recent revisions kept in memory per document
  29. )
  30. // Doc is one collaborative document. All fields are guarded by the owning
  31. // Space's mutex; consumers only ever see DocSnapshot copies.
  32. type Doc struct {
  33. id string
  34. name string
  35. creator string
  36. createdAt time.Time
  37. content string
  38. revision int64
  39. updatedAt time.Time
  40. updatedBy string
  41. history []DocRevision // ring of recent revisions, newest last
  42. }
  43. // DocPatch is a single splice edit: at rune offset Pos, delete Del runes and
  44. // insert Ins.
  45. type DocPatch struct {
  46. Pos int
  47. Del int
  48. Ins string
  49. }
  50. // DocRevision records one accepted update, for in-memory audit / undo.
  51. type DocRevision struct {
  52. Revision int64
  53. UpdatedBy string
  54. UpdatedAt time.Time
  55. Patch DocPatch
  56. }
  57. // DocSnapshot is an immutable copy of a document's state.
  58. type DocSnapshot struct {
  59. ID string
  60. Name string
  61. Creator string
  62. Content string
  63. Revision int64
  64. CreatedAt time.Time
  65. UpdatedAt time.Time
  66. UpdatedBy string
  67. }
  68. // snapshot builds a DocSnapshot. Callers must hold the owning Space's mutex.
  69. func (d *Doc) snapshot() *DocSnapshot {
  70. return &DocSnapshot{
  71. ID: d.id,
  72. Name: d.name,
  73. Creator: d.creator,
  74. Content: d.content,
  75. Revision: d.revision,
  76. CreatedAt: d.createdAt,
  77. UpdatedAt: d.updatedAt,
  78. UpdatedBy: d.updatedBy,
  79. }
  80. }
  81. // CreateDoc creates an empty document in the space. Requires post rights.
  82. func (s *Space) CreateDoc(requester string, name string) (*DocSnapshot, error) {
  83. if !s.CanPost(requester) {
  84. return nil, ErrPermissionDenied
  85. }
  86. name = clipString(name, maxDocNameLength)
  87. if name == "" {
  88. name = "Untitled document"
  89. }
  90. s.mu.Lock()
  91. if s.closed {
  92. s.mu.Unlock()
  93. return nil, ErrSpaceClosed
  94. }
  95. if len(s.docs) >= DefaultMaxDocs {
  96. s.mu.Unlock()
  97. return nil, ErrDocLimitReached
  98. }
  99. now := time.Now()
  100. doc := &Doc{
  101. id: randomID(itemIDBytes),
  102. name: name,
  103. creator: requester,
  104. createdAt: now,
  105. revision: 1,
  106. updatedAt: now,
  107. updatedBy: requester,
  108. }
  109. s.docs[doc.id] = doc
  110. snapshot := doc.snapshot()
  111. s.mu.Unlock()
  112. s.mgrPersistDoc(snapshot)
  113. s.emitEvent(&SpaceEvent{Kind: EventDocCreated, Doc: snapshot})
  114. return snapshot, nil
  115. }
  116. // GetDoc returns a snapshot of the document with the given ID.
  117. func (s *Space) GetDoc(docID string) (*DocSnapshot, bool) {
  118. s.mu.Lock()
  119. defer s.mu.Unlock()
  120. doc, ok := s.docs[docID]
  121. if !ok {
  122. return nil, false
  123. }
  124. return doc.snapshot(), true
  125. }
  126. // ListDocs returns snapshots of every document in the space, without their
  127. // content (Content is left empty to keep listings cheap).
  128. func (s *Space) ListDocs() []*DocSnapshot {
  129. s.mu.Lock()
  130. defer s.mu.Unlock()
  131. list := make([]*DocSnapshot, 0, len(s.docs))
  132. for _, doc := range s.docs {
  133. snapshot := doc.snapshot()
  134. snapshot.Content = ""
  135. list = append(list, snapshot)
  136. }
  137. return list
  138. }
  139. // DocCount returns the number of documents in the space.
  140. func (s *Space) DocCount() int {
  141. s.mu.Lock()
  142. defer s.mu.Unlock()
  143. return len(s.docs)
  144. }
  145. // UpdateDoc applies a compare-and-swap update: content replaces the document
  146. // body only when baseRevision matches the current revision, otherwise
  147. // ErrRevisionConflict is returned and the caller must re-fetch and rebase.
  148. // On success the revision increments and a doc-updated event carrying the
  149. // new snapshot and the splice patch is emitted.
  150. func (s *Space) UpdateDoc(requester string, docID string, baseRevision int64, content string) (*DocSnapshot, error) {
  151. if !s.CanPost(requester) {
  152. return nil, ErrPermissionDenied
  153. }
  154. if utf8.RuneCountInString(content) > MaxDocLength {
  155. return nil, ErrDocTooLarge
  156. }
  157. s.mu.Lock()
  158. if s.closed {
  159. s.mu.Unlock()
  160. return nil, ErrSpaceClosed
  161. }
  162. doc, ok := s.docs[docID]
  163. if !ok {
  164. s.mu.Unlock()
  165. return nil, ErrDocNotFound
  166. }
  167. if doc.revision != baseRevision {
  168. s.mu.Unlock()
  169. return nil, ErrRevisionConflict
  170. }
  171. patch := computeSplicePatch(doc.content, content)
  172. doc.content = content
  173. doc.revision++
  174. doc.updatedAt = time.Now()
  175. doc.updatedBy = requester
  176. doc.history = append(doc.history, DocRevision{
  177. Revision: doc.revision,
  178. UpdatedBy: requester,
  179. UpdatedAt: doc.updatedAt,
  180. Patch: patch,
  181. })
  182. if len(doc.history) > docHistoryEntries {
  183. doc.history = doc.history[len(doc.history)-docHistoryEntries:]
  184. }
  185. snapshot := doc.snapshot()
  186. s.mu.Unlock()
  187. s.mgrPersistDoc(snapshot)
  188. s.emitEvent(&SpaceEvent{Kind: EventDocUpdated, Doc: snapshot, Patch: &patch})
  189. return snapshot, nil
  190. }
  191. // DocHistory returns the recent revision log of a document (newest last).
  192. func (s *Space) DocHistory(docID string) ([]DocRevision, bool) {
  193. s.mu.Lock()
  194. defer s.mu.Unlock()
  195. doc, ok := s.docs[docID]
  196. if !ok {
  197. return nil, false
  198. }
  199. history := make([]DocRevision, len(doc.history))
  200. copy(history, doc.history)
  201. return history, true
  202. }
  203. // DeleteDoc removes a document. Only the document's creator, a space
  204. // manager, or the system may delete it.
  205. func (s *Space) DeleteDoc(requester string, docID string) error {
  206. s.mu.Lock()
  207. doc, ok := s.docs[docID]
  208. if !ok {
  209. s.mu.Unlock()
  210. return ErrDocNotFound
  211. }
  212. if requester != "" && requester != doc.creator && requester != s.Owner && s.members[requester] != RoleAdmin {
  213. s.mu.Unlock()
  214. return ErrPermissionDenied
  215. }
  216. delete(s.docs, docID)
  217. snapshot := doc.snapshot()
  218. s.mu.Unlock()
  219. s.mgrDeleteDocRecord(docID)
  220. s.emitEvent(&SpaceEvent{Kind: EventDocDeleted, Doc: snapshot})
  221. return nil
  222. }
  223. // computeSplicePatch derives the single splice (common prefix / suffix diff)
  224. // that turns oldStr into newStr, in rune offsets.
  225. func computeSplicePatch(oldStr string, newStr string) DocPatch {
  226. oldRunes := []rune(oldStr)
  227. newRunes := []rune(newStr)
  228. prefix := 0
  229. for prefix < len(oldRunes) && prefix < len(newRunes) && oldRunes[prefix] == newRunes[prefix] {
  230. prefix++
  231. }
  232. suffix := 0
  233. for suffix < len(oldRunes)-prefix && suffix < len(newRunes)-prefix &&
  234. oldRunes[len(oldRunes)-1-suffix] == newRunes[len(newRunes)-1-suffix] {
  235. suffix++
  236. }
  237. return DocPatch{
  238. Pos: prefix,
  239. Del: len(oldRunes) - prefix - suffix,
  240. Ins: string(newRunes[prefix : len(newRunes)-suffix]),
  241. }
  242. }