@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/README.md +124 -74
- package/dist/admin.d.ts +394 -50
- package/dist/admin.js +714 -82
- package/dist/d1-storage.d.ts +6 -5
- package/dist/d1-storage.js +128 -19
- package/dist/events-ring.d.ts +12 -2
- package/dist/events-ring.js +27 -2
- package/dist/events.d.ts +21 -3
- package/dist/handler.js +19 -6
- package/dist/postgres-storage.d.ts +6 -5
- package/dist/postgres-storage.js +102 -18
- package/dist/realtime.d.ts +15 -0
- package/dist/realtime.js +24 -0
- package/dist/sqlite-storage.d.ts +6 -5
- package/dist/sqlite-storage.js +115 -18
- package/dist/storage.d.ts +58 -7
- package/package.json +2 -2
- package/src/admin.ts +1215 -120
- package/src/d1-storage.ts +187 -23
- package/src/events-ring.ts +31 -2
- package/src/events.ts +27 -4
- package/src/handler.ts +20 -6
- package/src/postgres-storage.ts +173 -28
- package/src/realtime.ts +35 -1
- package/src/sqlite-storage.ts +181 -26
- package/src/storage.ts +72 -9
package/dist/admin.js
CHANGED
|
@@ -1,11 +1,33 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
70
|
-
*
|
|
71
|
-
* composed into the config's `events` by
|
|
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
|
|
89
|
-
|
|
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
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
return
|
|
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
|
|
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:
|
|
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:
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
208
|
-
|
|
209
|
-
return this.#
|
|
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
|
|
235
|
-
*
|
|
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 ??
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
278
|
-
perMinute:
|
|
674
|
+
count: durations.length,
|
|
675
|
+
perMinute: durations.length / (windowMs / 60_000),
|
|
279
676
|
errorCount,
|
|
280
|
-
errorRate:
|
|
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
|
|
285
|
-
buckets: {
|
|
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
|
|
295
|
-
*
|
|
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
|
|
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
|
}
|