orbitcast 0.3.1-x86_64-darwin

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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +28 -0
  3. data/README.md +100 -0
  4. data/app/assets/javascripts/orbitcast/recovery.js +196 -0
  5. data/db/orbitcast_migrate/001_create_orbitcast_payloads.rb +39 -0
  6. data/db/orbitcast_migrate/002_create_orbitcast_presence.rb +54 -0
  7. data/db/orbitcast_migrate/003_create_orbitcast_broker.rb +102 -0
  8. data/exe/orbitcast +11 -0
  9. data/lib/action_cable/subscription_adapter/orbitcast.rb +102 -0
  10. data/lib/generators/orbitcast/install/install_generator.rb +223 -0
  11. data/lib/generators/orbitcast/install/templates/cable.yml.tt +48 -0
  12. data/lib/generators/orbitcast/install/templates/orbitcast.rb.tt +156 -0
  13. data/lib/orbitcast/binary.rb +234 -0
  14. data/lib/orbitcast/channel.rb +459 -0
  15. data/lib/orbitcast/channel_registry.rb +131 -0
  16. data/lib/orbitcast/configuration.rb +636 -0
  17. data/lib/orbitcast/connection.rb +338 -0
  18. data/lib/orbitcast/control.rb +72 -0
  19. data/lib/orbitcast/dispatcher.rb +306 -0
  20. data/lib/orbitcast/engine.rb +98 -0
  21. data/lib/orbitcast/error.rb +54 -0
  22. data/lib/orbitcast/launcher.rb +302 -0
  23. data/lib/orbitcast/protocol/v1/control_message.rb +134 -0
  24. data/lib/orbitcast/protocol/v1/effects.rb +172 -0
  25. data/lib/orbitcast/protocol/v1/reply.rb +268 -0
  26. data/lib/orbitcast/protocol/v1/request.rb +364 -0
  27. data/lib/orbitcast/protocol/v1.rb +291 -0
  28. data/lib/orbitcast/protocol.rb +19 -0
  29. data/lib/orbitcast/publisher/control.rb +67 -0
  30. data/lib/orbitcast/publisher/http.rb +180 -0
  31. data/lib/orbitcast/publisher/instrumentation.rb +47 -0
  32. data/lib/orbitcast/publisher/postgres.rb +407 -0
  33. data/lib/orbitcast/rails_endpoint.rb +207 -0
  34. data/lib/orbitcast/recovery.rb +166 -0
  35. data/lib/orbitcast/state_codec.rb +186 -0
  36. data/lib/orbitcast/streams.rb +129 -0
  37. data/lib/orbitcast/version.rb +10 -0
  38. data/lib/orbitcast.rb +68 -0
  39. data/vendor/x86_64-darwin/bin/orbitcast +0 -0
  40. data/vendor/x86_64-darwin/manifest.json +14 -0
  41. metadata +130 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d5dfa18b582b4d44a1dc95a967e3fd29e68e09b89e845e568df3eaf02b601e5d
4
+ data.tar.gz: bf2e31580db19b5992ec216f5cd8216e2d6603b755fc7c8283757bb2033e06b9
5
+ SHA512:
6
+ metadata.gz: bfde23c0354765cfca4a77b8cffe85e47ef0e90f48fb8525f427aebb55fc6e2978c3bf7c934076953c3527ab6fe918f367204fb7e8f248db12829305d4b56692
7
+ data.tar.gz: 35d284179b69ba6b0e58f06fe940a108e209badcdb3a2c7b8390fb7c5919c087decd7ba3026a708b3f7391ca352f6234f407a683bed062afb9ef6839181916de
data/LICENSE.txt ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, Abdelkader Boudih
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # orbitcast (Ruby gem)
2
+
3
+ Rails integration for the Orbitcast relay. The gem ships the Ruby side plus one
4
+ native executable per platform; the relay itself is a separately supervised Rust
5
+ process.
6
+
7
+ Two modes. In `mode: :signed` (default) the relay authorizes subscriptions
8
+ from signed stream names and no application Ruby runs on the socket path.
9
+ In `mode: :dispatch` the relay calls back into Rails over a private HTTP
10
+ endpoint, and `ApplicationCable::Connection#connect`, channel callbacks,
11
+ and `perform` actions run here as ordinary Rails code.
12
+
13
+ ## What is in here
14
+
15
+ | Path | Role |
16
+ | --- | --- |
17
+ | `exe/orbitcast` | wrapper that execs the vendored binary |
18
+ | `lib/orbitcast/launcher.rb` | argv/env handling, single Rails boot for `serve` |
19
+ | `lib/orbitcast/binary.rb` | vendored binary resolution, `ORBITCAST_BINARY` override |
20
+ | `lib/orbitcast/configuration.rb` | validated settings |
21
+ | `lib/orbitcast/streams.rb` | `Orbitcast.signed_stream_name` |
22
+ | `lib/orbitcast/recovery.rb` | `Orbitcast.recovery_token` (opt-in retained replay) |
23
+ | `lib/orbitcast/publisher/postgres.rb` | pooled `pg_notify` publisher |
24
+ | `lib/orbitcast/publisher/http.rb` | private-ingress publisher (`publish_via: :http`) |
25
+ | `lib/orbitcast/publisher/instrumentation.rb` | `publish.orbitcast` notifications for both publishers |
26
+ | `lib/orbitcast/publisher/control.rb` | control-message publisher (`Orbitcast.disconnect`) |
27
+ | `lib/action_cable/subscription_adapter/orbitcast.rb` | publish-only cable adapter |
28
+ | `lib/orbitcast/connection.rb`, `lib/orbitcast/channel.rb` | dispatch-mode `ApplicationCable` bases |
29
+ | `lib/orbitcast/rails_endpoint.rb` | the private Rack endpoint the relay calls (dispatch mode) |
30
+ | `lib/orbitcast/engine.rb` | `config.orbitcast.*`, `db/orbitcast_migrate` migration path, derived stream key, mounts the RPC endpoint in dispatch mode; never launches the relay |
31
+ | `lib/generators/orbitcast/install/` | `bin/rails generate orbitcast:install` |
32
+ | `db/orbitcast_migrate/` | overflow, presence, and broker migrations |
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ bin/rails generate orbitcast:install # or --overflow
38
+ ```
39
+
40
+ The installer applies, idempotently: `config/cable.yml` (`adapter: orbitcast`),
41
+ `config/initializers/orbitcast.rb`, the four cable lines in
42
+ `config/environments/{development,production}.rb` (`mount_path = nil`, `url`,
43
+ `allowed_request_origins`, `config.orbitcast.mode = :signed` — existing values
44
+ kept), `action_cable_meta_tag` in the layout `<head>`, and a `cable:` entry in
45
+ `Procfile.dev`. Importmap pins and Solid Queue/Cache settings are untouched.
46
+ `--overflow` also copies the overflow migration (picked up by `bin/rails
47
+ db:migrate` through the engine) and sets `overflow_enabled: true`.
48
+
49
+ ```yaml
50
+ # config/cable.yml
51
+ production:
52
+ adapter: orbitcast
53
+ database_url: <%= ENV.fetch("ORBITCAST_DATABASE_URL") %>
54
+ namespace: "example_production"
55
+ publish_via: postgres # or http (ingress_url + ingress_token)
56
+ connection: pool # or active_record: join the app transaction
57
+ ```
58
+
59
+ ```text
60
+ # Procfile.dev
61
+ cable: bundle exec orbitcast serve
62
+ ```
63
+
64
+ `bundle exec orbitcast --version` and `--help` run the binary directly, without
65
+ booting Rails. `serve` (also a bare `orbitcast`, flags understood) inside an
66
+ application boots Rails once, exports an allowlisted `ORBITCAST_*` environment
67
+ (secrets as strict base64, never argv; `RAILS_MASTER_KEY`/`SECRET_KEY_BASE`
68
+ never), and replaces the Ruby process with the relay. The listener defaults to
69
+ `127.0.0.1:28080` in development/test and must be configured elsewhere
70
+ (`listen_addr` or `ORBITCAST_LISTEN_ADDR`).
71
+
72
+ Rails-side keys beyond the basics: `listener_database_url`, `listen_addr`,
73
+ `trusted_proxies`, `outbound_queue_messages/bytes`,
74
+ `streams_previous_secrets`, `turbo_previous_secrets`, `connection`. Without an
75
+ explicit `streams_secret` the stream key is derived from
76
+ `Rails.application.key_generator` under `orbitcast/streams/v1/<namespace>`.
77
+ Every publish emits `publish.orbitcast` through `ActiveSupport::Notifications`
78
+ (`stream:`, `bytes:`, `via:`), failures included.
79
+
80
+ ## Platforms
81
+
82
+ `arm64-darwin`, `x86_64-darwin`, `x86_64-linux-gnu`, `aarch64-linux-gnu`,
83
+ `x86_64-linux-musl`. On anything else, build the crate and point
84
+ `ORBITCAST_BINARY` at the result; the gem never downloads or compiles a binary.
85
+ An override is checked with `<binary> doctor --json` before `serve` uses it:
86
+ the reported `version` must equal the gem's, `features` must include
87
+ `postgres` when publishing through PostgreSQL, and `envelope_versions` must
88
+ include `1` — otherwise `Orbitcast::BinaryError` says exactly what is wrong.
89
+
90
+ ## Tests
91
+
92
+ No database and no Rails boot required:
93
+
94
+ ```bash
95
+ ruby -Ilib -Itest test/configuration_test.rb # etc.
96
+ ```
97
+
98
+ ## License
99
+
100
+ BSD-3-Clause. See `LICENSE.txt`.
@@ -0,0 +1,196 @@
1
+ // Orbitcast recovery helper.
2
+ //
3
+ // A plain ES module, importmap-ready: no build step, no bundler, no transpile.
4
+ // It uses only public `@rails/actioncable` APIs and is *never* injected into
5
+ // ordinary Turbo or ActionCable subscriptions - importing this file is the
6
+ // entire opt-in.
7
+ //
8
+ // import { createRecovery } from "orbitcast/recovery"
9
+ //
10
+ // const recovery = createRecovery(consumer, {
11
+ // token: document.querySelector("meta[name=orbitcast-recovery]").content,
12
+ // onMessage: ({ stream, payload, publicationId }) => render(stream, payload),
13
+ // onGap: ({ stream, reason }) => location.reload()
14
+ // })
15
+ //
16
+ // What it does:
17
+ // 1. subscribes to `Orbitcast::RecoveryChannel` with a Rails-minted token,
18
+ // 2. sends `resume` with the cursors it holds, on connect and on every
19
+ // reconnect,
20
+ // 3. tracks the cursor of every delivery it receives,
21
+ // 4. acknowledges receipt periodically (best effort),
22
+ // 5. reports gaps so the application can resynchronize.
23
+ //
24
+ // Delivery semantics - read this before relying on it:
25
+ //
26
+ // * **At-least-once, within retention.** The same publication can arrive
27
+ // twice across a replay-to-live handoff. Deduplicate on `publicationId`;
28
+ // this helper already drops repeats inside a bounded window, but an
29
+ // application that must be exactly-once needs its own idempotency.
30
+ // * **An ack is a receipt, not proof of effect.** It says bytes arrived, not
31
+ // that the UI applied them. It never gates delivery.
32
+ // * **A gap is real.** `onGap` means history the server cannot reconstruct:
33
+ // it expired, it was trimmed, the stream's generation was wiped, another
34
+ // tab won the fencing race, or live traffic outran the replay buffer. The
35
+ // only correct response is an application-level resync (refetch the page,
36
+ // the list, the document), never "ignore it".
37
+ // * **Cursors are opaque.** Store and echo them; never parse or compare them.
38
+ // * **Subscribe first, recover last.** The relay replays a stream only while
39
+ // this connection holds a confirmed subscription to it (the token alone is
40
+ // never enough), and it checks on every `resume`. Create the application
41
+ // subscriptions before calling `createRecovery`; ActionCable confirms
42
+ // subscriptions in creation order, on connect and on every reconnect, so
43
+ // the order you create them in is the order the relay sees. A stream the
44
+ // connection is not subscribed to arrives as an `unauthorized` gap.
45
+ // * **No cursors means "where I left off".** A `resume` sent without any
46
+ // cursors (a fresh page load that did not persist them) continues from the
47
+ // last position this session acknowledged, as long as the relay still has
48
+ // the session; otherwise it starts live. Persist `cursors()` to be exact.
49
+
50
+ const CHANNEL = "Orbitcast::RecoveryChannel";
51
+ const ACTION = "orbitcast_recovery";
52
+ const DELIVERY_TYPE = "orbitcast_delivery";
53
+ const CONTROL_TYPE = "orbitcast_recovery";
54
+ const DEFAULT_ACK_INTERVAL_MS = 5000;
55
+ const DEDUP_WINDOW = 2000;
56
+
57
+ /**
58
+ * Subscribe to Orbitcast recovery on an existing ActionCable consumer.
59
+ *
60
+ * @param {object} consumer - a `@rails/actioncable` consumer
61
+ * @param {object} options
62
+ * @param {string} options.token - the Rails-minted recovery token
63
+ * @param {function} [options.onMessage] - ({stream, payload, publicationId, cursor})
64
+ * @param {function} [options.onGap] - ({stream, reason})
65
+ * @param {function} [options.onReplayStart] - ({streams})
66
+ * @param {function} [options.onReplayDone] - ({cursors})
67
+ * @param {object} [options.cursors] - previously persisted {stream: cursor}
68
+ * @param {number} [options.ackIntervalMs] - acknowledgement cadence
69
+ * @returns {{subscription: object, cursors: function, unsubscribe: function}}
70
+ */
71
+ export function createRecovery(consumer, options = {}) {
72
+ const { token, cursors: initialCursors = {}, ackIntervalMs = DEFAULT_ACK_INTERVAL_MS } = options;
73
+ if (!token) throw new Error("orbitcast/recovery: a recovery token is required");
74
+
75
+ const cursors = { ...initialCursors };
76
+ const seen = new Set();
77
+ const order = [];
78
+ let ackTimer = null;
79
+ let acked = null;
80
+
81
+ const call = (name, argument) => {
82
+ const handler = options[name];
83
+ if (typeof handler === "function") handler(argument);
84
+ };
85
+
86
+ // Bounded deduplication: the same publication may legitimately be delivered
87
+ // twice across a replay-to-live handoff.
88
+ const isNew = (publicationId) => {
89
+ if (!publicationId) return true;
90
+ if (seen.has(publicationId)) return false;
91
+ seen.add(publicationId);
92
+ order.push(publicationId);
93
+ if (order.length > DEDUP_WINDOW) seen.delete(order.shift());
94
+ return true;
95
+ };
96
+
97
+ const subscription = consumer.subscriptions.create({ channel: CHANNEL, token }, {
98
+ // Called on connect *and* on every reconnect: resuming is what turns a
99
+ // reconnect into "continue from my cursor" instead of "start from now".
100
+ connected() {
101
+ send("resume");
102
+ if (ackIntervalMs > 0 && ackTimer === null) {
103
+ ackTimer = setInterval(() => acknowledge(), ackIntervalMs);
104
+ }
105
+ },
106
+
107
+ disconnected() {
108
+ if (ackTimer !== null) {
109
+ clearInterval(ackTimer);
110
+ ackTimer = null;
111
+ }
112
+ },
113
+
114
+ received(message) {
115
+ if (!message || typeof message !== "object") return;
116
+
117
+ if (message.type === DELIVERY_TYPE) {
118
+ // The cursor is opaque: store it, never parse it.
119
+ cursors[message.stream] = message.cursor;
120
+ if (!isNew(message.publication_id)) return;
121
+
122
+ let payload = null;
123
+ try {
124
+ // The payload travels as exact JSON *text*, so float and unicode
125
+ // escaping survive the whole path unchanged.
126
+ payload = JSON.parse(message.json_payload);
127
+ } catch (error) {
128
+ call("onGap", { stream: message.stream, reason: "undecodable_payload" });
129
+ return;
130
+ }
131
+
132
+ call("onMessage", {
133
+ stream: message.stream,
134
+ payload,
135
+ publicationId: message.publication_id,
136
+ sequence: message.sequence, // a decimal string: JS numbers are too small
137
+ generation: message.generation,
138
+ cursor: message.cursor
139
+ });
140
+ return;
141
+ }
142
+
143
+ if (message.type !== CONTROL_TYPE) return;
144
+
145
+ switch (message.event) {
146
+ case "replay_start":
147
+ call("onReplayStart", { streams: message.streams || [] });
148
+ break;
149
+ case "replay_done":
150
+ call("onReplayDone", { cursors: { ...cursors } });
151
+ break;
152
+ case "gap":
153
+ // History the server cannot reconstruct. Resynchronize; the cursor
154
+ // is kept so live delivery continues from the current edge.
155
+ call("onGap", { stream: message.stream, reason: message.reason });
156
+ break;
157
+ default:
158
+ break; // An unknown event is never treated as success.
159
+ }
160
+ }
161
+ });
162
+
163
+ // Receipt only. Sent on a timer, skipped when nothing moved, and never
164
+ // awaited: an unrecorded ack costs a longer replay, never a delivery.
165
+ function acknowledge() {
166
+ const encoded = JSON.stringify(cursors);
167
+ if (encoded === acked) return;
168
+ acked = encoded;
169
+ send("ack");
170
+ }
171
+
172
+ // `subscription.perform(action, data)` is ActionCable's client API, and it
173
+ // writes `data.action = action` before sending — so a call shaped
174
+ // `perform("message", { action: ACTION, ... })` reaches the server as
175
+ // `{"action":"message", ...}` and the relay never sees `orbitcast_recovery`.
176
+ // The action name must therefore be the first argument; the remaining
177
+ // fields are the v1 recovery command (`version`, `op`, `cursors`).
178
+ function send(op) {
179
+ subscription.perform(ACTION, { version: 1, op, cursors: { ...cursors } });
180
+ }
181
+
182
+ return {
183
+ subscription,
184
+ /** The cursors held right now; persist them to resume across page loads. */
185
+ cursors: () => ({ ...cursors }),
186
+ /** Force an acknowledgement (for example before unload). */
187
+ acknowledge,
188
+ unsubscribe() {
189
+ if (ackTimer !== null) clearInterval(ackTimer);
190
+ ackTimer = null;
191
+ subscription.unsubscribe();
192
+ }
193
+ };
194
+ }
195
+
196
+ export default createRecovery;
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Overflow relay table for publications too large for a PostgreSQL NOTIFY
4
+ # payload (8000 bytes, envelope and escaping included).
5
+ #
6
+ # Contract shared with the Rust server (`src/pubsub/postgres.rs`); the column
7
+ # names and types below are wire contract, not an implementation detail:
8
+ #
9
+ # orbitcast_payloads(id uuid primary key, namespace text not null,
10
+ # stream text not null, json_payload text not null,
11
+ # expires_at timestamptz not null)
12
+ #
13
+ # The publisher inserts one row and emits its `kind: "overflow"` reference in
14
+ # the *same transaction*, so no node can observe a reference to a row it cannot
15
+ # read. Every listening node fetches the row from the same primary with a fresh
16
+ # READ COMMITTED snapshot; nobody deletes it after their own delivery. Expiry
17
+ # cleanup belongs to the Rust server, which is why `expires_at` is indexed.
18
+ #
19
+ # This is a relay, not durable history: it is not replay, not offline delivery,
20
+ # and not a guarantee of delivery during a listener outage. Install it in the
21
+ # cable database (which may be separate from the primary application database).
22
+ class CreateOrbitcastPayloads < ActiveRecord::Migration[8.1]
23
+ def change
24
+ create_table :orbitcast_payloads, id: false do |t|
25
+ # `default: nil`: the id is always the publication UUID supplied by the
26
+ # publisher, so no gen_random_uuid()/pgcrypto default is needed.
27
+ t.primary_key :id, :uuid, default: nil
28
+ t.text :namespace, null: false
29
+ t.text :stream, null: false
30
+ t.text :json_payload, null: false
31
+ # Explicitly timestamptz rather than `t.datetime`: expiry is compared
32
+ # against the database clock (`now()`), never an application clock, and
33
+ # must not depend on the app's `datetime_type` default.
34
+ t.column :expires_at, :timestamptz, null: false
35
+ end
36
+
37
+ add_index :orbitcast_payloads, :expires_at
38
+ end
39
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Presence leases for authorized, distributed presence.
4
+ #
5
+ # Contract shared with the Rust server (`src/presence/postgres.rs`); the column
6
+ # names and types below are wire contract, not an implementation detail:
7
+ #
8
+ # orbitcast_presence(namespace text not null, stream text not null,
9
+ # subject_id text not null, session_id text not null,
10
+ # node_id text not null, metadata text not null,
11
+ # expires_at timestamptz not null,
12
+ # primary key (namespace, stream, subject_id, session_id))
13
+ #
14
+ # One row is one *session's* lease: a subject with three tabs holds three rows,
15
+ # so one tab's refresh can never preserve another tab's lease. Clients see the
16
+ # aggregate - the first live lease announces a join, the last one to go
17
+ # announces a leave - and that decision is serialized per (namespace, stream,
18
+ # subject_id) with a PostgreSQL advisory transaction lock.
19
+ #
20
+ # `metadata` is text, not jsonb: it holds the exact JSON the Rails
21
+ # `presence_grant` effect produced, byte for byte, and is never queried into.
22
+ # The identity in `subject_id` and everything in `metadata` are *public to the
23
+ # stream's granted subscribers* - never put a session token, an email, or any
24
+ # other sensitive connection attribute there.
25
+ #
26
+ # Rows are removed by whichever node sweeps first; `expires_at` is compared
27
+ # against the database clock (`now()`), never an application clock, which is
28
+ # what bounds staleness when a node dies without releasing its leases. Presence
29
+ # is eventually consistent and is not an authorization database.
30
+ #
31
+ # Install it in the cable database (which may be separate from the primary
32
+ # application database). Only needed for Rails-dispatch deployments that grant
33
+ # presence; a signed-only deployment never writes here.
34
+ class CreateOrbitcastPresence < ActiveRecord::Migration[8.1]
35
+ def change
36
+ create_table :orbitcast_presence, id: false, primary_key: %i[namespace stream subject_id session_id] do |t|
37
+ t.text :namespace, null: false
38
+ t.text :stream, null: false
39
+ t.text :subject_id, null: false
40
+ t.text :session_id, null: false
41
+ t.text :node_id, null: false
42
+ t.text :metadata, null: false
43
+ # Explicitly timestamptz rather than `t.datetime`: expiry is compared
44
+ # against the database clock and must not depend on the app's
45
+ # `datetime_type` default.
46
+ t.column :expires_at, :timestamptz, null: false
47
+ end
48
+
49
+ # The sweeper's scan: expired rows first, bounded per statement.
50
+ add_index :orbitcast_presence, :expires_at
51
+ # Roster reads: snapshots and the per-subject counts a transition takes.
52
+ add_index :orbitcast_presence, %i[namespace stream]
53
+ end
54
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Retained publications, per-stream ordering, and recovery sessions.
4
+ #
5
+ # Contract shared with the Rust server (`src/broker/postgres.rs`); the column
6
+ # names and types below are wire contract, not an implementation detail:
7
+ #
8
+ # orbitcast_messages(namespace text, stream text, generation bigint,
9
+ # seq bigint, publication_id uuid, json_payload text,
10
+ # created_at timestamptz, expires_at timestamptz,
11
+ # primary key (namespace, stream, generation, seq))
12
+ # orbitcast_stream_positions(namespace text, stream text, generation bigint,
13
+ # next_seq bigint, updated_at timestamptz,
14
+ # primary key (namespace, stream))
15
+ # orbitcast_sessions(namespace text, session_token_hash text, snapshot text,
16
+ # fencing_generation bigint, expires_at timestamptz,
17
+ # primary key (namespace, session_token_hash))
18
+ #
19
+ # Ordering. `seq` is assigned inside the transaction that inserts the message,
20
+ # while holding `SELECT ... FOR UPDATE` on that stream's
21
+ # `orbitcast_stream_positions` row. The lock is held until commit, so sequence
22
+ # order *is* commit order and a reader can treat "seq > last_delivered" as
23
+ # "everything I have not seen". A global BIGSERIAL cannot do this: two
24
+ # transactions can take values in one order and commit in the other, which
25
+ # would let a reader skip history permanently. There is deliberately no
26
+ # sequence, no serial column, and no ordering by `created_at`.
27
+ #
28
+ # Generations. `generation` is bumped when retention wipes a stream's history,
29
+ # so an old delivery cursor is recognizably stale instead of silently matching
30
+ # sequence numbers from a new era.
31
+ #
32
+ # Deduplication. The unique index on (namespace, stream, publication_id) makes a
33
+ # repeat publication id a no-op within the retention window: first write wins,
34
+ # and the conflict is counted and warned by the server. After expiry the id is
35
+ # forgotten and may be stored again.
36
+ #
37
+ # Sessions. `session_token_hash` is the SHA-256 hex digest of the recovery
38
+ # token - never the token itself - so a database leak cannot be replayed as a
39
+ # capability. `snapshot` is versioned JSON holding delivery cursors and an
40
+ # identity *reference*: never browser cookies, never Ruby Marshal data, never
41
+ # sensitive connection attributes. `fencing_generation` is bumped atomically by
42
+ # every resume, so two nodes can never restore one session at once.
43
+ #
44
+ # Retention is bounded by time (`expires_at`, swept in batches), by message
45
+ # count per stream (enforced in the publish transaction), and by bytes per
46
+ # stream (swept). Expiry cleanup belongs to the Rust server, which is why both
47
+ # `expires_at` columns are indexed. Install in the cable database (which may be
48
+ # separate from the primary application database); only needed when
49
+ # `broker_enabled` is set on the server.
50
+ class CreateOrbitcastBroker < ActiveRecord::Migration[8.1]
51
+ def change
52
+ create_table :orbitcast_messages, id: false,
53
+ primary_key: %i[namespace stream generation seq] do |t|
54
+ t.text :namespace, null: false
55
+ t.text :stream, null: false
56
+ # The history era this sequence belongs to; bumped on a retention wipe.
57
+ t.bigint :generation, null: false
58
+ # Per-stream commit order, assigned under the stream's position lock.
59
+ t.bigint :seq, null: false
60
+ # The publisher-supplied publication UUID, which is also the client's
61
+ # deduplication key across a replay-to-live handoff.
62
+ t.column :publication_id, :uuid, null: false
63
+ t.text :json_payload, null: false
64
+ # Explicitly timestamptz rather than `t.datetime`: expiry and retention
65
+ # are compared against the database clock (`now()`), never an application
66
+ # clock, and must not depend on the app's `datetime_type` default.
67
+ t.column :created_at, :timestamptz, null: false, default: -> { "now()" }
68
+ t.column :expires_at, :timestamptz, null: false
69
+ end
70
+
71
+ # Deduplication within the retention window.
72
+ add_index :orbitcast_messages, %i[namespace stream publication_id],
73
+ unique: true, name: "index_orbitcast_messages_on_publication"
74
+ # The sweeper's scan: expired rows first, bounded per statement.
75
+ add_index :orbitcast_messages, :expires_at
76
+
77
+ create_table :orbitcast_stream_positions, id: false, primary_key: %i[namespace stream] do |t|
78
+ t.text :namespace, null: false
79
+ t.text :stream, null: false
80
+ t.bigint :generation, null: false, default: 1
81
+ # The sequence the *next* publication will take. The row is locked
82
+ # FOR UPDATE for the whole publish transaction; that lock is the ordering
83
+ # mechanism, so this table is never written outside it.
84
+ t.bigint :next_seq, null: false, default: 1
85
+ t.column :updated_at, :timestamptz, null: false, default: -> { "now()" }
86
+ end
87
+
88
+ create_table :orbitcast_sessions, id: false,
89
+ primary_key: %i[namespace session_token_hash] do |t|
90
+ t.text :namespace, null: false
91
+ # SHA-256 hex of the recovery token. The token itself is never stored.
92
+ t.text :session_token_hash, null: false
93
+ # Versioned JSON: {"version":1,"session_id":"...","cursors":{...}}.
94
+ t.text :snapshot, null: false
95
+ # Bumped atomically by every resume; the highest holder owns the session.
96
+ t.bigint :fencing_generation, null: false, default: 0
97
+ t.column :expires_at, :timestamptz, null: false
98
+ end
99
+
100
+ add_index :orbitcast_sessions, :expires_at
101
+ end
102
+ end
data/exe/orbitcast ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Thin wrapper around the vendored Orbitcast executable.
5
+ #
6
+ # Only the launcher is loaded here. `require "orbitcast"` would mark the gem as
7
+ # loaded before `Bundler.require` runs inside a Rails boot, which would skip
8
+ # `Orbitcast::Engine` registration entirely.
9
+ require "orbitcast/launcher"
10
+
11
+ Orbitcast::Launcher.call(ARGV)
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_cable/subscription_adapter/base"
4
+ require "orbitcast"
5
+
6
+ module ActionCable
7
+ module SubscriptionAdapter
8
+ # Publish-only ActionCable adapter.
9
+ #
10
+ # # config/cable.yml
11
+ # production:
12
+ # adapter: orbitcast
13
+ # database_url: <%= ENV.fetch("ORBITCAST_DATABASE_URL") %>
14
+ # namespace: "example_production"
15
+ # publish_via: postgres
16
+ #
17
+ # `ActionCable.server.broadcast` and the Turbo broadcast helpers go through
18
+ # `broadcast`, to `Orbitcast::Publisher::Postgres` (`publish_via: postgres`,
19
+ # the default) or `Orbitcast::Publisher::Http` (`publish_via: http`).
20
+ # `shutdown` releases whichever publisher was built, fork-safely.
21
+ # Subscribing is the Rust relay's job: in signed mode the Ruby
22
+ # WebSocket endpoint is disabled (`config.action_cable.mount_path = nil`), so
23
+ # a local `subscribe` call is a configuration bug, not something to satisfy
24
+ # by starting a second listener.
25
+ #
26
+ # Note the `::` prefixes below: inside this class, a bare `Orbitcast`
27
+ # resolves to the adapter itself, not to the gem's top-level module.
28
+ class Orbitcast < Base
29
+ def initialize(server)
30
+ super
31
+ @publisher = nil
32
+ @mutex = Mutex.new
33
+ end
34
+
35
+ # @param channel [String] logical stream name
36
+ # @param payload [String] JSON text ActionCable already encoded; it is
37
+ # embedded verbatim rather than encoded a second time
38
+ def broadcast(channel, payload)
39
+ publisher.publish(channel, payload)
40
+ end
41
+
42
+ def subscribe(channel, message_callback, success_callback = nil)
43
+ raise NotImplementedError, unsupported_message("subscribe", channel)
44
+ end
45
+
46
+ def unsubscribe(channel, message_callback)
47
+ raise NotImplementedError, unsupported_message("unsubscribe", channel)
48
+ end
49
+
50
+ def shutdown
51
+ publisher = @mutex.synchronize do
52
+ current = @publisher
53
+ @publisher = nil
54
+ current
55
+ end
56
+
57
+ publisher&.shutdown
58
+ nil
59
+ end
60
+
61
+ # The publisher, built from `cable.yml` merged over `Orbitcast.config`.
62
+ def publisher
63
+ @publisher || @mutex.synchronize { @publisher ||= build_publisher }
64
+ end
65
+
66
+ private
67
+
68
+ def build_publisher
69
+ config = ::Orbitcast.config
70
+ config.assign(cable_options)
71
+
72
+ case config.publish_via
73
+ when :postgres
74
+ ::Orbitcast::Publisher::Postgres.new(config)
75
+ when :http
76
+ # Loaded here, not at require time: a PostgreSQL deployment never
77
+ # needs net/http in the adapter's path.
78
+ require "orbitcast/publisher/http"
79
+ ::Orbitcast::Publisher::Http.new(config)
80
+ else
81
+ # Configuration#publish_via= already rejects anything else; this is the
82
+ # fail-closed backstop if a new value is added there first.
83
+ raise ::Orbitcast::ConfigurationError,
84
+ "publish_via #{config.publish_via.inspect} has no publisher implementation"
85
+ end
86
+ end
87
+
88
+ # `cable[:id]` is ActionCable's own connection tag and `adapter` selects
89
+ # us; neither is an Orbitcast setting.
90
+ def cable_options
91
+ server.config.cable.to_h.transform_keys(&:to_sym).except(:adapter, :id)
92
+ end
93
+
94
+ def unsupported_message(operation, channel)
95
+ "Orbitcast does not #{operation} in this process (stream #{channel.inspect}). " \
96
+ "The Rust relay owns WebSocket connections and PostgreSQL LISTEN; the Rails cable " \
97
+ "endpoint is disabled in signed mode. Check that config.action_cable.mount_path is nil " \
98
+ "and that nothing mounts ActionCable::Server in this application."
99
+ end
100
+ end
101
+ end
102
+ end