@syncular/server 0.3.1 → 0.4.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.
package/README.md CHANGED
@@ -134,9 +134,11 @@ JSON-able queries.
134
134
 
135
135
  `RingBufferEvents` is a `SyncularServerEvents` sink that retains the last N
136
136
  events in memory (bounded — oldest dropped when full) with a
137
- `query({type?, sinceMs?, limit})`. It is the event stream without any
138
- infrastructure dependency. Compose it with any other sink so the console
139
- tail and your logs/metrics see the same emissions:
137
+ `query({type?, sinceMs?, clientId?, actorId?, limit})` and a
138
+ `subscribe(listener)` hook for live tails (the SSE route below). It is the
139
+ event stream without any infrastructure dependency. Compose it with any
140
+ other sink so the console tail and your logs/metrics see the same
141
+ emissions:
140
142
 
141
143
  ```ts
142
144
  import {
@@ -153,21 +155,28 @@ const admin = SyncularAdmin.fromConfig(config, { ring });
153
155
 
154
156
  ### Query surface
155
157
 
156
- Every method is read-only and partition-scoped:
158
+ Every method is read-only. All are partition-scoped except the two fleet
159
+ reads (`listPartitions` / `partitionsOverview`), which enumerate partitions
160
+ by design:
157
161
 
158
162
  | Method | Returns |
159
163
  | --- | --- |
160
- | `listClients(partition)` | Known clients: `clientId`, `actorId`, `cursor`, `updatedAtMs`, `subscriptions[]`, and an `active` flag (cursor touched within the §4.6 active window). |
164
+ | `listClients(partition)` | Known clients: `clientId`, `actorId`, `cursor`, `lag` (commits not yet pulled: `maxCommitSeq − max(cursor, 0)`), `updatedAtMs`, `subscriptions[]`, and an `active` flag (cursor touched within the §4.6 active window). |
165
+ | `clientDetail(partition, clientId, {eventLimit?})` | One client's drill-down: `{exists, client?, lease?, events}` — the record (with lag), its §7.3 lease when a lease store is wired, and its slice of the event tail. Answers "why is this client stale" in one read. |
161
166
  | `listCommits(partition, {afterSeq?, limit?, table?})` | Commit-log **metadata** (never payloads), newest first: `commitSeq`, `clientId`, `clientCommitId`, `actorId`, `createdAtMs`, `changeCount`, `tables[]`. |
162
167
  | `inspectRow(partition, table, rowId)` | `{exists, serverVersion?, scopes?}` — current row version + stored scopes, payload **not** decoded. |
163
168
  | `scopeActivity(partition, {variable, value}, {limit?})` | Recent commits touching one scope key, via the §3.1 change-scope index (never a log scan). |
164
169
  | `horizonStatus(partition)` | `{maxCommitSeq, horizonSeq, retainedCommits, activeCursorFloor, recommendedHorizonSeq, recommendation}` — the horizon a prune pass would reach now (§4.6) + a coarse `up-to-date` / `prune-recommended`. |
165
170
  | `segmentStats()` / `blobStats(partition)` / `stats(partition)` | Counts/bytes where the stores expose them (segments split rows/sqlite). `undefined` when a store omits `stats()`. |
166
- | `events({type?, sinceMs?, limit?})` | The ring tail, newest first. Empty when no ring is wired (`hasEventStream` reports which). |
171
+ | `metrics(partition, {windowMs?, buckets?})` | Ring-derived request/push health over a trailing window (default 5 min): request count/rate, error share, p50/p95 duration, push applied/rejected/conflicted, and per-bucket counts for a sparkline. Zero new server state; all zeros when no ring is wired. |
172
+ | `listPartitions()` / `partitionsOverview()` | Every partition the storage holds state for; the overview adds retained backlog, client counts (known/active), and the prune recommendation per partition — the fleet view. |
173
+ | `events({type?, sinceMs?, clientId?, actorId?, limit?})` | The ring tail, newest first. Empty when no ring is wired (`hasEventStream` reports which). |
174
+ | `subscribeEvents(listener)` | Live events as they land in the ring; returns the unsubscribe function, `undefined` when no ring is wired. |
167
175
 
168
176
  The query surface leans on **additive, optional** storage/store methods
169
177
  (`ServerStorage.listClientRecords` / `listCommitMetadata` / `scopeActivity` /
170
- `getRowScopes`; `SegmentStore.stats`; `BlobStore.stats`) — the established
178
+ `getRowScopes` / `listPartitions`; `SegmentStore.stats`; `BlobStore.stats`)
179
+ — the established
171
180
  optional-method pattern. `SqliteServerStorage`, `PostgresServerStorage`,
172
181
  `D1ServerStorage`, and the memory/sqlite stores implement them; the shared
173
182
  `ServerStorage` contract suite exercises them on all backends. A backend that
@@ -199,29 +208,44 @@ app.route('/admin', routes);
199
208
  | --- | --- |
200
209
  | `GET /` | The console page (see below). |
201
210
  | `GET /clients` | `listClients` |
211
+ | `GET /clients/:clientId?eventLimit` | `clientDetail` |
202
212
  | `GET /commits?afterSeq&limit&table` | `listCommits` |
203
213
  | `GET /rows/:table/:rowId` | `inspectRow` |
204
214
  | `GET /scope-activity?variable&value&limit` | `scopeActivity` |
205
215
  | `GET /horizon` | `horizonStatus` |
206
216
  | `GET /stats` | `stats` |
207
- | `GET /events?type&sinceMs&limit` | `events` (ring tail) |
217
+ | `GET /metrics?windowMs` | `metrics` |
218
+ | `GET /partitions` | `partitionsOverview` (fleet view) |
219
+ | `GET /events?type&sinceMs&clientId&actorId&limit` | `events` (ring tail) |
220
+ | `GET /events/stream?type&clientId&actorId&limit` | live SSE tail (backlog replay, then `subscribeEvents`) |
208
221
 
209
222
  `?partition=` selects the partition (falls back to `defaultPartition`).
223
+ `GET /partitions` is the one cross-partition endpoint: the guard still runs
224
+ on it, with an empty `partition` in its context unless the query passes one
225
+ — a host that authorizes per partition should treat it accordingly.
210
226
 
211
227
  `GET /` (or `/admin`) serves a **single static HTML page** — zero
212
228
  framework, no build step, no React. It fetches the sibling JSON endpoints
213
229
  (relative to its own mount path, so it works under any prefix and the same
214
- guard covers its XHRs), renders tables for horizon, store stats, clients,
215
- recent commits, and the event tail, with an auto-refresh toggle (2 s poll).
216
- This is a deliberate ~300-line console: 5% of the code a full console app
230
+ guard covers its XHRs) and renders: a metrics statusbar (push rate,
231
+ conflicts, error share, p95, ASCII sparkline), the fleet view (which doubles
232
+ as the partition picker), horizon, store stats, clients with cursor lag and
233
+ a per-client drill-down (subscriptions with full scope sets, lease status,
234
+ the client's event slice), recent commits, the row inspector, scope
235
+ activity, and the event tail — with an auto-refresh toggle (2 s poll).
236
+ This is a deliberate one-file console: 5% of the code a full console app
217
237
  would cost, the 80% operator value.
218
238
 
219
- **No SSE (yet).** `GET /events` is a polled ring query; the page's
220
- auto-refresh polls it. Server-Sent-Events streaming was deliberately
221
- skipped for this rung the ring is pull-only, so SSE would need a
222
- push-notification path from the sink into open connections (extra
223
- machinery for marginal benefit at admin cadence). Polling is the right
224
- rung; SSE is a noted follow-up.
239
+ **Live tail over SSE.** `GET /events/stream` streams the ring as
240
+ server-sent events: a recent backlog replays first, then events arrive as
241
+ they land (a `subscribe` hook on the ring), with a comment ping every 5 s
242
+ (under Bun.serve's 10 s default idle timeout, so a quiet stream survives).
243
+ It is plain web-streams, so it serves identically on Bun, Node, and
244
+ Workers. The page upgrades its event panel to the stream automatically and
245
+ falls back to the 2-second poll when the stream is unavailable (no ring, or
246
+ `EventSource` cannot pass the host's auth headers — cookie-authorized
247
+ setups stream fine). The tail can carry sensitive identifiers (actorIds,
248
+ session ids), the same as `GET /events`; the admin guard gates both.
225
249
 
226
250
  The demo server (`apps/demo`) mounts the admin behind a dev guard:
227
251
  `SYNCULAR_DEMO_ADMIN=1` enables `/admin` (optionally token-gated with
package/dist/admin.d.ts CHANGED
@@ -18,6 +18,7 @@ import type { BlobStore, BlobStoreStats } from './blob-store.js';
18
18
  import { type SyncServerConfig } from './context.js';
19
19
  import type { SyncularServerEvent } from './events.js';
20
20
  import type { RingBufferEvents, RingEventQuery } from './events-ring.js';
21
+ import type { LeaseRecord, LeaseStore } from './lease-store.js';
21
22
  import { type RetentionPolicy } from './prune.js';
22
23
  import { type ServerSchema } from './schema.js';
23
24
  import type { SegmentStore, SegmentStoreStats } from './segment-store.js';
@@ -27,6 +28,11 @@ export interface AdminClient {
27
28
  readonly clientId: string;
28
29
  readonly actorId: string;
29
30
  readonly cursor: number;
31
+ /**
32
+ * Commits the client has not pulled yet: `maxCommitSeq − max(cursor, 0)`.
33
+ * The first number an operator wants for "why is this client stale".
34
+ */
35
+ readonly lag: number;
30
36
  readonly updatedAtMs: number;
31
37
  readonly subscriptions: readonly {
32
38
  readonly id: string;
@@ -36,6 +42,58 @@ export interface AdminClient {
36
42
  /** True when the cursor record was touched within the active window. */
37
43
  readonly active: boolean;
38
44
  }
45
+ /** One client's drill-down: record + lease + its slice of the event tail. */
46
+ export interface AdminClientDetail {
47
+ readonly clientId: string;
48
+ readonly exists: boolean;
49
+ /** Present iff `exists` — the same shape `listClients` returns. */
50
+ readonly client?: AdminClient;
51
+ /** The client's §7.3 lease, when a lease store is wired and one exists. */
52
+ readonly lease?: LeaseRecord;
53
+ /** Recent ring events carrying this clientId (newest first). */
54
+ readonly events: readonly SyncularServerEvent[];
55
+ }
56
+ /** Ring-derived request/push aggregates over a trailing window. */
57
+ export interface AdminMetrics {
58
+ readonly partition: string;
59
+ readonly windowMs: number;
60
+ /** Wall-clock the aggregation ran at (window end). */
61
+ readonly atMs: number;
62
+ readonly requests: {
63
+ readonly count: number;
64
+ readonly perMinute: number;
65
+ readonly errorCount: number;
66
+ /** errors ÷ requests, 0 when the window is empty. */
67
+ readonly errorRate: number;
68
+ readonly p50Ms: number;
69
+ readonly p95Ms: number;
70
+ };
71
+ readonly pushes: {
72
+ readonly applied: number;
73
+ readonly rejected: number;
74
+ readonly conflicted: number;
75
+ };
76
+ /**
77
+ * Request counts split into `counts.length` equal buckets, oldest first —
78
+ * the console's sparkline. `errors` marks the error share per bucket.
79
+ */
80
+ readonly buckets: {
81
+ readonly widthMs: number;
82
+ readonly counts: readonly number[];
83
+ readonly errors: readonly number[];
84
+ };
85
+ }
86
+ /** One partition's row in the fleet view (`listPartitions` + horizon math). */
87
+ export interface AdminPartitionOverview {
88
+ readonly partition: string;
89
+ readonly maxCommitSeq: number;
90
+ readonly horizonSeq: number;
91
+ readonly retainedCommits: number;
92
+ readonly knownClients: number;
93
+ /** Clients whose cursor record was touched within the active window. */
94
+ readonly activeClients: number;
95
+ readonly recommendation: 'up-to-date' | 'prune-recommended';
96
+ }
39
97
  export interface AdminRowInspection {
40
98
  readonly table: string;
41
99
  readonly rowId: string;
@@ -81,6 +139,8 @@ export interface SyncularAdminOptions {
81
139
  readonly ring?: RingBufferEvents;
82
140
  readonly segments?: SegmentStore;
83
141
  readonly blobs?: BlobStore;
142
+ /** The §7.3 lease store — feeds the client drill-down's lease read. */
143
+ readonly leases?: LeaseStore;
84
144
  /** Retention policy for horizon recommendation (defaults to §4.6). */
85
145
  readonly retention?: Partial<RetentionPolicy>;
86
146
  /** Epoch-ms clock (defaults to `Date.now`) — active-window math. */
@@ -99,8 +159,16 @@ export declare class SyncularAdmin {
99
159
  ring?: RingBufferEvents;
100
160
  retention?: Partial<RetentionPolicy>;
101
161
  }): SyncularAdmin;
102
- /** The known clients for a partition (cursor, last-seen, subscriptions). */
162
+ /** The known clients for a partition (cursor, lag, subscriptions). */
103
163
  listClients(partition: string): Promise<AdminClient[]>;
164
+ /**
165
+ * One client's drill-down: its record (with lag), its §7.3 lease when a
166
+ * lease store is wired, and its recent slice of the event tail. Answers
167
+ * "why is this client stale" in a single read.
168
+ */
169
+ clientDetail(partition: string, clientId: string, options?: {
170
+ readonly eventLimit?: number;
171
+ }): Promise<AdminClientDetail>;
104
172
  /** Commit-log metadata (no payloads), newest first. */
105
173
  listCommits(partition: string, options?: AdminListCommitsOptions): Promise<CommitMetadata[]>;
106
174
  /**
@@ -133,4 +201,32 @@ export declare class SyncularAdmin {
133
201
  get hasEventStream(): boolean;
134
202
  /** The event tail from the ring buffer (newest first). Empty when unwired. */
135
203
  events(query?: RingEventQuery): SyncularServerEvent[];
204
+ /**
205
+ * Subscribe to events as they land in the ring (the SSE tail). Returns
206
+ * the unsubscribe function, or `undefined` when no ring is wired — a
207
+ * host can branch on that the same way `hasEventStream` reports it.
208
+ */
209
+ subscribeEvents(listener: (event: SyncularServerEvent) => void): (() => void) | undefined;
210
+ /**
211
+ * Request/push health over a trailing window, derived entirely from the
212
+ * ring (zero new server state): rates, error share, duration percentiles,
213
+ * and per-bucket counts for the console's sparkline. Only events carrying
214
+ * this `partition` count. Empty (all zeros) when no ring is wired.
215
+ */
216
+ metrics(partition: string, options?: {
217
+ readonly windowMs?: number;
218
+ readonly buckets?: number;
219
+ }): AdminMetrics;
220
+ /**
221
+ * Every partition the storage knows (commit log + client records) — the
222
+ * fleet-view backing and the console's partition picker. Fails loud when
223
+ * the backend omits the optional `listPartitions`.
224
+ */
225
+ listPartitions(): Promise<string[]>;
226
+ /**
227
+ * The fleet view: one row per known partition — retained backlog, client
228
+ * counts, prune recommendation. A cross-partition read (every other
229
+ * method is partition-scoped); host authorization should account for it.
230
+ */
231
+ partitionsOverview(): Promise<AdminPartitionOverview[]>;
136
232
  }
package/dist/admin.js CHANGED
@@ -3,17 +3,30 @@ import { DEFAULT_RETENTION } from './prune.js';
3
3
  import { compileSchema } from './schema.js';
4
4
  const DEFAULT_COMMIT_LIMIT = 50;
5
5
  const DEFAULT_SCOPE_LIMIT = 50;
6
+ const DEFAULT_CLIENT_EVENT_LIMIT = 100;
7
+ const DEFAULT_METRICS_WINDOW_MS = 5 * 60 * 1000;
8
+ const DEFAULT_METRICS_BUCKETS = 30;
9
+ /** Nearest-rank percentile over an unsorted sample; 0 for an empty one. */
10
+ function percentile(sample, q) {
11
+ if (sample.length === 0)
12
+ return 0;
13
+ const sorted = [...sample].sort((a, b) => a - b);
14
+ const rank = Math.max(1, Math.ceil(q * sorted.length));
15
+ return sorted[rank - 1] ?? 0;
16
+ }
6
17
  function required(value, what) {
7
18
  if (value === undefined) {
8
19
  throw new Error(`SyncularAdmin: the configured ${what} does not implement this read (TODO §2.5 optional method missing)`);
9
20
  }
10
21
  return value;
11
22
  }
12
- function toAdminClient(record, active) {
23
+ function toAdminClient(record, active, maxCommitSeq) {
13
24
  return {
14
25
  clientId: record.clientId,
15
26
  actorId: record.actorId,
16
27
  cursor: record.cursor,
28
+ // A never-pulled cursor (-1) has seen nothing: it lags the whole log.
29
+ lag: Math.max(0, maxCommitSeq - Math.max(0, record.cursor)),
17
30
  updatedAtMs: record.updatedAtMs,
18
31
  subscriptions: record.subscriptions.map((sub) => ({
19
32
  id: sub.id,
@@ -30,6 +43,7 @@ export class SyncularAdmin {
30
43
  #ring;
31
44
  #segments;
32
45
  #blobs;
46
+ #leases;
33
47
  #retention;
34
48
  #clock;
35
49
  constructor(options) {
@@ -42,6 +56,8 @@ export class SyncularAdmin {
42
56
  this.#segments = options.segments;
43
57
  if (options.blobs !== undefined)
44
58
  this.#blobs = options.blobs;
59
+ if (options.leases !== undefined)
60
+ this.#leases = options.leases;
45
61
  this.#retention = { ...DEFAULT_RETENTION, ...options.retention };
46
62
  this.#clock = options.clock ?? Date.now;
47
63
  }
@@ -56,17 +72,44 @@ export class SyncularAdmin {
56
72
  schema: config.schema,
57
73
  segments: config.segments,
58
74
  ...(config.blobs !== undefined ? { blobs: config.blobs } : {}),
75
+ ...(config.leases !== undefined ? { leases: config.leases.store } : {}),
59
76
  ...(extra?.ring !== undefined ? { ring: extra.ring } : {}),
60
77
  ...(extra?.retention !== undefined ? { retention: extra.retention } : {}),
61
78
  clock: clockOf(config),
62
79
  });
63
80
  }
64
- /** The known clients for a partition (cursor, last-seen, subscriptions). */
81
+ /** The known clients for a partition (cursor, lag, subscriptions). */
65
82
  async listClients(partition) {
66
83
  const list = required(this.#storage.listClientRecords?.bind(this.#storage), 'storage');
67
84
  const records = await list(partition);
85
+ const maxCommitSeq = await this.#storage.getMaxCommitSeq(partition);
68
86
  const activeFloorMs = this.#clock() - this.#retention.activeWindowMs;
69
- return records.map((record) => toAdminClient(record, record.updatedAtMs >= activeFloorMs));
87
+ return records.map((record) => toAdminClient(record, record.updatedAtMs >= activeFloorMs, maxCommitSeq));
88
+ }
89
+ /**
90
+ * One client's drill-down: its record (with lag), its §7.3 lease when a
91
+ * lease store is wired, and its recent slice of the event tail. Answers
92
+ * "why is this client stale" in a single read.
93
+ */
94
+ async clientDetail(partition, clientId, options = {}) {
95
+ const record = await this.#storage.getClientRecord(partition, clientId);
96
+ const events = this.events({
97
+ clientId,
98
+ limit: options.eventLimit ?? DEFAULT_CLIENT_EVENT_LIMIT,
99
+ });
100
+ if (record === undefined) {
101
+ return { clientId, exists: false, events };
102
+ }
103
+ const maxCommitSeq = await this.#storage.getMaxCommitSeq(partition);
104
+ const activeFloorMs = this.#clock() - this.#retention.activeWindowMs;
105
+ const lease = await this.#leases?.get(partition, clientId);
106
+ return {
107
+ clientId,
108
+ exists: true,
109
+ client: toAdminClient(record, record.updatedAtMs >= activeFloorMs, maxCommitSeq),
110
+ ...(lease !== undefined ? { lease } : {}),
111
+ events,
112
+ };
70
113
  }
71
114
  /** Commit-log metadata (no payloads), newest first. */
72
115
  async listCommits(partition, options = {}) {
@@ -165,4 +208,103 @@ export class SyncularAdmin {
165
208
  events(query = {}) {
166
209
  return this.#ring?.query(query) ?? [];
167
210
  }
211
+ /**
212
+ * Subscribe to events as they land in the ring (the SSE tail). Returns
213
+ * the unsubscribe function, or `undefined` when no ring is wired — a
214
+ * host can branch on that the same way `hasEventStream` reports it.
215
+ */
216
+ subscribeEvents(listener) {
217
+ return this.#ring?.subscribe(listener);
218
+ }
219
+ /**
220
+ * Request/push health over a trailing window, derived entirely from the
221
+ * ring (zero new server state): rates, error share, duration percentiles,
222
+ * and per-bucket counts for the console's sparkline. Only events carrying
223
+ * this `partition` count. Empty (all zeros) when no ring is wired.
224
+ */
225
+ metrics(partition, options = {}) {
226
+ const windowMs = options.windowMs ?? DEFAULT_METRICS_WINDOW_MS;
227
+ const bucketCount = options.buckets ?? DEFAULT_METRICS_BUCKETS;
228
+ const atMs = this.#clock();
229
+ const sinceMs = atMs - windowMs;
230
+ const widthMs = windowMs / bucketCount;
231
+ const counts = new Array(bucketCount).fill(0);
232
+ const errors = new Array(bucketCount).fill(0);
233
+ const durations = [];
234
+ let requestCount = 0;
235
+ let errorCount = 0;
236
+ let applied = 0;
237
+ let rejected = 0;
238
+ let conflicted = 0;
239
+ for (const event of this.events({ sinceMs })) {
240
+ if (event.partition !== partition)
241
+ continue;
242
+ if (event.type === 'push.applied')
243
+ applied += 1;
244
+ else if (event.type === 'push.rejected')
245
+ rejected += 1;
246
+ else if (event.type === 'push.conflicted')
247
+ conflicted += 1;
248
+ if (event.type !== 'request.handled')
249
+ continue;
250
+ requestCount += 1;
251
+ durations.push(event.durationMs);
252
+ const failed = event.outcome === 'rejected' || event.outcome === 'error';
253
+ if (failed)
254
+ errorCount += 1;
255
+ const bucket = Math.min(bucketCount - 1, Math.max(0, Math.floor((event.atMs - sinceMs) / widthMs)));
256
+ counts[bucket] = (counts[bucket] ?? 0) + 1;
257
+ if (failed)
258
+ errors[bucket] = (errors[bucket] ?? 0) + 1;
259
+ }
260
+ return {
261
+ partition,
262
+ windowMs,
263
+ atMs,
264
+ requests: {
265
+ count: requestCount,
266
+ perMinute: requestCount / (windowMs / 60_000),
267
+ errorCount,
268
+ errorRate: requestCount === 0 ? 0 : errorCount / requestCount,
269
+ p50Ms: percentile(durations, 0.5),
270
+ p95Ms: percentile(durations, 0.95),
271
+ },
272
+ pushes: { applied, rejected, conflicted },
273
+ buckets: { widthMs, counts, errors },
274
+ };
275
+ }
276
+ /**
277
+ * Every partition the storage knows (commit log + client records) — the
278
+ * fleet-view backing and the console's partition picker. Fails loud when
279
+ * the backend omits the optional `listPartitions`.
280
+ */
281
+ async listPartitions() {
282
+ const list = required(this.#storage.listPartitions?.bind(this.#storage), 'storage');
283
+ return list();
284
+ }
285
+ /**
286
+ * The fleet view: one row per known partition — retained backlog, client
287
+ * counts, prune recommendation. A cross-partition read (every other
288
+ * method is partition-scoped); host authorization should account for it.
289
+ */
290
+ async partitionsOverview() {
291
+ const partitions = await this.listPartitions();
292
+ const activeFloorMs = this.#clock() - this.#retention.activeWindowMs;
293
+ const out = [];
294
+ for (const partition of partitions) {
295
+ const status = await this.horizonStatus(partition);
296
+ const cursors = await this.#storage.listClientCursors(partition);
297
+ out.push({
298
+ partition,
299
+ maxCommitSeq: status.maxCommitSeq,
300
+ horizonSeq: status.horizonSeq,
301
+ retainedCommits: status.retainedCommits,
302
+ knownClients: cursors.length,
303
+ activeClients: cursors.filter((c) => c.updatedAtMs >= activeFloorMs)
304
+ .length,
305
+ recommendation: status.recommendation,
306
+ });
307
+ }
308
+ return out;
309
+ }
168
310
  }
@@ -47,4 +47,5 @@ export declare class D1ServerStorage implements ServerStorage {
47
47
  serverVersion: number;
48
48
  scopes: Record<string, string>;
49
49
  } | undefined>;
50
+ listPartitions(): Promise<string[]>;
50
51
  }
Binary file
@@ -3,7 +3,8 @@
3
3
  * without any infrastructure dependency.
4
4
  *
5
5
  * `RingBufferEvents` is a `SyncularServerEvents` sink that retains the last
6
- * N events in memory and exposes a `query({type?, sinceMs?, limit})`. It
6
+ * N events in memory and exposes a `query({type?, sinceMs?, clientId?,
7
+ * actorId?, limit})` plus a `subscribe(listener)` hook for live tails. It
7
8
  * composes with any other sink through `composeEvents(...sinks)`, so a host
8
9
  * can keep `consoleJsonEvents()` (or a Sentry adapter) AND feed the console
9
10
  * event tail from the same emissions. Fire-and-forget discipline is
@@ -16,9 +17,20 @@ export interface RingEventQuery {
16
17
  readonly type?: SyncularServerEvent['type'];
17
18
  /** Only events with `atMs >= sinceMs`. */
18
19
  readonly sinceMs?: number;
20
+ /** Restrict to events carrying this `clientId` (not every type does). */
21
+ readonly clientId?: string;
22
+ /** Restrict to events carrying this `actorId` (not every type does). */
23
+ readonly actorId?: string;
19
24
  /** Newest-first cap (default: the whole retained buffer). */
20
25
  readonly limit?: number;
21
26
  }
27
+ /**
28
+ * The one matching rule shared by `RingBufferEvents.query` and live
29
+ * subscribers (the admin SSE tail): `type` matches exactly; `clientId` /
30
+ * `actorId` match only events that carry the field (events without a
31
+ * client/actor identity never match an identity filter).
32
+ */
33
+ export declare function matchesRingQuery(event: SyncularServerEvent, query: Omit<RingEventQuery, 'limit'>): boolean;
22
34
  export declare const DEFAULT_RING_CAPACITY = 1000;
23
35
  /**
24
36
  * In-memory ring of the most recent events. When full, the oldest event is
@@ -33,6 +45,13 @@ export declare class RingBufferEvents implements SyncularServerEvents {
33
45
  readonly capacity?: number;
34
46
  });
35
47
  emit(event: SyncularServerEvent): void;
48
+ /**
49
+ * Subscribe to every event as it lands in the ring (the push half the
50
+ * SSE tail needs). Returns the unsubscribe function. Listeners run
51
+ * synchronously on the emit path under the same fire-and-forget contract
52
+ * as sinks: a throwing listener is swallowed.
53
+ */
54
+ subscribe(listener: (event: SyncularServerEvent) => void): () => void;
36
55
  /** The configured maximum number of retained events. */
37
56
  get capacity(): number;
38
57
  /** The number of events currently retained. */
@@ -3,7 +3,8 @@
3
3
  * without any infrastructure dependency.
4
4
  *
5
5
  * `RingBufferEvents` is a `SyncularServerEvents` sink that retains the last
6
- * N events in memory and exposes a `query({type?, sinceMs?, limit})`. It
6
+ * N events in memory and exposes a `query({type?, sinceMs?, clientId?,
7
+ * actorId?, limit})` plus a `subscribe(listener)` hook for live tails. It
7
8
  * composes with any other sink through `composeEvents(...sinks)`, so a host
8
9
  * can keep `consoleJsonEvents()` (or a Sentry adapter) AND feed the console
9
10
  * event tail from the same emissions. Fire-and-forget discipline is
@@ -11,6 +12,26 @@
11
12
  * guarded), and the ring itself never throws through.
12
13
  */
13
14
  import { emitEvent, } from './events.js';
15
+ /**
16
+ * The one matching rule shared by `RingBufferEvents.query` and live
17
+ * subscribers (the admin SSE tail): `type` matches exactly; `clientId` /
18
+ * `actorId` match only events that carry the field (events without a
19
+ * client/actor identity never match an identity filter).
20
+ */
21
+ export function matchesRingQuery(event, query) {
22
+ if (query.type !== undefined && event.type !== query.type)
23
+ return false;
24
+ if (query.sinceMs !== undefined && event.atMs < query.sinceMs)
25
+ return false;
26
+ const carrier = event;
27
+ if (query.clientId !== undefined && carrier.clientId !== query.clientId) {
28
+ return false;
29
+ }
30
+ if (query.actorId !== undefined && carrier.actorId !== query.actorId) {
31
+ return false;
32
+ }
33
+ return true;
34
+ }
14
35
  export const DEFAULT_RING_CAPACITY = 1000;
15
36
  /**
16
37
  * In-memory ring of the most recent events. When full, the oldest event is
@@ -25,6 +46,8 @@ export class RingBufferEvents {
25
46
  /** Index of the oldest element in the circular buffer. */
26
47
  #head = 0;
27
48
  #size = 0;
49
+ /** Live subscribers (the SSE tail). Each notify is guarded. */
50
+ #listeners = new Set();
28
51
  constructor(options) {
29
52
  const capacity = options?.capacity ?? DEFAULT_RING_CAPACITY;
30
53
  if (!Number.isInteger(capacity) || capacity <= 0) {
@@ -42,6 +65,26 @@ export class RingBufferEvents {
42
65
  this.#buffer[this.#head] = event;
43
66
  this.#head = (this.#head + 1) % this.#capacity;
44
67
  }
68
+ for (const listener of this.#listeners) {
69
+ try {
70
+ listener(event);
71
+ }
72
+ catch {
73
+ // fire-and-forget: a throwing subscriber never affects emission
74
+ }
75
+ }
76
+ }
77
+ /**
78
+ * Subscribe to every event as it lands in the ring (the push half the
79
+ * SSE tail needs). Returns the unsubscribe function. Listeners run
80
+ * synchronously on the emit path under the same fire-and-forget contract
81
+ * as sinks: a throwing listener is swallowed.
82
+ */
83
+ subscribe(listener) {
84
+ this.#listeners.add(listener);
85
+ return () => {
86
+ this.#listeners.delete(listener);
87
+ };
45
88
  }
46
89
  /** The configured maximum number of retained events. */
47
90
  get capacity() {
@@ -64,9 +107,7 @@ export class RingBufferEvents {
64
107
  const event = this.#buffer[(this.#head + i) % this.#capacity];
65
108
  if (event === undefined)
66
109
  continue;
67
- if (query.type !== undefined && event.type !== query.type)
68
- continue;
69
- if (query.sinceMs !== undefined && event.atMs < query.sinceMs)
110
+ if (!matchesRingQuery(event, query))
70
111
  continue;
71
112
  out.push(event);
72
113
  }
@@ -66,4 +66,5 @@ export declare class PostgresServerStorage implements ServerStorage {
66
66
  serverVersion: number;
67
67
  scopes: Record<string, string>;
68
68
  } | undefined>;
69
+ listPartitions(): Promise<string[]>;
69
70
  }
@@ -763,4 +763,11 @@ export class PostgresServerStorage {
763
763
  : row.scopes),
764
764
  };
765
765
  }
766
+ async listPartitions() {
767
+ // Union: the registry row appears on first commit, the client row on
768
+ // first pull — a partition with only one of the two still shows up.
769
+ const { rows } = await this.#exec.query(`SELECT partition FROM sync_partitions
770
+ UNION SELECT partition FROM sync_clients ORDER BY partition`, []);
771
+ return rows.map((r) => r.partition);
772
+ }
766
773
  }
package/dist/schema.js CHANGED
@@ -106,7 +106,7 @@ export function compileSchema(schema) {
106
106
  // non-PK, non-scope column is encrypted — a fully-E2EE table's
107
107
  // projection would be pure ciphertext, so it defaults off.
108
108
  const scopeColumnIndices = new Set(scopePatterns.map((pattern) => pattern.columnIndex));
109
- const projectable = table.columns.filter((column, index) => index !== primaryKeyIndex && !scopeColumnIndices.has(index));
109
+ const projectable = table.columns.filter((_column, index) => index !== primaryKeyIndex && !scopeColumnIndices.has(index));
110
110
  const fullyEncrypted = encryptedColumnIndices.length > 0 &&
111
111
  projectable.length > 0 &&
112
112
  projectable.every((column) => column.encrypted === true);
@@ -44,4 +44,5 @@ export declare class SqliteServerStorage implements ServerStorage {
44
44
  serverVersion: number;
45
45
  scopes: Record<string, string>;
46
46
  } | undefined>;
47
+ listPartitions(): Promise<string[]>;
47
48
  }
@@ -474,4 +474,13 @@ export class SqliteServerStorage {
474
474
  scopes: JSON.parse(record.scopes),
475
475
  };
476
476
  }
477
+ async listPartitions() {
478
+ // Union: the registry row appears on first commit, the client row on
479
+ // first pull — a partition with only one of the two still shows up.
480
+ const rows = this.db
481
+ .query(`SELECT partition FROM sync_partitions
482
+ UNION SELECT partition FROM sync_clients ORDER BY partition`)
483
+ .all();
484
+ return rows.map((r) => r.partition);
485
+ }
477
486
  }
package/dist/storage.d.ts CHANGED
@@ -235,6 +235,10 @@ export interface ServerStorage {
235
235
  * change scope index (never a log scan).
236
236
  * `getRowScopes`: the (table, rowId) row's current server_version and
237
237
  * stored scopes without decoding its payload — the row inspector.
238
+ * `listPartitions`: every partition this storage holds state for — the
239
+ * union of the partition registry (commit log counters) and client
240
+ * records, sorted. Powers the console's fleet view / partition picker;
241
+ * deliberately NOT partition-scoped (the one cross-partition read).
238
242
  */
239
243
  listClientRecords?(partition: string): Promise<ClientRecord[]>;
240
244
  listCommitMetadata?(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
@@ -243,6 +247,7 @@ export interface ServerStorage {
243
247
  serverVersion: number;
244
248
  scopes: Record<string, string>;
245
249
  } | undefined>;
250
+ listPartitions?(): Promise<string[]>;
246
251
  }
247
252
  /** A row referencing a blob, with the scopes needed to authorize download. */
248
253
  export interface BlobReferencingRow {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -53,7 +53,7 @@
53
53
  "!dist/**/*.test.d.ts"
54
54
  ],
55
55
  "dependencies": {
56
- "@syncular/core": "0.2.1"
56
+ "@syncular/core": "0.3.1"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@electric-sql/pglite": "^0.5.4"
package/src/admin.ts CHANGED
@@ -18,6 +18,7 @@ import type { BlobStore, BlobStoreStats } from './blob-store';
18
18
  import { clockOf, type SyncServerConfig } from './context';
19
19
  import type { SyncularServerEvent } from './events';
20
20
  import type { RingBufferEvents, RingEventQuery } from './events-ring';
21
+ import type { LeaseRecord, LeaseStore } from './lease-store';
21
22
  import { DEFAULT_RETENTION, type RetentionPolicy } from './prune';
22
23
  import { compileSchema, type ServerSchema } from './schema';
23
24
  import type { SegmentStore, SegmentStoreStats } from './segment-store';
@@ -33,6 +34,11 @@ export interface AdminClient {
33
34
  readonly clientId: string;
34
35
  readonly actorId: string;
35
36
  readonly cursor: number;
37
+ /**
38
+ * Commits the client has not pulled yet: `maxCommitSeq − max(cursor, 0)`.
39
+ * The first number an operator wants for "why is this client stale".
40
+ */
41
+ readonly lag: number;
36
42
  readonly updatedAtMs: number;
37
43
  readonly subscriptions: readonly {
38
44
  readonly id: string;
@@ -43,6 +49,61 @@ export interface AdminClient {
43
49
  readonly active: boolean;
44
50
  }
45
51
 
52
+ /** One client's drill-down: record + lease + its slice of the event tail. */
53
+ export interface AdminClientDetail {
54
+ readonly clientId: string;
55
+ readonly exists: boolean;
56
+ /** Present iff `exists` — the same shape `listClients` returns. */
57
+ readonly client?: AdminClient;
58
+ /** The client's §7.3 lease, when a lease store is wired and one exists. */
59
+ readonly lease?: LeaseRecord;
60
+ /** Recent ring events carrying this clientId (newest first). */
61
+ readonly events: readonly SyncularServerEvent[];
62
+ }
63
+
64
+ /** Ring-derived request/push aggregates over a trailing window. */
65
+ export interface AdminMetrics {
66
+ readonly partition: string;
67
+ readonly windowMs: number;
68
+ /** Wall-clock the aggregation ran at (window end). */
69
+ readonly atMs: number;
70
+ readonly requests: {
71
+ readonly count: number;
72
+ readonly perMinute: number;
73
+ readonly errorCount: number;
74
+ /** errors ÷ requests, 0 when the window is empty. */
75
+ readonly errorRate: number;
76
+ readonly p50Ms: number;
77
+ readonly p95Ms: number;
78
+ };
79
+ readonly pushes: {
80
+ readonly applied: number;
81
+ readonly rejected: number;
82
+ readonly conflicted: number;
83
+ };
84
+ /**
85
+ * Request counts split into `counts.length` equal buckets, oldest first —
86
+ * the console's sparkline. `errors` marks the error share per bucket.
87
+ */
88
+ readonly buckets: {
89
+ readonly widthMs: number;
90
+ readonly counts: readonly number[];
91
+ readonly errors: readonly number[];
92
+ };
93
+ }
94
+
95
+ /** One partition's row in the fleet view (`listPartitions` + horizon math). */
96
+ export interface AdminPartitionOverview {
97
+ readonly partition: string;
98
+ readonly maxCommitSeq: number;
99
+ readonly horizonSeq: number;
100
+ readonly retainedCommits: number;
101
+ readonly knownClients: number;
102
+ /** Clients whose cursor record was touched within the active window. */
103
+ readonly activeClients: number;
104
+ readonly recommendation: 'up-to-date' | 'prune-recommended';
105
+ }
106
+
46
107
  export interface AdminRowInspection {
47
108
  readonly table: string;
48
109
  readonly rowId: string;
@@ -93,6 +154,8 @@ export interface SyncularAdminOptions {
93
154
  readonly ring?: RingBufferEvents;
94
155
  readonly segments?: SegmentStore;
95
156
  readonly blobs?: BlobStore;
157
+ /** The §7.3 lease store — feeds the client drill-down's lease read. */
158
+ readonly leases?: LeaseStore;
96
159
  /** Retention policy for horizon recommendation (defaults to §4.6). */
97
160
  readonly retention?: Partial<RetentionPolicy>;
98
161
  /** Epoch-ms clock (defaults to `Date.now`) — active-window math. */
@@ -101,6 +164,17 @@ export interface SyncularAdminOptions {
101
164
 
102
165
  const DEFAULT_COMMIT_LIMIT = 50;
103
166
  const DEFAULT_SCOPE_LIMIT = 50;
167
+ const DEFAULT_CLIENT_EVENT_LIMIT = 100;
168
+ const DEFAULT_METRICS_WINDOW_MS = 5 * 60 * 1000;
169
+ const DEFAULT_METRICS_BUCKETS = 30;
170
+
171
+ /** Nearest-rank percentile over an unsorted sample; 0 for an empty one. */
172
+ function percentile(sample: readonly number[], q: number): number {
173
+ if (sample.length === 0) return 0;
174
+ const sorted = [...sample].sort((a, b) => a - b);
175
+ const rank = Math.max(1, Math.ceil(q * sorted.length));
176
+ return sorted[rank - 1] ?? 0;
177
+ }
104
178
 
105
179
  function required<T>(value: T | undefined, what: string): T {
106
180
  if (value === undefined) {
@@ -111,11 +185,17 @@ function required<T>(value: T | undefined, what: string): T {
111
185
  return value;
112
186
  }
113
187
 
114
- function toAdminClient(record: ClientRecord, active: boolean): AdminClient {
188
+ function toAdminClient(
189
+ record: ClientRecord,
190
+ active: boolean,
191
+ maxCommitSeq: number,
192
+ ): AdminClient {
115
193
  return {
116
194
  clientId: record.clientId,
117
195
  actorId: record.actorId,
118
196
  cursor: record.cursor,
197
+ // A never-pulled cursor (-1) has seen nothing: it lags the whole log.
198
+ lag: Math.max(0, maxCommitSeq - Math.max(0, record.cursor)),
119
199
  updatedAtMs: record.updatedAtMs,
120
200
  subscriptions: record.subscriptions.map((sub) => ({
121
201
  id: sub.id,
@@ -133,6 +213,7 @@ export class SyncularAdmin {
133
213
  readonly #ring?: RingBufferEvents;
134
214
  readonly #segments?: SegmentStore;
135
215
  readonly #blobs?: BlobStore;
216
+ readonly #leases?: LeaseStore;
136
217
  readonly #retention: RetentionPolicy;
137
218
  readonly #clock: () => number;
138
219
 
@@ -142,6 +223,7 @@ export class SyncularAdmin {
142
223
  if (options.ring !== undefined) this.#ring = options.ring;
143
224
  if (options.segments !== undefined) this.#segments = options.segments;
144
225
  if (options.blobs !== undefined) this.#blobs = options.blobs;
226
+ if (options.leases !== undefined) this.#leases = options.leases;
145
227
  this.#retention = { ...DEFAULT_RETENTION, ...options.retention };
146
228
  this.#clock = options.clock ?? Date.now;
147
229
  }
@@ -160,25 +242,61 @@ export class SyncularAdmin {
160
242
  schema: config.schema,
161
243
  segments: config.segments,
162
244
  ...(config.blobs !== undefined ? { blobs: config.blobs } : {}),
245
+ ...(config.leases !== undefined ? { leases: config.leases.store } : {}),
163
246
  ...(extra?.ring !== undefined ? { ring: extra.ring } : {}),
164
247
  ...(extra?.retention !== undefined ? { retention: extra.retention } : {}),
165
248
  clock: clockOf(config),
166
249
  });
167
250
  }
168
251
 
169
- /** The known clients for a partition (cursor, last-seen, subscriptions). */
252
+ /** The known clients for a partition (cursor, lag, subscriptions). */
170
253
  async listClients(partition: string): Promise<AdminClient[]> {
171
254
  const list = required(
172
255
  this.#storage.listClientRecords?.bind(this.#storage),
173
256
  'storage',
174
257
  );
175
258
  const records = await list(partition);
259
+ const maxCommitSeq = await this.#storage.getMaxCommitSeq(partition);
176
260
  const activeFloorMs = this.#clock() - this.#retention.activeWindowMs;
177
261
  return records.map((record) =>
178
- toAdminClient(record, record.updatedAtMs >= activeFloorMs),
262
+ toAdminClient(record, record.updatedAtMs >= activeFloorMs, maxCommitSeq),
179
263
  );
180
264
  }
181
265
 
266
+ /**
267
+ * One client's drill-down: its record (with lag), its §7.3 lease when a
268
+ * lease store is wired, and its recent slice of the event tail. Answers
269
+ * "why is this client stale" in a single read.
270
+ */
271
+ async clientDetail(
272
+ partition: string,
273
+ clientId: string,
274
+ options: { readonly eventLimit?: number } = {},
275
+ ): Promise<AdminClientDetail> {
276
+ const record = await this.#storage.getClientRecord(partition, clientId);
277
+ const events = this.events({
278
+ clientId,
279
+ limit: options.eventLimit ?? DEFAULT_CLIENT_EVENT_LIMIT,
280
+ });
281
+ if (record === undefined) {
282
+ return { clientId, exists: false, events };
283
+ }
284
+ const maxCommitSeq = await this.#storage.getMaxCommitSeq(partition);
285
+ const activeFloorMs = this.#clock() - this.#retention.activeWindowMs;
286
+ const lease = await this.#leases?.get(partition, clientId);
287
+ return {
288
+ clientId,
289
+ exists: true,
290
+ client: toAdminClient(
291
+ record,
292
+ record.updatedAtMs >= activeFloorMs,
293
+ maxCommitSeq,
294
+ ),
295
+ ...(lease !== undefined ? { lease } : {}),
296
+ events,
297
+ };
298
+ }
299
+
182
300
  /** Commit-log metadata (no payloads), newest first. */
183
301
  async listCommits(
184
302
  partition: string,
@@ -309,4 +427,111 @@ export class SyncularAdmin {
309
427
  events(query: RingEventQuery = {}): SyncularServerEvent[] {
310
428
  return this.#ring?.query(query) ?? [];
311
429
  }
430
+
431
+ /**
432
+ * Subscribe to events as they land in the ring (the SSE tail). Returns
433
+ * the unsubscribe function, or `undefined` when no ring is wired — a
434
+ * host can branch on that the same way `hasEventStream` reports it.
435
+ */
436
+ subscribeEvents(
437
+ listener: (event: SyncularServerEvent) => void,
438
+ ): (() => void) | undefined {
439
+ return this.#ring?.subscribe(listener);
440
+ }
441
+
442
+ /**
443
+ * Request/push health over a trailing window, derived entirely from the
444
+ * ring (zero new server state): rates, error share, duration percentiles,
445
+ * and per-bucket counts for the console's sparkline. Only events carrying
446
+ * this `partition` count. Empty (all zeros) when no ring is wired.
447
+ */
448
+ metrics(
449
+ partition: string,
450
+ options: { readonly windowMs?: number; readonly buckets?: number } = {},
451
+ ): AdminMetrics {
452
+ const windowMs = options.windowMs ?? DEFAULT_METRICS_WINDOW_MS;
453
+ const bucketCount = options.buckets ?? DEFAULT_METRICS_BUCKETS;
454
+ const atMs = this.#clock();
455
+ const sinceMs = atMs - windowMs;
456
+ const widthMs = windowMs / bucketCount;
457
+ const counts = new Array<number>(bucketCount).fill(0);
458
+ const errors = new Array<number>(bucketCount).fill(0);
459
+ const durations: number[] = [];
460
+ let requestCount = 0;
461
+ let errorCount = 0;
462
+ let applied = 0;
463
+ let rejected = 0;
464
+ let conflicted = 0;
465
+ for (const event of this.events({ sinceMs })) {
466
+ if (event.partition !== partition) continue;
467
+ if (event.type === 'push.applied') applied += 1;
468
+ else if (event.type === 'push.rejected') rejected += 1;
469
+ else if (event.type === 'push.conflicted') conflicted += 1;
470
+ if (event.type !== 'request.handled') continue;
471
+ requestCount += 1;
472
+ durations.push(event.durationMs);
473
+ const failed = event.outcome === 'rejected' || event.outcome === 'error';
474
+ if (failed) errorCount += 1;
475
+ const bucket = Math.min(
476
+ bucketCount - 1,
477
+ Math.max(0, Math.floor((event.atMs - sinceMs) / widthMs)),
478
+ );
479
+ counts[bucket] = (counts[bucket] ?? 0) + 1;
480
+ if (failed) errors[bucket] = (errors[bucket] ?? 0) + 1;
481
+ }
482
+ return {
483
+ partition,
484
+ windowMs,
485
+ atMs,
486
+ requests: {
487
+ count: requestCount,
488
+ perMinute: requestCount / (windowMs / 60_000),
489
+ errorCount,
490
+ errorRate: requestCount === 0 ? 0 : errorCount / requestCount,
491
+ p50Ms: percentile(durations, 0.5),
492
+ p95Ms: percentile(durations, 0.95),
493
+ },
494
+ pushes: { applied, rejected, conflicted },
495
+ buckets: { widthMs, counts, errors },
496
+ };
497
+ }
498
+
499
+ /**
500
+ * Every partition the storage knows (commit log + client records) — the
501
+ * fleet-view backing and the console's partition picker. Fails loud when
502
+ * the backend omits the optional `listPartitions`.
503
+ */
504
+ async listPartitions(): Promise<string[]> {
505
+ const list = required(
506
+ this.#storage.listPartitions?.bind(this.#storage),
507
+ 'storage',
508
+ );
509
+ return list();
510
+ }
511
+
512
+ /**
513
+ * The fleet view: one row per known partition — retained backlog, client
514
+ * counts, prune recommendation. A cross-partition read (every other
515
+ * method is partition-scoped); host authorization should account for it.
516
+ */
517
+ async partitionsOverview(): Promise<AdminPartitionOverview[]> {
518
+ const partitions = await this.listPartitions();
519
+ const activeFloorMs = this.#clock() - this.#retention.activeWindowMs;
520
+ const out: AdminPartitionOverview[] = [];
521
+ for (const partition of partitions) {
522
+ const status = await this.horizonStatus(partition);
523
+ const cursors = await this.#storage.listClientCursors(partition);
524
+ out.push({
525
+ partition,
526
+ maxCommitSeq: status.maxCommitSeq,
527
+ horizonSeq: status.horizonSeq,
528
+ retainedCommits: status.retainedCommits,
529
+ knownClients: cursors.length,
530
+ activeClients: cursors.filter((c) => c.updatedAtMs >= activeFloorMs)
531
+ .length,
532
+ recommendation: status.recommendation,
533
+ });
534
+ }
535
+ return out;
536
+ }
312
537
  }
package/src/d1-storage.ts CHANGED
Binary file
@@ -3,7 +3,8 @@
3
3
  * without any infrastructure dependency.
4
4
  *
5
5
  * `RingBufferEvents` is a `SyncularServerEvents` sink that retains the last
6
- * N events in memory and exposes a `query({type?, sinceMs?, limit})`. It
6
+ * N events in memory and exposes a `query({type?, sinceMs?, clientId?,
7
+ * actorId?, limit})` plus a `subscribe(listener)` hook for live tails. It
7
8
  * composes with any other sink through `composeEvents(...sinks)`, so a host
8
9
  * can keep `consoleJsonEvents()` (or a Sentry adapter) AND feed the console
9
10
  * event tail from the same emissions. Fire-and-forget discipline is
@@ -21,10 +22,36 @@ export interface RingEventQuery {
21
22
  readonly type?: SyncularServerEvent['type'];
22
23
  /** Only events with `atMs >= sinceMs`. */
23
24
  readonly sinceMs?: number;
25
+ /** Restrict to events carrying this `clientId` (not every type does). */
26
+ readonly clientId?: string;
27
+ /** Restrict to events carrying this `actorId` (not every type does). */
28
+ readonly actorId?: string;
24
29
  /** Newest-first cap (default: the whole retained buffer). */
25
30
  readonly limit?: number;
26
31
  }
27
32
 
33
+ /**
34
+ * The one matching rule shared by `RingBufferEvents.query` and live
35
+ * subscribers (the admin SSE tail): `type` matches exactly; `clientId` /
36
+ * `actorId` match only events that carry the field (events without a
37
+ * client/actor identity never match an identity filter).
38
+ */
39
+ export function matchesRingQuery(
40
+ event: SyncularServerEvent,
41
+ query: Omit<RingEventQuery, 'limit'>,
42
+ ): boolean {
43
+ if (query.type !== undefined && event.type !== query.type) return false;
44
+ if (query.sinceMs !== undefined && event.atMs < query.sinceMs) return false;
45
+ const carrier = event as { clientId?: string; actorId?: string };
46
+ if (query.clientId !== undefined && carrier.clientId !== query.clientId) {
47
+ return false;
48
+ }
49
+ if (query.actorId !== undefined && carrier.actorId !== query.actorId) {
50
+ return false;
51
+ }
52
+ return true;
53
+ }
54
+
28
55
  export const DEFAULT_RING_CAPACITY = 1000;
29
56
 
30
57
  /**
@@ -40,6 +67,8 @@ export class RingBufferEvents implements SyncularServerEvents {
40
67
  /** Index of the oldest element in the circular buffer. */
41
68
  #head = 0;
42
69
  #size = 0;
70
+ /** Live subscribers (the SSE tail). Each notify is guarded. */
71
+ readonly #listeners = new Set<(event: SyncularServerEvent) => void>();
43
72
 
44
73
  constructor(options?: { readonly capacity?: number }) {
45
74
  const capacity = options?.capacity ?? DEFAULT_RING_CAPACITY;
@@ -58,6 +87,26 @@ export class RingBufferEvents implements SyncularServerEvents {
58
87
  this.#buffer[this.#head] = event;
59
88
  this.#head = (this.#head + 1) % this.#capacity;
60
89
  }
90
+ for (const listener of this.#listeners) {
91
+ try {
92
+ listener(event);
93
+ } catch {
94
+ // fire-and-forget: a throwing subscriber never affects emission
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Subscribe to every event as it lands in the ring (the push half the
101
+ * SSE tail needs). Returns the unsubscribe function. Listeners run
102
+ * synchronously on the emit path under the same fire-and-forget contract
103
+ * as sinks: a throwing listener is swallowed.
104
+ */
105
+ subscribe(listener: (event: SyncularServerEvent) => void): () => void {
106
+ this.#listeners.add(listener);
107
+ return () => {
108
+ this.#listeners.delete(listener);
109
+ };
61
110
  }
62
111
 
63
112
  /** The configured maximum number of retained events. */
@@ -82,8 +131,7 @@ export class RingBufferEvents implements SyncularServerEvents {
82
131
  for (let i = this.#size - 1; i >= 0 && out.length < limit; i -= 1) {
83
132
  const event = this.#buffer[(this.#head + i) % this.#capacity];
84
133
  if (event === undefined) continue;
85
- if (query.type !== undefined && event.type !== query.type) continue;
86
- if (query.sinceMs !== undefined && event.atMs < query.sinceMs) continue;
134
+ if (!matchesRingQuery(event, query)) continue;
87
135
  out.push(event);
88
136
  }
89
137
  return out;
@@ -1179,4 +1179,15 @@ export class PostgresServerStorage implements ServerStorage {
1179
1179
  : row.scopes) as Record<string, string>,
1180
1180
  };
1181
1181
  }
1182
+
1183
+ async listPartitions(): Promise<string[]> {
1184
+ // Union: the registry row appears on first commit, the client row on
1185
+ // first pull — a partition with only one of the two still shows up.
1186
+ const { rows } = await this.#exec.query<{ partition: string }>(
1187
+ `SELECT partition FROM sync_partitions
1188
+ UNION SELECT partition FROM sync_clients ORDER BY partition`,
1189
+ [],
1190
+ );
1191
+ return rows.map((r) => r.partition);
1192
+ }
1182
1193
  }
package/src/schema.ts CHANGED
@@ -240,7 +240,7 @@ export function compileSchema(schema: ServerSchema): CompiledSchema {
240
240
  scopePatterns.map((pattern) => pattern.columnIndex),
241
241
  );
242
242
  const projectable = table.columns.filter(
243
- (column, index) =>
243
+ (_column, index) =>
244
244
  index !== primaryKeyIndex && !scopeColumnIndices.has(index),
245
245
  );
246
246
  const fullyEncrypted =
@@ -789,4 +789,16 @@ export class SqliteServerStorage implements ServerStorage {
789
789
  scopes: JSON.parse(record.scopes) as Record<string, string>,
790
790
  };
791
791
  }
792
+
793
+ async listPartitions(): Promise<string[]> {
794
+ // Union: the registry row appears on first commit, the client row on
795
+ // first pull — a partition with only one of the two still shows up.
796
+ const rows = this.db
797
+ .query<{ partition: string }, []>(
798
+ `SELECT partition FROM sync_partitions
799
+ UNION SELECT partition FROM sync_clients ORDER BY partition`,
800
+ )
801
+ .all();
802
+ return rows.map((r) => r.partition);
803
+ }
792
804
  }
package/src/storage.ts CHANGED
@@ -289,6 +289,10 @@ export interface ServerStorage {
289
289
  * change scope index (never a log scan).
290
290
  * `getRowScopes`: the (table, rowId) row's current server_version and
291
291
  * stored scopes without decoding its payload — the row inspector.
292
+ * `listPartitions`: every partition this storage holds state for — the
293
+ * union of the partition registry (commit log counters) and client
294
+ * records, sorted. Powers the console's fleet view / partition picker;
295
+ * deliberately NOT partition-scoped (the one cross-partition read).
292
296
  */
293
297
  listClientRecords?(partition: string): Promise<ClientRecord[]>;
294
298
  listCommitMetadata?(
@@ -306,6 +310,7 @@ export interface ServerStorage {
306
310
  ): Promise<
307
311
  { serverVersion: number; scopes: Record<string, string> } | undefined
308
312
  >;
313
+ listPartitions?(): Promise<string[]>;
309
314
  }
310
315
 
311
316
  /** A row referencing a blob, with the scopes needed to authorize download. */