@syncular/server 0.3.1 → 0.4.1
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 +41 -17
- package/dist/admin.d.ts +97 -1
- package/dist/admin.js +145 -3
- package/dist/d1-storage.d.ts +1 -0
- package/dist/d1-storage.js +0 -0
- package/dist/events-ring.d.ts +20 -1
- package/dist/events-ring.js +45 -4
- package/dist/postgres-storage.d.ts +1 -0
- package/dist/postgres-storage.js +7 -0
- package/dist/schema.js +1 -1
- package/dist/sqlite-storage.d.ts +1 -0
- package/dist/sqlite-storage.js +9 -0
- package/dist/storage.d.ts +5 -0
- package/package.json +2 -2
- package/src/admin.ts +228 -3
- package/src/d1-storage.ts +0 -0
- package/src/events-ring.ts +51 -3
- package/src/postgres-storage.ts +11 -0
- package/src/schema.ts +1 -1
- package/src/sqlite-storage.ts +12 -0
- package/src/storage.ts +5 -0
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})
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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
|
-
| `
|
|
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`)
|
|
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 /
|
|
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)
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
**
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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,
|
|
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,
|
|
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
|
}
|
package/dist/d1-storage.d.ts
CHANGED
package/dist/d1-storage.js
CHANGED
|
Binary file
|
package/dist/events-ring.d.ts
CHANGED
|
@@ -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?,
|
|
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. */
|
package/dist/events-ring.js
CHANGED
|
@@ -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?,
|
|
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 (
|
|
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
|
}
|
package/dist/postgres-storage.js
CHANGED
|
@@ -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((
|
|
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);
|
package/dist/sqlite-storage.d.ts
CHANGED
package/dist/sqlite-storage.js
CHANGED
|
@@ -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
|
+
"version": "0.4.1",
|
|
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.
|
|
56
|
+
"@syncular/core": "0.4.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(
|
|
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,
|
|
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
|
package/src/events-ring.ts
CHANGED
|
@@ -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?,
|
|
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 (
|
|
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;
|
package/src/postgres-storage.ts
CHANGED
|
@@ -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
|
-
(
|
|
243
|
+
(_column, index) =>
|
|
244
244
|
index !== primaryKeyIndex && !scopeColumnIndices.has(index),
|
|
245
245
|
);
|
|
246
246
|
const fullyEncrypted =
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -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. */
|