| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266 |
- package sharedspace
- /*
- SharedSpace collaborative documents
- Documents are revision-numbered text bodies designed for realtime
- co-editing without an OT/CRDT engine: every update is a
- compare-and-swap against the document's current revision. A client
- sends the full new content together with the revision it based its
- edit on; if the document moved on in the meantime the update is
- rejected with ErrRevisionConflict and the client re-fetches, rebases
- its edit and retries.
- Each accepted update computes a splice patch (common prefix / suffix
- diff) which is broadcast to live subscribers so other editors can
- apply the change in place. The server's content is authoritative:
- a client that detects a revision gap simply re-fetches the document,
- so convergence never depends on patch application.
- */
- import (
- "time"
- "unicode/utf8"
- )
- const (
- //DefaultMaxDocs is the number of documents one space can hold
- DefaultMaxDocs = 64
- //MaxDocLength is the maximum document length in runes (~256KB as
- //UTF-8, safely below the 512KB WebSocket frame limit)
- MaxDocLength = 262144
- maxDocNameLength = 128
- docHistoryEntries = 32 // recent revisions kept in memory per document
- )
- // Doc is one collaborative document. All fields are guarded by the owning
- // Space's mutex; consumers only ever see DocSnapshot copies.
- type Doc struct {
- id string
- name string
- creator string
- createdAt time.Time
- content string
- revision int64
- updatedAt time.Time
- updatedBy string
- history []DocRevision // ring of recent revisions, newest last
- }
- // DocPatch is a single splice edit: at rune offset Pos, delete Del runes and
- // insert Ins.
- type DocPatch struct {
- Pos int
- Del int
- Ins string
- }
- // DocRevision records one accepted update, for in-memory audit / undo.
- type DocRevision struct {
- Revision int64
- UpdatedBy string
- UpdatedAt time.Time
- Patch DocPatch
- }
- // DocSnapshot is an immutable copy of a document's state.
- type DocSnapshot struct {
- ID string
- Name string
- Creator string
- Content string
- Revision int64
- CreatedAt time.Time
- UpdatedAt time.Time
- UpdatedBy string
- }
- // snapshot builds a DocSnapshot. Callers must hold the owning Space's mutex.
- func (d *Doc) snapshot() *DocSnapshot {
- return &DocSnapshot{
- ID: d.id,
- Name: d.name,
- Creator: d.creator,
- Content: d.content,
- Revision: d.revision,
- CreatedAt: d.createdAt,
- UpdatedAt: d.updatedAt,
- UpdatedBy: d.updatedBy,
- }
- }
- // CreateDoc creates an empty document in the space. Requires post rights.
- func (s *Space) CreateDoc(requester string, name string) (*DocSnapshot, error) {
- if !s.CanPost(requester) {
- return nil, ErrPermissionDenied
- }
- name = clipString(name, maxDocNameLength)
- if name == "" {
- name = "Untitled document"
- }
- s.mu.Lock()
- if s.closed {
- s.mu.Unlock()
- return nil, ErrSpaceClosed
- }
- if len(s.docs) >= DefaultMaxDocs {
- s.mu.Unlock()
- return nil, ErrDocLimitReached
- }
- now := time.Now()
- doc := &Doc{
- id: randomID(itemIDBytes),
- name: name,
- creator: requester,
- createdAt: now,
- revision: 1,
- updatedAt: now,
- updatedBy: requester,
- }
- s.docs[doc.id] = doc
- snapshot := doc.snapshot()
- s.mu.Unlock()
- s.mgrPersistDoc(snapshot)
- s.emitEvent(&SpaceEvent{Kind: EventDocCreated, Doc: snapshot})
- return snapshot, nil
- }
- // GetDoc returns a snapshot of the document with the given ID.
- func (s *Space) GetDoc(docID string) (*DocSnapshot, bool) {
- s.mu.Lock()
- defer s.mu.Unlock()
- doc, ok := s.docs[docID]
- if !ok {
- return nil, false
- }
- return doc.snapshot(), true
- }
- // ListDocs returns snapshots of every document in the space, without their
- // content (Content is left empty to keep listings cheap).
- func (s *Space) ListDocs() []*DocSnapshot {
- s.mu.Lock()
- defer s.mu.Unlock()
- list := make([]*DocSnapshot, 0, len(s.docs))
- for _, doc := range s.docs {
- snapshot := doc.snapshot()
- snapshot.Content = ""
- list = append(list, snapshot)
- }
- return list
- }
- // DocCount returns the number of documents in the space.
- func (s *Space) DocCount() int {
- s.mu.Lock()
- defer s.mu.Unlock()
- return len(s.docs)
- }
- // UpdateDoc applies a compare-and-swap update: content replaces the document
- // body only when baseRevision matches the current revision, otherwise
- // ErrRevisionConflict is returned and the caller must re-fetch and rebase.
- // On success the revision increments and a doc-updated event carrying the
- // new snapshot and the splice patch is emitted.
- func (s *Space) UpdateDoc(requester string, docID string, baseRevision int64, content string) (*DocSnapshot, error) {
- if !s.CanPost(requester) {
- return nil, ErrPermissionDenied
- }
- if utf8.RuneCountInString(content) > MaxDocLength {
- return nil, ErrDocTooLarge
- }
- s.mu.Lock()
- if s.closed {
- s.mu.Unlock()
- return nil, ErrSpaceClosed
- }
- doc, ok := s.docs[docID]
- if !ok {
- s.mu.Unlock()
- return nil, ErrDocNotFound
- }
- if doc.revision != baseRevision {
- s.mu.Unlock()
- return nil, ErrRevisionConflict
- }
- patch := computeSplicePatch(doc.content, content)
- doc.content = content
- doc.revision++
- doc.updatedAt = time.Now()
- doc.updatedBy = requester
- doc.history = append(doc.history, DocRevision{
- Revision: doc.revision,
- UpdatedBy: requester,
- UpdatedAt: doc.updatedAt,
- Patch: patch,
- })
- if len(doc.history) > docHistoryEntries {
- doc.history = doc.history[len(doc.history)-docHistoryEntries:]
- }
- snapshot := doc.snapshot()
- s.mu.Unlock()
- s.mgrPersistDoc(snapshot)
- s.emitEvent(&SpaceEvent{Kind: EventDocUpdated, Doc: snapshot, Patch: &patch})
- return snapshot, nil
- }
- // DocHistory returns the recent revision log of a document (newest last).
- func (s *Space) DocHistory(docID string) ([]DocRevision, bool) {
- s.mu.Lock()
- defer s.mu.Unlock()
- doc, ok := s.docs[docID]
- if !ok {
- return nil, false
- }
- history := make([]DocRevision, len(doc.history))
- copy(history, doc.history)
- return history, true
- }
- // DeleteDoc removes a document. Only the document's creator, a space
- // manager, or the system may delete it.
- func (s *Space) DeleteDoc(requester string, docID string) error {
- s.mu.Lock()
- doc, ok := s.docs[docID]
- if !ok {
- s.mu.Unlock()
- return ErrDocNotFound
- }
- if requester != "" && requester != doc.creator && requester != s.Owner && s.members[requester] != RoleAdmin {
- s.mu.Unlock()
- return ErrPermissionDenied
- }
- delete(s.docs, docID)
- snapshot := doc.snapshot()
- s.mu.Unlock()
- s.mgrDeleteDocRecord(docID)
- s.emitEvent(&SpaceEvent{Kind: EventDocDeleted, Doc: snapshot})
- return nil
- }
- // computeSplicePatch derives the single splice (common prefix / suffix diff)
- // that turns oldStr into newStr, in rune offsets.
- func computeSplicePatch(oldStr string, newStr string) DocPatch {
- oldRunes := []rune(oldStr)
- newRunes := []rune(newStr)
- prefix := 0
- for prefix < len(oldRunes) && prefix < len(newRunes) && oldRunes[prefix] == newRunes[prefix] {
- prefix++
- }
- suffix := 0
- for suffix < len(oldRunes)-prefix && suffix < len(newRunes)-prefix &&
- oldRunes[len(oldRunes)-1-suffix] == newRunes[len(newRunes)-1-suffix] {
- suffix++
- }
- return DocPatch{
- Pos: prefix,
- Del: len(oldRunes) - prefix - suffix,
- Ins: string(newRunes[prefix : len(newRunes)-suffix]),
- }
- }
|