@syncular/server 0.31.0 → 0.32.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/dist/admin.js CHANGED
@@ -1,11 +1,33 @@
1
- import { clockOf } from './context.js';
2
- import { DEFAULT_RETENTION } from './prune.js';
3
- import { compileSchema } from './schema.js';
1
+ /**
2
+ * `SyncularAdmin` is the operator-facing surface over the server core: a
3
+ * partition-scoped, JSON-able query layer over `ServerStorage`, the optional
4
+ * segment/blob store stats, and an in-memory event ring, plus three explicit
5
+ * operator actions (retry a dead-lettered reaction, prune the commit log,
6
+ * revoke a client's lease). No separate UI package, no framework, and no
7
+ * wire-protocol surface. This host surface is mirrored in the server README.
8
+ *
9
+ * Nothing here is on the sync hot path. The additive optional storage/store
10
+ * methods it depends on are documented as such and this module fails loud (a
11
+ * thrown `Error`) when a backend lacks one, so a host wiring an unsupported
12
+ * store learns immediately rather than getting a silently-empty console.
13
+ */
14
+ import { bytesToBase64, decodeRow, renderRowValues, } from '@syncular/core';
15
+ import { clockOf, RESOLVER_OUTAGE, } from './context.js';
16
+ import { SyncError } from './errors.js';
17
+ import { emitEvent, } from './events.js';
18
+ import { RealtimeHub } from './realtime.js';
19
+ import { DEFAULT_RETENTION, pruneCommitLog, } from './prune.js';
20
+ import { compileSchema, } from './schema.js';
21
+ import { computeEffective, matchesEffective } from './scopes.js';
22
+ const DEFAULT_CLIENT_LIMIT = 200;
23
+ const DEFAULT_PARTITION_LIMIT = 100;
4
24
  const DEFAULT_COMMIT_LIMIT = 50;
5
25
  const DEFAULT_SCOPE_LIMIT = 50;
6
26
  const DEFAULT_REACTION_LIMIT = 100;
7
27
  const DEFAULT_CLIENT_EVENT_LIMIT = 100;
8
- const DEFAULT_METRICS_WINDOW_MS = 5 * 60 * 1000;
28
+ const DEFAULT_ROW_HISTORY_LIMIT = 20;
29
+ const DEFAULT_ROW_PAGE_LIMIT = 50;
30
+ const DEFAULT_WINDOW_MS = 5 * 60 * 1000;
9
31
  const DEFAULT_METRICS_BUCKETS = 30;
10
32
  /** Nearest-rank percentile over an unsorted sample; 0 for an empty one. */
11
33
  function percentile(sample, q) {
@@ -21,28 +43,77 @@ function required(value, what) {
21
43
  }
22
44
  return value;
23
45
  }
24
- function toAdminClient(record, active, maxCommitSeq) {
25
- return {
26
- clientId: record.clientId,
27
- actorId: record.actorId,
28
- cursor: record.cursor,
29
- // A never-pulled cursor (-1) has seen nothing: it lags the whole log.
30
- lag: Math.max(0, maxCommitSeq - Math.max(0, record.cursor)),
31
- updatedAtMs: record.updatedAtMs,
32
- subscriptions: record.subscriptions.map((sub) => ({
46
+ /** Verdicts in evaluation order; `rowDelivery` sorts by it. */
47
+ const VERDICT_ORDER = [
48
+ 'client-unknown',
49
+ 'row-missing',
50
+ 'no-subscription',
51
+ 'row-deleted',
52
+ 'scope-mismatch',
53
+ 'scope-denied',
54
+ 'pending-pull',
55
+ 'delivered',
56
+ ];
57
+ /** One known client's subscriptions on the row's table and its verdict. */
58
+ function traceVerdict(record, row, lastChange, horizonSeq, allowed) {
59
+ const rowScopes = row.scopes ?? {};
60
+ const subscriptions = record.subscriptions
61
+ .filter((sub) => sub.table === row.table)
62
+ .map((sub) => {
63
+ const mismatches = Object.entries(sub.scopes)
64
+ .filter(([variable, values]) => {
65
+ const value = rowScopes[variable];
66
+ return value === undefined || !values.includes(value);
67
+ })
68
+ .map(([variable, values]) => ({
69
+ variable,
70
+ rowValue: rowScopes[variable] ?? null,
71
+ requested: values,
72
+ }));
73
+ const traced = {
33
74
  id: sub.id,
34
- table: sub.table,
35
75
  scopes: sub.scopes,
36
- })),
37
- active,
38
- };
76
+ requestedMatch: mismatches.length === 0,
77
+ mismatches,
78
+ };
79
+ if (allowed === undefined || allowed === 'outage')
80
+ return traced;
81
+ const effective = computeEffective(sub.scopes, { ok: true, allowed });
82
+ return {
83
+ ...traced,
84
+ effectiveMatch: effective.status === 'active' &&
85
+ matchesEffective(rowScopes, effective.effective),
86
+ };
87
+ });
88
+ // With no retained change left, the newest one sits at or below the
89
+ // horizon, so a cursor at the horizon has pulled it.
90
+ const pulled = record.cursor >= (lastChange?.commitSeq ?? horizonSeq);
91
+ const verdict = !row.exists && lastChange === undefined
92
+ ? 'row-missing'
93
+ : subscriptions.length === 0
94
+ ? 'no-subscription'
95
+ : !row.exists
96
+ ? pulled
97
+ ? 'row-deleted'
98
+ : 'pending-pull'
99
+ : !subscriptions.some((sub) => sub.requestedMatch)
100
+ ? 'scope-mismatch'
101
+ : subscriptions.every((sub) => sub.effectiveMatch === false)
102
+ ? 'scope-denied'
103
+ : pulled
104
+ ? 'delivered'
105
+ : 'pending-pull';
106
+ return { subscriptions, verdict };
39
107
  }
40
- /** The read-only console query surface. Construct one per host process. */
108
+ /** The console query surface plus its operator actions. One per process. */
41
109
  export class SyncularAdmin {
42
110
  #storage;
43
111
  #schema;
44
112
  #checkpoints;
45
113
  #ring;
114
+ #events;
115
+ #resolveScopes;
116
+ #realtime;
46
117
  #segments;
47
118
  #blobs;
48
119
  #leases;
@@ -50,12 +121,20 @@ export class SyncularAdmin {
50
121
  #clock;
51
122
  constructor(options) {
52
123
  this.#storage = options.storage;
53
- if (options.schema !== undefined)
54
- this.#schema = options.schema;
124
+ if (options.schema !== undefined) {
125
+ this.#schema = compileSchema(options.schema);
126
+ }
55
127
  if (options.checkpoints !== undefined)
56
128
  this.#checkpoints = options.checkpoints;
57
129
  if (options.ring !== undefined)
58
130
  this.#ring = options.ring;
131
+ if (options.events !== undefined)
132
+ this.#events = options.events;
133
+ if (options.resolveScopes !== undefined) {
134
+ this.#resolveScopes = options.resolveScopes;
135
+ }
136
+ if (options.realtime !== undefined)
137
+ this.#realtime = options.realtime;
59
138
  if (options.segments !== undefined)
60
139
  this.#segments = options.segments;
61
140
  if (options.blobs !== undefined)
@@ -66,9 +145,10 @@ export class SyncularAdmin {
66
145
  this.#clock = options.clock ?? Date.now;
67
146
  }
68
147
  /**
69
- * Build an admin over a `SyncServerConfig`, reusing its storage / segment
70
- * / blob store / clock. Pass the ring separately (it is an events sink,
71
- * composed into the config's `events` by the host — see `composeEvents`).
148
+ * Build an admin over a `SyncServerConfig`, reusing its storage, schema,
149
+ * resolver, events sink, stores, leases and clock. Pass the ring
150
+ * separately (it is an events sink, composed into the config's `events` by
151
+ * the host — see `composeEvents`).
72
152
  */
73
153
  static fromConfig(config, extra) {
74
154
  return new SyncularAdmin({
@@ -77,6 +157,11 @@ export class SyncularAdmin {
77
157
  ...(config.checkpoints !== undefined
78
158
  ? { checkpoints: config.checkpoints }
79
159
  : {}),
160
+ resolveScopes: config.resolveScopes,
161
+ ...(config.realtime instanceof RealtimeHub
162
+ ? { realtime: config.realtime }
163
+ : {}),
164
+ ...(config.events !== undefined ? { events: config.events } : {}),
80
165
  segments: config.segments,
81
166
  ...(config.blobs !== undefined ? { blobs: config.blobs } : {}),
82
167
  ...(config.leases !== undefined ? { leases: config.leases.store } : {}),
@@ -85,35 +170,53 @@ export class SyncularAdmin {
85
170
  clock: clockOf(config),
86
171
  });
87
172
  }
88
- /** The known clients for a partition (cursor, lag, subscriptions). */
89
- async listClients(partition) {
173
+ /** The server schema version; `undefined` without a schema. */
174
+ get schemaVersion() {
175
+ return this.#schema?.version;
176
+ }
177
+ /** The schema tables and columns, for row and commit pickers. */
178
+ tables() {
179
+ return [...(this.#schema?.tables.values() ?? [])].map((table) => ({
180
+ name: table.name,
181
+ columns: table.columns.map((column, i) => ({
182
+ name: column.name,
183
+ type: column.type,
184
+ nullable: column.nullable,
185
+ primaryKey: i === table.primaryKeyIndex,
186
+ })),
187
+ }));
188
+ }
189
+ /** The known clients for a partition (cursor, lag, versions, subs). */
190
+ async listClients(partition, options = {}) {
90
191
  const list = required(this.#storage.listClientRecords?.bind(this.#storage), 'storage');
91
- const records = await list(partition);
92
- const maxCommitSeq = await this.#storage.getMaxCommitSeq(partition);
93
- const activeFloorMs = this.#clock() - this.#retention.activeWindowMs;
94
- return records.map((record) => toAdminClient(record, record.updatedAtMs >= activeFloorMs, maxCommitSeq));
192
+ const view = await this.#clientView(partition);
193
+ const clients = (await list(partition)).map(view);
194
+ clients.sort((a, b) => options.sort === 'lag' ? b.lag - a.lag : b.updatedAtMs - a.updatedAtMs);
195
+ return {
196
+ clients: clients.slice(0, options.limit ?? DEFAULT_CLIENT_LIMIT),
197
+ total: clients.length,
198
+ };
95
199
  }
96
200
  /**
97
201
  * One client's drill-down: its record (with lag), its §7.3 lease when a
98
- * lease store is wired, and its recent slice of the event tail. Answers
99
- * "why is this client stale" in a single read.
202
+ * lease store is wired, its realtime state, and its recent slice of the
203
+ * event tail. Answers "why is this client stale" in a single read.
100
204
  */
101
205
  async clientDetail(partition, clientId, options = {}) {
102
206
  const record = await this.#storage.getClientRecord(partition, clientId);
103
207
  const events = this.events({
208
+ partition,
104
209
  clientId,
105
210
  limit: options.eventLimit ?? DEFAULT_CLIENT_EVENT_LIMIT,
106
211
  });
107
212
  if (record === undefined) {
108
213
  return { clientId, exists: false, events };
109
214
  }
110
- const maxCommitSeq = await this.#storage.getMaxCommitSeq(partition);
111
- const activeFloorMs = this.#clock() - this.#retention.activeWindowMs;
112
215
  const lease = await this.#leases?.get(partition, clientId);
113
216
  return {
114
217
  clientId,
115
218
  exists: true,
116
- client: toAdminClient(record, record.updatedAtMs >= activeFloorMs, maxCommitSeq),
219
+ client: (await this.#clientView(partition))(record),
117
220
  ...(lease !== undefined ? { lease } : {}),
118
221
  events,
119
222
  };
@@ -123,10 +226,56 @@ export class SyncularAdmin {
123
226
  const read = required(this.#storage.listCommitMetadata?.bind(this.#storage), 'storage');
124
227
  return read(partition, {
125
228
  afterSeq: options.afterSeq ?? 0,
229
+ ...(options.beforeSeq !== undefined
230
+ ? { beforeSeq: options.beforeSeq }
231
+ : {}),
126
232
  limit: options.limit ?? DEFAULT_COMMIT_LIMIT,
127
233
  ...(options.table !== undefined ? { table: options.table } : {}),
234
+ ...(options.changeSample !== undefined
235
+ ? { changeSample: options.changeSample }
236
+ : {}),
128
237
  });
129
238
  }
239
+ /**
240
+ * One retained commit with every change rendered per §11. Throws
241
+ * `sync.not_found` when the commit does not exist or was pruned.
242
+ */
243
+ async commitDetail(partition, commitSeq) {
244
+ const read = required(this.#storage.getCommit?.bind(this.#storage), 'storage');
245
+ const schema = this.#requireSchema();
246
+ const commit = await read(partition, commitSeq);
247
+ if (commit === undefined) {
248
+ throw new SyncError('sync.not_found', 'commit is not in the retained log', JSON.stringify({ commitSeq }));
249
+ }
250
+ return {
251
+ ...commit,
252
+ changes: commit.changes.map((change) => {
253
+ const base = {
254
+ table: change.table,
255
+ rowId: change.rowId,
256
+ op: change.op,
257
+ ...(change.rowVersion !== undefined
258
+ ? { rowVersion: change.rowVersion }
259
+ : {}),
260
+ scopes: change.scopes,
261
+ };
262
+ const table = schema.tables.get(change.table);
263
+ if (change.payload === undefined)
264
+ return base;
265
+ if (table === undefined)
266
+ return { ...base, undecodable: true };
267
+ try {
268
+ return {
269
+ ...base,
270
+ values: renderRowValues(table.columns, decodeRow(table.columns, change.payload)),
271
+ };
272
+ }
273
+ catch {
274
+ return { ...base, undecodable: true };
275
+ }
276
+ }),
277
+ };
278
+ }
130
279
  /** Pending, leased, completed, and dead-lettered durable reactions. */
131
280
  async listReactions(partition, options = {}) {
132
281
  const read = required(this.#storage.listReactions?.bind(this.#storage), 'storage');
@@ -137,18 +286,26 @@ export class SyncularAdmin {
137
286
  });
138
287
  }
139
288
  /**
140
- * Inspect a single row: current server_version, stored scopes, and the
141
- * blobIds it references (when the store tracks references). Payload bytes
142
- * are deliberately NOT decoded — the console shows metadata, not content.
289
+ * Inspect a single row: server_version, stored scopes, its values decoded
290
+ * per §11, and its retained change history (newest first). Throws
291
+ * `sync.unknown_table` for a table outside the schema.
143
292
  */
144
- async inspectRow(partition, table, rowId) {
145
- const read = required(this.#storage.getRowScopes?.bind(this.#storage), 'storage');
146
- if (this.#schema !== undefined) {
147
- await this.#storage.ensureSchema(compileSchema(this.#schema), this.#checkpoints);
293
+ async inspectRow(partition, table, rowId, options = {}) {
294
+ const schema = this.#requireSchema();
295
+ const compiled = schema.tables.get(table);
296
+ if (compiled === undefined) {
297
+ throw new SyncError('sync.unknown_table', 'table is not in the server schema', JSON.stringify({ table }));
148
298
  }
149
- const row = await read(partition, table, rowId);
299
+ await this.#storage.ensureSchema(schema, this.#checkpoints);
300
+ const listChanges = required(this.#storage.listRowChanges?.bind(this.#storage), 'storage');
301
+ const history = await listChanges(partition, {
302
+ table,
303
+ rowId,
304
+ limit: options.historyLimit ?? DEFAULT_ROW_HISTORY_LIMIT,
305
+ });
306
+ const row = await this.#storage.getRow(partition, table, rowId);
150
307
  if (row === undefined) {
151
- return { table, rowId, exists: false };
308
+ return { table, rowId, exists: false, history };
152
309
  }
153
310
  return {
154
311
  table,
@@ -156,6 +313,242 @@ export class SyncularAdmin {
156
313
  exists: true,
157
314
  serverVersion: row.serverVersion,
158
315
  scopes: row.scopes,
316
+ values: renderRowValues(compiled.columns, decodeRow(compiled.columns, row.payload)),
317
+ history,
318
+ };
319
+ }
320
+ /**
321
+ * Trace whether one client receives one row, combining the row's stored
322
+ * scopes and newest change with the client's subscriptions, cursor, the
323
+ * actor's allowed scopes, and its retained push failures. See
324
+ * `AdminTraceVerdict` for the verdict order.
325
+ */
326
+ async traceRow(partition, query) {
327
+ const { table, rowId, clientId } = query;
328
+ const row = await this.inspectRow(partition, table, rowId, {
329
+ historyLimit: 1,
330
+ });
331
+ const lastChange = row.history[0];
332
+ const horizonSeq = await this.#storage.getHorizonSeq(partition);
333
+ const rejections = this.events({ partition, clientId }).filter((event) => event.type === 'push.rejected' || event.type === 'push.conflicted');
334
+ const record = await this.#storage.getClientRecord(partition, clientId);
335
+ const base = {
336
+ table,
337
+ rowId,
338
+ clientId,
339
+ row,
340
+ horizonSeq,
341
+ rejections,
342
+ ...(lastChange !== undefined ? { lastChange } : {}),
343
+ };
344
+ if (record === undefined) {
345
+ return { ...base, verdict: 'client-unknown', subscriptions: [] };
346
+ }
347
+ const allowed = await this.#allowedScopes(partition, record);
348
+ return {
349
+ ...base,
350
+ client: (await this.#clientView(partition))(record),
351
+ ...(allowed !== undefined ? { allowed } : {}),
352
+ ...traceVerdict(record, row, lastChange, horizonSeq, allowed),
353
+ };
354
+ }
355
+ /**
356
+ * Who has one row and who does not: the trace verdict for every known
357
+ * client in the partition, resolving each actor's allowed scopes once.
358
+ */
359
+ async rowDelivery(partition, table, rowId) {
360
+ const list = required(this.#storage.listClientRecords?.bind(this.#storage), 'storage');
361
+ const row = await this.inspectRow(partition, table, rowId, {
362
+ historyLimit: 1,
363
+ });
364
+ const lastChange = row.history[0];
365
+ const horizonSeq = await this.#storage.getHorizonSeq(partition);
366
+ const view = await this.#clientView(partition);
367
+ const allowedByActor = new Map();
368
+ const clients = [];
369
+ for (const record of await list(partition)) {
370
+ if (!allowedByActor.has(record.actorId)) {
371
+ allowedByActor.set(record.actorId, await this.#allowedScopes(partition, record));
372
+ }
373
+ clients.push({
374
+ client: view(record),
375
+ verdict: traceVerdict(record, row, lastChange, horizonSeq, allowedByActor.get(record.actorId)).verdict,
376
+ });
377
+ }
378
+ clients.sort((a, b) => VERDICT_ORDER.indexOf(a.verdict) - VERDICT_ORDER.indexOf(b.verdict) ||
379
+ b.client.updatedAtMs - a.client.updatedAtMs);
380
+ return {
381
+ table,
382
+ rowId,
383
+ row,
384
+ ...(lastChange !== undefined ? { lastChange } : {}),
385
+ horizonSeq,
386
+ clients,
387
+ };
388
+ }
389
+ /**
390
+ * The partition's actors with their devices (clients), open sockets,
391
+ * worst lag, and failures in the last 5 minutes; most recently seen
392
+ * first.
393
+ */
394
+ async actors(partition) {
395
+ const { clients } = await this.listClients(partition, {
396
+ limit: Number.MAX_SAFE_INTEGER,
397
+ });
398
+ // The same per-client tally as `errors()`, so People and the overview's
399
+ // failure panel agree.
400
+ const failures = new Map();
401
+ for (const client of this.errors(partition).byClient) {
402
+ failures.set(client.actorId, (failures.get(client.actorId) ?? 0) + client.count);
403
+ }
404
+ const byActor = new Map();
405
+ for (const client of clients) {
406
+ byActor.set(client.actorId, [
407
+ ...(byActor.get(client.actorId) ?? []),
408
+ client,
409
+ ]);
410
+ }
411
+ return [...byActor].map(([actorId, devices]) => ({
412
+ actorId,
413
+ clients: devices,
414
+ ...(this.#realtime !== undefined
415
+ ? { connected: devices.filter((device) => device.connected).length }
416
+ : {}),
417
+ lastSeenMs: Math.max(...devices.map((device) => device.updatedAtMs)),
418
+ maxLag: Math.max(...devices.map((device) => device.lag)),
419
+ recentFailures: failures.get(actorId) ?? 0,
420
+ }));
421
+ }
422
+ /** Row counts per schema table for the partition. */
423
+ async tableCounts(partition) {
424
+ const count = required(this.#storage.countRows?.bind(this.#storage), 'storage');
425
+ const schema = this.#requireSchema();
426
+ await this.#storage.ensureSchema(schema, this.#checkpoints);
427
+ const counts = {};
428
+ for (const table of schema.tables.keys()) {
429
+ counts[table] = await count(partition, table);
430
+ }
431
+ return counts;
432
+ }
433
+ /**
434
+ * One page of a table's rows decoded per §11, ordered by rowId. With a
435
+ * `scope`, only rows whose stored scope matches, read through the scope
436
+ * index.
437
+ */
438
+ async listRows(partition, table, options = {}) {
439
+ const schema = this.#requireSchema();
440
+ const compiled = schema.tables.get(table);
441
+ if (compiled === undefined) {
442
+ throw new SyncError('sync.unknown_table', 'table is not in the server schema', JSON.stringify({ table }));
443
+ }
444
+ await this.#storage.ensureSchema(schema, this.#checkpoints);
445
+ const limit = options.limit ?? DEFAULT_ROW_PAGE_LIMIT;
446
+ const afterRowId = options.afterRowId ?? null;
447
+ let rows;
448
+ if (options.scope !== undefined) {
449
+ if (!compiled.declaredVariables.has(options.scope.variable)) {
450
+ throw new SyncError('sync.invalid_request', 'the table declares no such scope variable', JSON.stringify({ table, variable: options.scope.variable }));
451
+ }
452
+ rows = await this.#storage.scanRows(partition, {
453
+ table,
454
+ scopeFilter: { [options.scope.variable]: [options.scope.value] },
455
+ afterRowId,
456
+ limit,
457
+ });
458
+ }
459
+ else {
460
+ const list = required(this.#storage.listRows?.bind(this.#storage), 'storage');
461
+ rows = await list(partition, { table, afterRowId, limit });
462
+ }
463
+ const last = rows.at(-1);
464
+ return {
465
+ table,
466
+ rows: rows.map((row) => ({
467
+ rowId: row.rowId,
468
+ serverVersion: row.serverVersion,
469
+ scopes: row.scopes,
470
+ values: renderRowValues(compiled.columns, decodeRow(compiled.columns, row.payload)),
471
+ })),
472
+ ...(rows.length === limit && last !== undefined
473
+ ? { nextAfterRowId: last.rowId }
474
+ : {}),
475
+ };
476
+ }
477
+ /**
478
+ * What happened to a write: every stored idempotency outcome for the
479
+ * clientCommitId (applied with its commitSeq, or rejected with the
480
+ * per-operation results), plus the retained ring events that carry it.
481
+ */
482
+ async findWrite(partition, clientCommitId) {
483
+ const list = required(this.#storage.listPushResults?.bind(this.#storage), 'storage');
484
+ const outcomes = (await list(partition, clientCommitId)).map(({ clientId, result }) => ({
485
+ clientId,
486
+ status: result.status,
487
+ ...(result.commitSeq !== undefined
488
+ ? { commitSeq: result.commitSeq }
489
+ : {}),
490
+ ...(result.recordedAtMs !== undefined
491
+ ? { recordedAtMs: result.recordedAtMs }
492
+ : {}),
493
+ results: result.results.map((entry) => entry.status === 'conflict'
494
+ ? {
495
+ ...entry,
496
+ serverRow: bytesToBase64(entry.serverRow),
497
+ conflictColumns: bytesToBase64(entry.conflictColumns),
498
+ }
499
+ : entry),
500
+ }));
501
+ const events = this.events({ partition }).filter((event) => 'clientCommitId' in event && event.clientCommitId === clientCommitId);
502
+ return { clientCommitId, outcomes, events };
503
+ }
504
+ /**
505
+ * Resolve an id pasted from a bug report: clients and actors containing
506
+ * it, rows with that rowId in any table, the commit with that seq, and
507
+ * writes with that clientCommitId.
508
+ */
509
+ async search(partition, query) {
510
+ const needle = query.trim();
511
+ if (needle.length === 0) {
512
+ throw new SyncError('sync.invalid_request', 'search query is empty');
513
+ }
514
+ const { clients } = await this.listClients(partition, {
515
+ limit: Number.MAX_SAFE_INTEGER,
516
+ });
517
+ const schema = this.#requireSchema();
518
+ await this.#storage.ensureSchema(schema, this.#checkpoints);
519
+ const rows = [];
520
+ for (const table of schema.tables.keys()) {
521
+ if ((await this.#storage.getRow(partition, table, needle)) !== undefined) {
522
+ rows.push({ table, rowId: needle });
523
+ }
524
+ }
525
+ const read = required(this.#storage.getCommit?.bind(this.#storage), 'storage');
526
+ const commit = /^\d+$/.test(needle)
527
+ ? await read(partition, Number.parseInt(needle, 10))
528
+ : undefined;
529
+ return {
530
+ query: needle,
531
+ clients: clients.filter((client) => client.clientId.includes(needle) || client.actorId === needle),
532
+ actors: [
533
+ ...new Set(clients
534
+ .map((client) => client.actorId)
535
+ .filter((actorId) => actorId.includes(needle))),
536
+ ],
537
+ rows,
538
+ ...(commit !== undefined
539
+ ? {
540
+ commit: {
541
+ commitSeq: commit.commitSeq,
542
+ clientId: commit.clientId,
543
+ clientCommitId: commit.clientCommitId,
544
+ actorId: commit.actorId,
545
+ createdAtMs: commit.createdAtMs,
546
+ changeCount: commit.changeCount,
547
+ tables: commit.tables,
548
+ },
549
+ }
550
+ : {}),
551
+ writes: (await this.findWrite(partition, needle)).outcomes,
159
552
  };
160
553
  }
161
554
  /**
@@ -204,13 +597,9 @@ export class SyncularAdmin {
204
597
  ...(blobs !== undefined ? { blobs } : {}),
205
598
  };
206
599
  }
207
- /** Segment store counters alone (undefined when unsupported/unset). */
208
- async segmentStats() {
209
- return this.#segments?.stats?.();
210
- }
211
- /** Blob store counters for a partition (undefined when unsupported/unset). */
212
- async blobStats(partition) {
213
- return this.#blobs?.stats?.(partition);
600
+ /** True when a realtime hub is wired: clients report `connected`. */
601
+ get hasRealtime() {
602
+ return this.#realtime !== undefined;
214
603
  }
215
604
  /** True when this admin has an event ring wired (the event tail is live). */
216
605
  get hasEventStream() {
@@ -231,58 +620,178 @@ export class SyncularAdmin {
231
620
  /**
232
621
  * Request/push health over a trailing window, derived entirely from the
233
622
  * ring (zero new server state): rates, error share, duration percentiles,
234
- * and per-bucket counts for the console's sparkline. Only events carrying
235
- * this `partition` count. Empty (all zeros) when no ring is wired.
623
+ * and per-bucket series. Only events carrying this `partition` count.
624
+ * `coverage` reports when the ring dropped events inside the window.
236
625
  */
237
626
  metrics(partition, options = {}) {
238
- const windowMs = options.windowMs ?? DEFAULT_METRICS_WINDOW_MS;
627
+ const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
239
628
  const bucketCount = options.buckets ?? DEFAULT_METRICS_BUCKETS;
240
629
  const atMs = this.#clock();
241
630
  const sinceMs = atMs - windowMs;
242
631
  const widthMs = windowMs / bucketCount;
243
632
  const counts = new Array(bucketCount).fill(0);
244
633
  const errors = new Array(bucketCount).fill(0);
634
+ const bucketDurations = Array.from({ length: bucketCount }, () => []);
635
+ const bucketPushes = Array.from({ length: bucketCount }, () => ({
636
+ applied: 0,
637
+ rejected: 0,
638
+ conflicted: 0,
639
+ }));
245
640
  const durations = [];
246
- let requestCount = 0;
247
641
  let errorCount = 0;
248
- let applied = 0;
249
- let rejected = 0;
250
- let conflicted = 0;
251
- for (const event of this.events({ sinceMs })) {
252
- if (event.partition !== partition)
253
- continue;
254
- if (event.type === 'push.applied')
255
- applied += 1;
256
- else if (event.type === 'push.rejected')
257
- rejected += 1;
258
- else if (event.type === 'push.conflicted')
259
- conflicted += 1;
642
+ const pushes = { applied: 0, rejected: 0, conflicted: 0 };
643
+ for (const event of this.events({ partition, sinceMs })) {
644
+ const bucket = Math.min(bucketCount - 1, Math.max(0, Math.floor((event.atMs - sinceMs) / widthMs)));
645
+ const outcome = event.type === 'push.applied'
646
+ ? 'applied'
647
+ : event.type === 'push.rejected'
648
+ ? 'rejected'
649
+ : event.type === 'push.conflicted'
650
+ ? 'conflicted'
651
+ : undefined;
652
+ if (outcome !== undefined) {
653
+ pushes[outcome] += 1;
654
+ const slot = bucketPushes[bucket];
655
+ if (slot !== undefined)
656
+ slot[outcome] += 1;
657
+ }
260
658
  if (event.type !== 'request.handled')
261
659
  continue;
262
- requestCount += 1;
263
660
  durations.push(event.durationMs);
264
- const failed = event.outcome === 'rejected' || event.outcome === 'error';
265
- if (failed)
266
- errorCount += 1;
267
- const bucket = Math.min(bucketCount - 1, Math.max(0, Math.floor((event.atMs - sinceMs) / widthMs)));
661
+ bucketDurations[bucket]?.push(event.durationMs);
268
662
  counts[bucket] = (counts[bucket] ?? 0) + 1;
269
- if (failed)
663
+ if (event.outcome === 'rejected' || event.outcome === 'error') {
664
+ errorCount += 1;
270
665
  errors[bucket] = (errors[bucket] ?? 0) + 1;
666
+ }
271
667
  }
272
668
  return {
273
669
  partition,
274
670
  windowMs,
275
671
  atMs,
672
+ coverage: this.#coverage(sinceMs),
276
673
  requests: {
277
- count: requestCount,
278
- perMinute: requestCount / (windowMs / 60_000),
674
+ count: durations.length,
675
+ perMinute: durations.length / (windowMs / 60_000),
279
676
  errorCount,
280
- errorRate: requestCount === 0 ? 0 : errorCount / requestCount,
677
+ errorRate: durations.length === 0 ? 0 : errorCount / durations.length,
281
678
  p50Ms: percentile(durations, 0.5),
282
679
  p95Ms: percentile(durations, 0.95),
283
680
  },
284
- pushes: { applied, rejected, conflicted },
285
- buckets: { widthMs, counts, errors },
681
+ pushes,
682
+ buckets: {
683
+ widthMs,
684
+ counts,
685
+ errors,
686
+ p95Ms: bucketDurations.map((sample) => sample.length === 0 ? null : percentile(sample, 0.95)),
687
+ pushes: bucketPushes,
688
+ },
689
+ };
690
+ }
691
+ /**
692
+ * Failures over a trailing window, grouped by source and code and by
693
+ * client: failed requests, push rejections and conflicts, dead-lettered
694
+ * reactions, and throwing scope resolvers. Derived from the ring.
695
+ */
696
+ errors(partition, options = {}) {
697
+ const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
698
+ const atMs = this.#clock();
699
+ const sinceMs = atMs - windowMs;
700
+ const groups = new Map();
701
+ const byClient = new Map();
702
+ // Newest first, so the first event per group or client is its latest.
703
+ for (const event of this.events({ partition, sinceMs })) {
704
+ let source;
705
+ let code;
706
+ let clientId;
707
+ let actorId;
708
+ if (event.type === 'request.handled') {
709
+ if (event.outcome !== 'rejected' && event.outcome !== 'error')
710
+ continue;
711
+ source = 'request';
712
+ code = event.errorCode ?? 'sync.internal_error';
713
+ clientId = event.clientId;
714
+ actorId = event.actorId;
715
+ }
716
+ else if (event.type === 'push.rejected') {
717
+ source = 'push';
718
+ code = event.code;
719
+ clientId = event.clientId;
720
+ actorId = event.actorId;
721
+ }
722
+ else if (event.type === 'push.conflicted') {
723
+ source = 'push';
724
+ code = 'sync.version_conflict';
725
+ clientId = event.clientId;
726
+ actorId = event.actorId;
727
+ }
728
+ else if (event.type === 'reaction.dead_lettered') {
729
+ source = 'reaction';
730
+ code = event.errorCode;
731
+ }
732
+ else if (event.type === 'scopes.resolve_failed') {
733
+ source = 'scopes';
734
+ code = 'scopes.resolve_failed';
735
+ actorId = event.actorId;
736
+ }
737
+ else {
738
+ continue;
739
+ }
740
+ const key = `${source} ${code}`;
741
+ const group = groups.get(key) ?? {
742
+ source,
743
+ code,
744
+ count: 0,
745
+ clients: new Set(),
746
+ actors: new Set(),
747
+ rows: new Map(),
748
+ lastAtMs: event.atMs,
749
+ };
750
+ group.count += 1;
751
+ if ((event.type === 'push.rejected' || event.type === 'push.conflicted') &&
752
+ event.table !== undefined &&
753
+ event.rowId !== undefined) {
754
+ group.rows.set(`${event.table} ${event.rowId}`, {
755
+ table: event.table,
756
+ rowId: event.rowId,
757
+ });
758
+ }
759
+ if (clientId !== undefined)
760
+ group.clients.add(clientId);
761
+ if (actorId !== undefined)
762
+ group.actors.add(actorId);
763
+ groups.set(key, group);
764
+ if (clientId !== undefined && actorId !== undefined) {
765
+ const seen = byClient.get(clientId);
766
+ byClient.set(clientId, seen !== undefined
767
+ ? { ...seen, count: seen.count + 1 }
768
+ : {
769
+ clientId,
770
+ actorId,
771
+ count: 1,
772
+ lastCode: code,
773
+ lastAtMs: event.atMs,
774
+ });
775
+ }
776
+ }
777
+ return {
778
+ partition,
779
+ windowMs,
780
+ atMs,
781
+ coverage: this.#coverage(sinceMs),
782
+ byCode: [...groups.values()]
783
+ .sort((a, b) => b.count - a.count)
784
+ .map((group) => ({
785
+ source: group.source,
786
+ code: group.code,
787
+ count: group.count,
788
+ clients: group.clients.size,
789
+ actors: group.actors.size,
790
+ lastAtMs: group.lastAtMs,
791
+ sampleClientIds: [...group.clients].slice(0, 5),
792
+ sampleRows: [...group.rows.values()].slice(0, 5),
793
+ })),
794
+ byClient: [...byClient.values()].sort((a, b) => b.count - a.count),
286
795
  };
287
796
  }
288
797
  /** Every authenticated partition in the storage-backed registry. */
@@ -291,14 +800,18 @@ export class SyncularAdmin {
291
800
  }
292
801
  /**
293
802
  * The fleet view: one row per known partition — retained backlog, client
294
- * counts, prune recommendation. A cross-partition read (every other
295
- * method is partition-scoped); host authorization should account for it.
803
+ * counts, prune recommendation — paged by `offset`/`limit` (default 100)
804
+ * because each row costs several reads. A cross-partition read (every
805
+ * other method is partition-scoped); host authorization should account
806
+ * for it.
296
807
  */
297
- async partitionsOverview() {
808
+ async partitionsOverview(options = {}) {
298
809
  const partitions = await this.listPartitions();
810
+ const offset = options.offset ?? 0;
811
+ const page = partitions.slice(offset, offset + (options.limit ?? DEFAULT_PARTITION_LIMIT));
299
812
  const activeFloorMs = this.#clock() - this.#retention.activeWindowMs;
300
813
  const out = [];
301
- for (const partition of partitions) {
814
+ for (const partition of page) {
302
815
  const status = await this.horizonStatus(partition);
303
816
  const cursors = await this.#storage.listClientCursors(partition);
304
817
  out.push({
@@ -312,6 +825,125 @@ export class SyncularAdmin {
312
825
  recommendation: status.recommendation,
313
826
  });
314
827
  }
315
- return out;
828
+ return { partitions: out, total: partitions.length };
829
+ }
830
+ /**
831
+ * Operator action: requeue a dead-lettered reaction. Throws
832
+ * `sync.invalid_request` when the key names no dead-lettered reaction.
833
+ */
834
+ async retryReaction(partition, idempotencyKey) {
835
+ const retry = required(this.#storage.retryReaction?.bind(this.#storage), 'storage');
836
+ const read = required(this.#storage.getReaction?.bind(this.#storage), 'storage');
837
+ if (!(await retry(partition, idempotencyKey, this.#clock()))) {
838
+ throw new SyncError('sync.invalid_request', 'no dead-lettered reaction with this key', JSON.stringify({ idempotencyKey }));
839
+ }
840
+ const reaction = await read(partition, idempotencyKey);
841
+ if (reaction === undefined) {
842
+ throw new Error('SyncularAdmin: a retried reaction disappeared');
843
+ }
844
+ return reaction;
845
+ }
846
+ /** Operator action: one §4.6 prune pass now, emitting `prune.completed`. */
847
+ async prune(partition) {
848
+ const previousHorizonSeq = await this.#storage.getHorizonSeq(partition);
849
+ const horizonSeq = await pruneCommitLog({
850
+ storage: this.#storage,
851
+ partition,
852
+ nowMs: this.#clock(),
853
+ retention: this.#retention,
854
+ ...(this.#events !== undefined ? { events: this.#events } : {}),
855
+ });
856
+ return { previousHorizonSeq, horizonSeq };
857
+ }
858
+ /**
859
+ * Operator action: revoke the client's §7.3 lease (§7.3.4), emitting
860
+ * `lease.revoked`. Throws `sync.invalid_request` when the client holds no
861
+ * lease.
862
+ */
863
+ async revokeLease(partition, clientId) {
864
+ const leases = required(this.#leases, 'lease store');
865
+ const lease = await leases.get(partition, clientId);
866
+ if (lease === undefined) {
867
+ throw new SyncError('sync.invalid_request', 'the client holds no lease', JSON.stringify({ clientId }));
868
+ }
869
+ await leases.revoke(partition, lease.leaseId);
870
+ if (this.#events !== undefined) {
871
+ emitEvent(this.#events, {
872
+ type: 'lease.revoked',
873
+ atMs: this.#clock(),
874
+ partition,
875
+ leaseId: lease.leaseId,
876
+ });
877
+ }
878
+ return { ...lease, revoked: true };
879
+ }
880
+ #requireSchema() {
881
+ if (this.#schema === undefined) {
882
+ throw new Error('SyncularAdmin: row reads require the server schema');
883
+ }
884
+ return this.#schema;
885
+ }
886
+ /**
887
+ * Map client records to `AdminClient` with one read of the head, the
888
+ * active floor, and the hub's open sockets for the partition.
889
+ */
890
+ async #clientView(partition) {
891
+ const maxCommitSeq = await this.#storage.getMaxCommitSeq(partition);
892
+ const activeFloorMs = this.#clock() - this.#retention.activeWindowMs;
893
+ const sockets = this.#realtime?.connections(partition);
894
+ const connectedSince = new Map();
895
+ for (const socket of sockets ?? []) {
896
+ const since = connectedSince.get(socket.clientId);
897
+ if (since === undefined || socket.openedAtMs < since) {
898
+ connectedSince.set(socket.clientId, socket.openedAtMs);
899
+ }
900
+ }
901
+ return (record) => {
902
+ let schemaVersion;
903
+ for (const sub of record.subscriptions) {
904
+ if (sub.schemaVersion !== undefined &&
905
+ (schemaVersion === undefined || sub.schemaVersion > schemaVersion)) {
906
+ schemaVersion = sub.schemaVersion;
907
+ }
908
+ }
909
+ const connectedSinceMs = connectedSince.get(record.clientId);
910
+ return {
911
+ clientId: record.clientId,
912
+ actorId: record.actorId,
913
+ wireVersion: record.wireVersion,
914
+ ...(schemaVersion !== undefined ? { schemaVersion } : {}),
915
+ cursor: record.cursor,
916
+ // A never-pulled cursor (-1) has seen nothing: it lags the whole log.
917
+ lag: Math.max(0, maxCommitSeq - Math.max(0, record.cursor)),
918
+ updatedAtMs: record.updatedAtMs,
919
+ subscriptions: record.subscriptions.map((sub) => ({
920
+ id: sub.id,
921
+ table: sub.table,
922
+ scopes: sub.scopes,
923
+ })),
924
+ active: record.updatedAtMs >= activeFloorMs,
925
+ ...(sockets !== undefined
926
+ ? { connected: connectedSinceMs !== undefined }
927
+ : {}),
928
+ ...(connectedSinceMs !== undefined ? { connectedSinceMs } : {}),
929
+ };
930
+ };
931
+ }
932
+ /** The actor's allowed scopes from the host resolver, if one is wired. */
933
+ async #allowedScopes(partition, record) {
934
+ if (this.#resolveScopes === undefined)
935
+ return undefined;
936
+ const resolved = await this.#resolveScopes({
937
+ partition,
938
+ actorId: record.actorId,
939
+ clientId: record.clientId,
940
+ });
941
+ return resolved === RESOLVER_OUTAGE ? 'outage' : resolved;
942
+ }
943
+ #coverage(sinceMs) {
944
+ const evicted = this.#ring?.evictedThroughMs;
945
+ return evicted === undefined || evicted < sinceMs
946
+ ? { complete: true, fromMs: sinceMs }
947
+ : { complete: false, fromMs: evicted };
316
948
  }
317
949
  }