Skip to content

Releases: jpcamara/yrby

yrby-client 0.7.0

Choose a tag to compare

@jpcamara jpcamara released this 08 Oct 22:41
c2a8d92

This release updates yrby-client only. The gems are unchanged.

Added

  • yrby-client: <yrby-document> has a read-only current property that
    returns the yrby:synced detail of the bound session, or undefined when
    nothing is bound. A binding that loads after the event fired can bind from
    it.
  • yrby-client: <yrby-document> has a retry() method that acquires the
    document again after its session blocked or was discarded, without changing
    whether the page is live. It works from a lease abort handler or right after
    session.discard(), so a binding can replace a broken document with a fresh
    session. It replaces calling activate(), which is internal to the Turbo
    adapter.
  • yrby-client: YrbyDocumentElement.consumer accepts a function that returns
    a consumer or a promise of one. The element calls it when it first needs a
    consumer and reuses the result. A function that throws or rejects is called
    again on the next attempt, and assigning a different value replaces the
    result.

yrby 0.8.1

Choose a tag to compare

@jpcamara jpcamara released this 04 Oct 00:35

Added

  • unknown_types on Y::Lexical and Y::ProseMirror, and so on Y::Lexxy
    and Y::Tiptap. It lists the node types in a document that neither the
    built-in schema nor a rule handles, which are the ones to_html can't fully
    render. Render a real document from your editor in a test and assert the list
    is empty.
  • The renderer crates export escape_text and escape_attr for callers that
    build their own markup (lexical-yjs-html 0.1.4, prosemirror-yjs-html
    0.1.5).

Changed

  • The demo app and the render-parity test run on Lexxy 1.0. Y::Lexxy
    output matches Lexxy 1.0's own value, including image alt text edited
    after the document syncs.

Fixed

  • The Lexical renderers keep the text of an unknown inline wrapper, such as a
    mark with no rule. The text renders without the wrapper. Before, the whole
    node rendered as nothing, so its text was missing from the HTML.
  • Y::Lexxy's list-item rule escapes the stored __checked and __value
    attributes. Collaborators write those attributes, and a crafted value could
    break out of the HTML attribute. Normal values (booleans and integers) render
    the same as before.
  • The crate READMEs' callback examples escape the values they insert, and both
    READMEs describe how unknown node types render.

yrby 0.8.0

Choose a tag to compare

@jpcamara jpcamara released this 02 Oct 09:30

Added

  • Doc#read_array(name) returns a Y.Array root as a JSON string, for
    documents whose content lives in an array.

This release ships alongside yrby-rails 0.7.0 and yrby-client 0.6.0.

yrby-rails 0.7.0

Choose a tag to compare

@jpcamara jpcamara released this 02 Oct 09:30

Added

  • Y::DocumentChannel.authorize_document { |record, name| ... } runs the
    application's permission check when a client subscribes, on top of the
    signed grant. The block runs in channel context. If it denies access, the
    channel rejects that subscription without opening a stream or serving
    state, and other subscriptions on the connection keep working. Without a
    block, a valid signed grant is enough.

    The check runs once per subscription, so a permission you revoke
    mid-session takes effect the next time that client subscribes.

  • record.collaborative_document(name) returns a bound
    Y::Collaborative::Attribute, which both application code and the shipped
    channel use to read and write the document. y_doc builds a native
    Y::Doc, and load_state, append, and key all use the same storage
    class. key and load_state don't create a row, but the first append
    does. Use .document_row for the built-in row operations.

  • Y::Document.key_for(record, name) returns the conventional key without
    creating a document row.

  • collaborative_sgid(name, expires_in:) and
    collaborative_document_tag(record, name, expires_in:, refresh:) let you
    set the grant lifetime, which used to be GlobalID's default with no way to
    shorten it. refresh: is a URL the element fetches when a subscription is
    rejected. That action re-runs the app's authorization and renders
    { grant: ... }, and the client resubscribes the same session with the new
    grant. The client fetches the URL once per rejection and does not poll.

  • Y::DocumentChannel ships in the gem, so apps don't need to write a
    channel. Clients subscribe with the signed grant the page rendered
    ({ grant:, name: }). The channel finds the record from the grant, loads
    the document, and stores changes in Y::Document. It rejects grants that
    are missing, tampered with, for the wrong attribute, or for a destroyed
    record.

  • collaborative_document_tag(record, name, **options) is available in
    Action View through the engine. It renders the mount element with the
    signed grant, the attribute name, and the channel name as data attributes.
    Anyone holding the grant can open the document, the same as with a
    turbo_stream_from stream name, so only render the tag on pages where the
    user is allowed to edit the record.

  • Channels get Y::Document storage by default. Declare on_load and
    on_change if you want a different store. Outside a yrby-rails app there
    is still no default, and subscribing raises until both hooks are declared.

  • has_collaborative_document :name, encrypted: true declares which storage
    class backs an attribute, and Y::DocumentChannel uses that class. Every
    load and append for an encrypted attribute goes through
    Y::EncryptedDocument, so the bytes are ciphertext at rest and the plain
    classes can't read them. The model decides whether an attribute is
    encrypted, and a page or client can't change that. Undeclared attributes use
    plain Y::Document.

  • Y::Collaborative adds signed tokens for record-backed documents, and the
    engine includes it into ActiveRecord::Base. A page creates a token with
    record.collaborative_sgid(:body), and the channel finds the record with
    Y::Collaborative.locate(params[:grant], :body). The token is a signed
    GlobalID scoped to one attribute, so a token for one field can't open
    another. This is the standard way to implement authorized? for
    record-backed documents. lexxy-realtime already uses this flow, and now it
    ships in yrby-rails.

Changed

  • Breaking: channels reject every subscription until they define
    authorized?(key). sync_subscribed now calls it before opening a stream
    or serving state, and the default returns false. Add the method to every
    channel that includes Y::ActionCable:

    private
    
    def authorized?(key)
      current_user&.can_edit?(key)
    end

    Return true for public documents. If the default is what rejected a
    subscription, the log message says so.

  • yrby:install now creates only the storage migration, since the gem ships
    Y::DocumentChannel. Pass --channel to also generate an application
    channel for custom authorization or room-keyed documents. That channel
    rejects every subscription until you implement authorized?.

Fixed

  • Document elements now attach editors to sessions that exist independently
    of any editor, so pending edits survive navigation under their original
    grant and rejected deliveries can still be recovered. Turbo previews are
    inert, and cached HTML contains no CRDT bytes.

  • When an attribute morphs, the element releases the old editor binding and
    acquires the new document. It doesn't move pending edits or authorization
    over to the new document.

  • Default storage now supplies the loader and recorder together, and
    declaring only one custom hook raises before subscribing or acknowledging
    an update. Previously, reads and writes silently went to different stores.

  • Signed grants work even when the host app doesn't load Active Job.

  • The yrby:tables migration template caps y_documents.state at
    1.gigabyte - 1 instead of 4.gigabytes - 1. Postgres raises
    ArgumentError for binary limits above 1 GB - 1, so db:migrate on a
    fresh Postgres app failed. MySQL maps both values to longblob, and
    SQLite ignores limits, so nothing changes there. Apps that already
    migrated are unaffected.

yrby-client 0.6.0

Choose a tag to compare

@jpcamara jpcamara released this 02 Oct 09:30

Changed

  • yrby-client document elements now use shared document sessions scoped to
    the consumer. A session tracks pending delivery separately from the editors
    attached to it, and each editor binding gets an abort signal for cleanup. A
    clean remount after a delay reloads from the server. doc and provider
    are unavailable until the session is acquired and while it is retargeting.
    Turbo no longer serializes CRDT state into cached HTML. Turbolinks 5 works
    the same way, with its before-cache, render, load, and preview signals
    handled alongside Turbo's.

  • Managed sessions expose their delivery status and hold rejected work until
    you call retry() or discard(). Each session uses its own subscription
    nonce, so acknowledgment sequences from different sessions don't mix.
    Provider status events now include a pending flag.

  • The element accepts a refresh attribute. If a subscription is rejected
    and refresh is set, the session fetches that URL once, expects
    { "grant": ... } back, and resubscribes with the new grant, keeping the
    same document and pending edits. A failed fetch or a second rejection
    blocks the session as before. The session doesn't renew grants on a timer.

  • YProtocolSession and ReliableSync renamed their transport hooks to read
    as commands. onConnect(), onDisconnect(), and ack()/onAck() are now
    resume(), pause(), and acknowledge(id), and ReliableSync's
    onTick() is now retransmit(). onStatusChange keeps the on prefix
    because it's the only one that registers a listener. ReliableSync#pending
    returns an independent snapshot with its own copy of the update bytes, and
    enqueueing copies the buffer you pass in.

  • A grant refresh request now times out after 15 seconds and blocks the
    session. Previously the session stayed offline indefinitely. If a consumer
    throws while resubscribing with a renewed grant, that now blocks the
    session too, where it used to be an unhandled rejection.

  • When a status listener throws, the error goes to onError, and the other
    listeners and the cable callback that fired it still run. The provider also
    reports failures in awareness events and unsubscribe. A throwing callback
    can't interrupt presence removal or leave the awareness timer running. If
    the error handler itself throws, the error goes to the console.

Fixed

  • ActionCable providers now ignore callbacks from subscriptions that have
    been replaced, so a late disconnect or rejection can't stop the new
    subscription from delivering edits. Synchronous consumer callbacks wait
    until subscription creation returns, and managed sessions use distinct
    subscription identifiers so old ACKs stay separate.

v0.7.1

Choose a tag to compare

@jpcamara jpcamara released this 20 Aug 00:34

Fixed

  • Y::ProseMirror and Y::Tiptap keep marks shared by adjacent text
    runs open across them, the way ProseMirror's own serializer does:
    bold then bold-italic renders as <strong>a<em>b</em></strong>, not
    as two sibling <strong> wraps. A mark whose attributes differ
    between runs never merges. Found by the new cross-renderer
    verification, which checks the renderers against a live Tiptap
    editor, @tiptap/html's static generateHTML, tiptap-php,
    Lexical's $generateHtmlFromNodes, and Payload CMS's
    convertLexicalToHTML on a shared corpus.

yrby 0.7.0

Choose a tag to compare

@jpcamara jpcamara released this 12 Aug 02:07

Added

  • Y::Decoder ships in the core gem and loads with require "y". It was
    scaffolded as a separate yrby-decoder gem, but it is 66 lines of pure
    Ruby over Doc#read_text / read_xml, requires the native core either
    way, and the separate gem was never published, so the split left the
    module in no gem at all. The yrby-decoder name is retired unused.

Changed

  • Doc#handle_sync_message answers a SyncStep1 with the doc's full state,
    pending included, matching Y.js's encodeStateAsUpdate. It previously
    served integrated-only state. A peer parks a served pending struct the
    same way the doc did and heals it when the missing dependency arrives
    from its sender's ack-driven retransmit. compacted_state_update still
    excludes pending, so compaction cannot freeze a gap into a snapshot.

Fixed

  • Doc#update_advances? no longer misreads a gappy merged update carrying
    novel content as a no-op.
    A crafted frame can hide an internal gap behind
    a Skip block while its post-gap blocks still integrate (yrs plants a Skip
    hole in the store). That moves neither the doc's public state vector nor
    pending, so the probe comparison reported genuinely novel content as
    "doesn't advance"; update_ready? accepted the frame and the doc applied
    it, but it was never recorded to the durable log or broadcast. Any
    insertion past the update's own (Skip-capped) state_vector() now
    conservatively reports as advancing. Not reachable through standard Yjs
    providers (a client's own updates and diffs are gap-free), so this closes
    a hostile-input hole, not a real-world regression.

yrby-rails 0.6.1

Choose a tag to compare

@jpcamara jpcamara released this 12 Aug 03:49

Fixed

  • Y::Document.load_state serves lossless state (encode_state_as_update).
    It previously served gap-free state, so a client joining while a gap was
    open never received the parked edit and could not heal it until its next
    handshake. The quarantined row was always preserved; now the pending
    struct rides along in served state and a mid-gap joiner heals the moment
    the missing dependency arrives.

yrby-rails 0.6.0

Choose a tag to compare

@jpcamara jpcamara released this 12 Aug 02:07

Changed

  • Causal gaps are now accepted. A causally-incomplete update, one whose
    causally-prior update the store hasn't seen, is recorded and acked like any
    other (ack-on-durable) instead of being rejected with a resync request,
    and served onward like any other state (a peer parks a pending struct
    exactly as the server does). The gap heals through the ack loop: the
    missing dependency is an update its own sender still holds unacked and
    keeps retransmitting, and join or reconnect handshakes let any client
    that holds it supply it. The write path no longer rebuilds the document
    per update:
    it appends, relays, and acks, so a lost-ack retry records again (replay
    converges; CRDT apply is idempotent).

    This tightens the store contract: on_load must preserve pending
    (encode_state_as_update or a replayed raw append log), compaction must
    never fold a pending update into a gap-free snapshot and drop the raw
    row (the bundled Y::Document quarantines pending rows and folds only
    clean ones), and on_change must tolerate duplicate deltas. An acked
    update that leaves durable storage before it integrates is a silent
    data loss.

Changed (Y::Document)

  • Compaction folds past an open gap. A batch holding a causal gap still
    compacts everything integrable: the fold captures every struct that
    integrates, rows independent of the gap included, and only the gap
    tail survives as quarantined raw rows. A healed gap folds out at the
    next compaction. Rows are judged per row against the folded state, so
    a row that causally builds on the gap quarantines with it and an
    acked update never leaves the table before its content is durably in
    state.

Added

  • on_gap channel hook: fires with the document key at join/serve time
    whenever the loaded document still holds a causal gap, for metrics on
    unhealed gaps (which no longer surface as resync traffic). An open gap is
    also logged at info.

yrby-rails v0.5.0

Choose a tag to compare

@jpcamara jpcamara released this 08 Aug 17:33
1c1891b

Added

  • Y::EncryptedDocument / Y::EncryptedDocumentUpdate: document storage
    encrypted with Active Record encryption (state and update payloads),
    on the same tables; the class you access through decides the
    cryptography, the way ActionText::EncryptedRichText does. Point a
    channel's on_load/on_change (or a record association) at
    Y::EncryptedDocument and configure the app's encryption keys. Keep
    one access path per document: rows written encrypted read back as
    ciphertext through the plain classes. Ciphertext is larger than the
    plaintext, so the effective payload cap is roughly three quarters of
    the column limit.