@syncular/server 0.15.44 → 0.15.46
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 +135 -5
- package/dist/admin.d.ts +12 -6
- package/dist/admin.js +11 -1
- package/dist/authoritative-query.d.ts +20 -0
- package/dist/authoritative-query.js +184 -0
- package/dist/blob-store.d.ts +2 -2
- package/dist/context.d.ts +11 -2
- package/dist/context.js +2 -0
- package/dist/d1-storage.d.ts +10 -1
- package/dist/d1-storage.js +219 -3
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +43 -1
- package/dist/events-ring.d.ts +1 -1
- package/dist/events-ring.js +1 -1
- package/dist/events.d.ts +52 -3
- package/dist/handler.js +6 -3
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/operations-realtime.d.ts +16 -0
- package/dist/operations-realtime.js +196 -0
- package/dist/operations.d.ts +97 -0
- package/dist/operations.js +392 -0
- package/dist/pg-executor.d.ts +1 -1
- package/dist/pg-executor.js +1 -1
- package/dist/postgres-fanout.d.ts +1 -1
- package/dist/postgres-storage.d.ts +11 -2
- package/dist/postgres-storage.js +223 -3
- package/dist/pull.js +1 -1
- package/dist/push.d.ts +8 -2
- package/dist/push.js +75 -21
- package/dist/reactions.d.ts +167 -0
- package/dist/reactions.js +442 -0
- package/dist/realtime.js +4 -1
- package/dist/relational-rows.d.ts +9 -9
- package/dist/relational-rows.js +9 -9
- package/dist/s3-blob-store.js +1 -1
- package/dist/s3-segment-store.js +1 -1
- package/dist/schema.d.ts +4 -5
- package/dist/schema.js +3 -3
- package/dist/seed.js +1 -1
- package/dist/segment-store.d.ts +3 -3
- package/dist/signed-url.js +2 -2
- package/dist/sigv4.d.ts +2 -4
- package/dist/sigv4.js +3 -3
- package/dist/sqlite-blob-store.d.ts +1 -1
- package/dist/sqlite-blob-store.js +1 -1
- package/dist/sqlite-dialect.d.ts +3 -4
- package/dist/sqlite-dialect.js +21 -1
- package/dist/sqlite-image.d.ts +1 -1
- package/dist/sqlite-lease-store.d.ts +1 -1
- package/dist/sqlite-lease-store.js +1 -1
- package/dist/sqlite-segment-store.d.ts +2 -2
- package/dist/sqlite-segment-store.js +2 -2
- package/dist/sqlite-storage.d.ts +11 -2
- package/dist/sqlite-storage.js +219 -4
- package/dist/storage.d.ts +112 -3
- package/dist/validate.js +1 -0
- package/package.json +2 -2
- package/src/admin.ts +30 -6
- package/src/authoritative-query.ts +218 -0
- package/src/blob-store.ts +0 -0
- package/src/context.ts +12 -2
- package/src/d1-storage.ts +355 -3
- package/src/errors.ts +43 -1
- package/src/events-ring.ts +1 -1
- package/src/events.ts +64 -2
- package/src/handler.ts +15 -3
- package/src/index.ts +33 -1
- package/src/operations-realtime.ts +272 -0
- package/src/operations.ts +720 -0
- package/src/pg-executor.ts +1 -1
- package/src/postgres-fanout.ts +1 -1
- package/src/postgres-storage.ts +355 -4
- package/src/pull.ts +1 -1
- package/src/push.ts +97 -29
- package/src/reactions.ts +741 -0
- package/src/realtime.ts +7 -1
- package/src/relational-rows.ts +9 -9
- package/src/s3-blob-store.ts +1 -1
- package/src/s3-segment-store.ts +1 -1
- package/src/schema.ts +6 -7
- package/src/seed.ts +1 -1
- package/src/segment-store.ts +3 -3
- package/src/signed-url.ts +2 -2
- package/src/sigv4.ts +3 -5
- package/src/sqlite-blob-store.ts +1 -1
- package/src/sqlite-dialect.ts +22 -3
- package/src/sqlite-image.ts +1 -1
- package/src/sqlite-lease-store.ts +1 -1
- package/src/sqlite-segment-store.ts +2 -2
- package/src/sqlite-storage.ts +369 -4
- package/src/storage.ts +168 -3
- package/src/validate.ts +1 -0
package/dist/sqlite-storage.js
CHANGED
|
@@ -1,18 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SQLite storage via `bun:sqlite` (dev-speed, dependency-free).
|
|
3
3
|
*
|
|
4
|
-
* Scope fanout is index-first
|
|
4
|
+
* Scope fanout is index-first: both the commit log and the
|
|
5
5
|
* current-row table carry a (table, variable, value) inverted index; reads
|
|
6
6
|
* select candidates from the index and verify the full multi-variable
|
|
7
7
|
* match against the stored scope map — never a log scan.
|
|
8
8
|
*/
|
|
9
9
|
import { Database } from 'bun:sqlite';
|
|
10
|
+
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
10
11
|
import { syncError } from './errors.js';
|
|
11
12
|
import { commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
12
13
|
import { matchesEffective } from './scopes.js';
|
|
13
14
|
import { collectCommitWindowPage, deserializePushResult, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
|
|
14
15
|
import { isSqliteConstraintError, StorageConstraintError, } from './storage-errors.js';
|
|
15
16
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
17
|
+
function toStoredReaction(record) {
|
|
18
|
+
return {
|
|
19
|
+
idempotencyKey: record.idempotency_key,
|
|
20
|
+
type: record.type,
|
|
21
|
+
version: record.version,
|
|
22
|
+
payload: JSON.parse(record.payload),
|
|
23
|
+
sourceClientId: record.source_client_id,
|
|
24
|
+
sourceClientCommitId: record.source_client_commit_id,
|
|
25
|
+
sourceCommitSeq: record.source_commit_seq,
|
|
26
|
+
createdAtMs: record.created_at_ms,
|
|
27
|
+
maxAttempts: record.max_attempts,
|
|
28
|
+
status: record.status,
|
|
29
|
+
attempts: record.attempts,
|
|
30
|
+
availableAtMs: record.available_at_ms,
|
|
31
|
+
...(record.lease_owner !== null ? { leaseOwner: record.lease_owner } : {}),
|
|
32
|
+
...(record.lease_expires_at_ms !== null
|
|
33
|
+
? { leaseExpiresAtMs: record.lease_expires_at_ms }
|
|
34
|
+
: {}),
|
|
35
|
+
...(record.completed_at_ms !== null
|
|
36
|
+
? { completedAtMs: record.completed_at_ms }
|
|
37
|
+
: {}),
|
|
38
|
+
...(record.last_failure !== null
|
|
39
|
+
? { lastFailure: JSON.parse(record.last_failure) }
|
|
40
|
+
: {}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
16
43
|
class SqliteTransaction {
|
|
17
44
|
#storage;
|
|
18
45
|
#partition;
|
|
@@ -118,6 +145,17 @@ class SqliteTransaction {
|
|
|
118
145
|
.query('INSERT OR IGNORE INTO sync_push_results(partition, client_id, client_commit_id, result) VALUES (?,?,?,?)')
|
|
119
146
|
.run(this.#partition, clientId, clientCommitId, serializePushResult(result));
|
|
120
147
|
}
|
|
148
|
+
async enqueueReactions(reactions) {
|
|
149
|
+
this.#assertOpen();
|
|
150
|
+
const statement = this.#storage.db.query(`INSERT INTO sync_reactions(
|
|
151
|
+
partition, idempotency_key, type, version, payload,
|
|
152
|
+
source_client_id, source_client_commit_id, source_commit_seq,
|
|
153
|
+
created_at_ms, available_at_ms, status, attempts, max_attempts
|
|
154
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,'pending',0,?)`);
|
|
155
|
+
for (const reaction of reactions) {
|
|
156
|
+
statement.run(this.#partition, reaction.idempotencyKey, reaction.type, reaction.version, JSON.stringify(reaction.payload), reaction.sourceClientId, reaction.sourceClientCommitId, reaction.sourceCommitSeq, reaction.createdAtMs, reaction.createdAtMs, reaction.maxAttempts);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
121
159
|
async commit() {
|
|
122
160
|
this.#assertOpen();
|
|
123
161
|
try {
|
|
@@ -161,6 +199,20 @@ export class SqliteServerStorage {
|
|
|
161
199
|
/** Set by `ensureSchema`: app-table lookup for the relational row store. */
|
|
162
200
|
#tables;
|
|
163
201
|
#schemaVersion;
|
|
202
|
+
async #serializeReactionWrite(operation) {
|
|
203
|
+
const previous = this.#transactionTail;
|
|
204
|
+
let release;
|
|
205
|
+
this.#transactionTail = new Promise((resolve) => {
|
|
206
|
+
release = resolve;
|
|
207
|
+
});
|
|
208
|
+
await previous;
|
|
209
|
+
try {
|
|
210
|
+
return operation();
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
release();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
164
216
|
constructor(db = ':memory:') {
|
|
165
217
|
this.db = typeof db === 'string' ? new Database(db) : db;
|
|
166
218
|
this.db.exec(SQLITE_DDL);
|
|
@@ -240,8 +292,8 @@ export class SqliteServerStorage {
|
|
|
240
292
|
this.#schemaVersion = schema.version;
|
|
241
293
|
}
|
|
242
294
|
/**
|
|
243
|
-
* Migration rewrite
|
|
244
|
-
*
|
|
295
|
+
* Migration rewrite: keyset-paged walk of a row table. When `oldLayout` is
|
|
296
|
+
* given every payload re-encodes under
|
|
245
297
|
* the current columns, and the projection (when materialized) refreshes
|
|
246
298
|
* from the payload either way.
|
|
247
299
|
*/
|
|
@@ -310,6 +362,40 @@ export class SqliteServerStorage {
|
|
|
310
362
|
.get(partition);
|
|
311
363
|
return row?.max_commit_seq ?? 0;
|
|
312
364
|
}
|
|
365
|
+
async queryAuthoritative(partition, query) {
|
|
366
|
+
if (this.#tables === undefined) {
|
|
367
|
+
throw new Error('ensureSchema(schema) must run before registered queries');
|
|
368
|
+
}
|
|
369
|
+
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.sql, query.params, query.tables, this.#tables), partition);
|
|
370
|
+
const previous = this.#transactionTail;
|
|
371
|
+
let release;
|
|
372
|
+
this.#transactionTail = new Promise((resolve) => {
|
|
373
|
+
release = resolve;
|
|
374
|
+
});
|
|
375
|
+
await previous;
|
|
376
|
+
let open = false;
|
|
377
|
+
try {
|
|
378
|
+
this.db.exec('BEGIN');
|
|
379
|
+
open = true;
|
|
380
|
+
const rows = this.db
|
|
381
|
+
.query(prepared.sql)
|
|
382
|
+
.all(...prepared.params);
|
|
383
|
+
const cursor = this.db
|
|
384
|
+
.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
|
|
385
|
+
.get(partition);
|
|
386
|
+
this.db.exec('COMMIT');
|
|
387
|
+
open = false;
|
|
388
|
+
return { rows, maxCommitSeq: cursor?.max_commit_seq ?? 0 };
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
if (open)
|
|
392
|
+
this.db.exec('ROLLBACK');
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
release();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
313
399
|
async getHorizonSeq(partition) {
|
|
314
400
|
const row = this.db
|
|
315
401
|
.query('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
|
|
@@ -361,6 +447,135 @@ export class SqliteServerStorage {
|
|
|
361
447
|
throw syncError('sync.idempotency_cache_miss', 'persisted push result unreadable (§6.3)');
|
|
362
448
|
}
|
|
363
449
|
}
|
|
450
|
+
async claimReactions(partition, query) {
|
|
451
|
+
if (query.types.length === 0 || query.limit <= 0)
|
|
452
|
+
return [];
|
|
453
|
+
return this.#serializeReactionWrite(() => {
|
|
454
|
+
const typeParams = query.types.map(() => '?').join(',');
|
|
455
|
+
const records = this.db
|
|
456
|
+
.query(`UPDATE sync_reactions
|
|
457
|
+
SET status='leased', attempts=attempts+1,
|
|
458
|
+
lease_owner=?, lease_expires_at_ms=?, completed_at_ms=NULL
|
|
459
|
+
WHERE (partition, idempotency_key) IN (
|
|
460
|
+
SELECT partition, idempotency_key
|
|
461
|
+
FROM sync_reactions
|
|
462
|
+
WHERE partition=? AND type IN (${typeParams})
|
|
463
|
+
AND ((status='pending' AND available_at_ms<=?)
|
|
464
|
+
OR (status='leased' AND lease_expires_at_ms<=?))
|
|
465
|
+
ORDER BY CASE WHEN status='leased' THEN lease_expires_at_ms
|
|
466
|
+
ELSE available_at_ms END,
|
|
467
|
+
created_at_ms, idempotency_key
|
|
468
|
+
LIMIT ?
|
|
469
|
+
)
|
|
470
|
+
RETURNING *`)
|
|
471
|
+
.all(query.leaseOwner, Math.min(Number.MAX_SAFE_INTEGER, query.nowMs + query.leaseDurationMs), partition, ...query.types, query.nowMs, query.nowMs, query.limit);
|
|
472
|
+
return records
|
|
473
|
+
.map(toStoredReaction)
|
|
474
|
+
.sort((a, b) => a.createdAtMs - b.createdAtMs ||
|
|
475
|
+
a.idempotencyKey.localeCompare(b.idempotencyKey));
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
async completeReaction(partition, idempotencyKey, leaseOwner, completedAtMs) {
|
|
479
|
+
return this.#serializeReactionWrite(() => {
|
|
480
|
+
const result = this.db
|
|
481
|
+
.query(`UPDATE sync_reactions
|
|
482
|
+
SET status='completed', completed_at_ms=?,
|
|
483
|
+
lease_owner=NULL, lease_expires_at_ms=NULL
|
|
484
|
+
WHERE partition=? AND idempotency_key=?
|
|
485
|
+
AND status='leased' AND lease_owner=?`)
|
|
486
|
+
.run(completedAtMs, partition, idempotencyKey, leaseOwner);
|
|
487
|
+
return Number(result.changes) === 1;
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
async extendReactionLease(partition, idempotencyKey, leaseOwner, leaseExpiresAtMs) {
|
|
491
|
+
return this.#serializeReactionWrite(() => {
|
|
492
|
+
const result = this.db
|
|
493
|
+
.query(`UPDATE sync_reactions SET lease_expires_at_ms=?
|
|
494
|
+
WHERE partition=? AND idempotency_key=?
|
|
495
|
+
AND status='leased' AND lease_owner=?`)
|
|
496
|
+
.run(leaseExpiresAtMs, partition, idempotencyKey, leaseOwner);
|
|
497
|
+
return Number(result.changes) === 1;
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
async failReaction(partition, idempotencyKey, update) {
|
|
501
|
+
const retry = update.retryAtMs !== undefined;
|
|
502
|
+
return this.#serializeReactionWrite(() => {
|
|
503
|
+
const result = this.db
|
|
504
|
+
.query(`UPDATE sync_reactions
|
|
505
|
+
SET status=?, available_at_ms=?, last_failure=?,
|
|
506
|
+
lease_owner=NULL, lease_expires_at_ms=NULL
|
|
507
|
+
WHERE partition=? AND idempotency_key=?
|
|
508
|
+
AND status='leased' AND lease_owner=?`)
|
|
509
|
+
.run(retry ? 'pending' : 'dead-letter', update.retryAtMs ?? update.failure.atMs, JSON.stringify(update.failure), partition, idempotencyKey, update.leaseOwner);
|
|
510
|
+
return Number(result.changes) === 1;
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
async retryReaction(partition, idempotencyKey, nowMs) {
|
|
514
|
+
return this.#serializeReactionWrite(() => {
|
|
515
|
+
const result = this.db
|
|
516
|
+
.query(`UPDATE sync_reactions
|
|
517
|
+
SET status='pending', attempts=0, available_at_ms=?,
|
|
518
|
+
last_failure=NULL, lease_owner=NULL, lease_expires_at_ms=NULL,
|
|
519
|
+
completed_at_ms=NULL
|
|
520
|
+
WHERE partition=? AND idempotency_key=? AND status='dead-letter'`)
|
|
521
|
+
.run(nowMs, partition, idempotencyKey);
|
|
522
|
+
return Number(result.changes) === 1;
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
async getReaction(partition, idempotencyKey) {
|
|
526
|
+
const record = this.db
|
|
527
|
+
.query('SELECT * FROM sync_reactions WHERE partition=? AND idempotency_key=?')
|
|
528
|
+
.get(partition, idempotencyKey);
|
|
529
|
+
return record === null ? undefined : toStoredReaction(record);
|
|
530
|
+
}
|
|
531
|
+
async listReactions(partition, query) {
|
|
532
|
+
const where = ['partition=?'];
|
|
533
|
+
const params = [partition];
|
|
534
|
+
if (query.statuses !== undefined && query.statuses.length > 0) {
|
|
535
|
+
where.push(`status IN (${query.statuses.map(() => '?').join(',')})`);
|
|
536
|
+
params.push(...query.statuses);
|
|
537
|
+
}
|
|
538
|
+
if (query.types !== undefined && query.types.length > 0) {
|
|
539
|
+
where.push(`type IN (${query.types.map(() => '?').join(',')})`);
|
|
540
|
+
params.push(...query.types);
|
|
541
|
+
}
|
|
542
|
+
params.push(query.limit);
|
|
543
|
+
const records = this.db
|
|
544
|
+
.query(`SELECT * FROM sync_reactions WHERE ${where.join(' AND ')}
|
|
545
|
+
ORDER BY created_at_ms DESC, idempotency_key DESC LIMIT ?`)
|
|
546
|
+
.all(...params);
|
|
547
|
+
return records.map(toStoredReaction);
|
|
548
|
+
}
|
|
549
|
+
async pruneReactions(partition, query) {
|
|
550
|
+
if (query.limit <= 0)
|
|
551
|
+
return { completed: 0, deadLetter: 0 };
|
|
552
|
+
return this.#serializeReactionWrite(() => {
|
|
553
|
+
const records = this.db
|
|
554
|
+
.query(`DELETE FROM sync_reactions
|
|
555
|
+
WHERE partition=? AND idempotency_key IN (
|
|
556
|
+
SELECT idempotency_key FROM sync_reactions
|
|
557
|
+
WHERE partition=?
|
|
558
|
+
AND ((status='completed' AND completed_at_ms IS NOT NULL
|
|
559
|
+
AND completed_at_ms<?)
|
|
560
|
+
OR (status='dead-letter' AND available_at_ms<?))
|
|
561
|
+
ORDER BY CASE WHEN status='completed' THEN completed_at_ms
|
|
562
|
+
ELSE available_at_ms END,
|
|
563
|
+
idempotency_key
|
|
564
|
+
LIMIT ?
|
|
565
|
+
)
|
|
566
|
+
AND ((status='completed' AND completed_at_ms IS NOT NULL
|
|
567
|
+
AND completed_at_ms<?)
|
|
568
|
+
OR (status='dead-letter' AND available_at_ms<?))
|
|
569
|
+
RETURNING status`)
|
|
570
|
+
.all(partition, partition, query.completedBeforeMs, query.deadLetterBeforeMs, query.limit, query.completedBeforeMs, query.deadLetterBeforeMs);
|
|
571
|
+
return {
|
|
572
|
+
completed: records.filter((record) => record.status === 'completed')
|
|
573
|
+
.length,
|
|
574
|
+
deadLetter: records.filter((record) => record.status === 'dead-letter')
|
|
575
|
+
.length,
|
|
576
|
+
};
|
|
577
|
+
});
|
|
578
|
+
}
|
|
364
579
|
async readCommitWindow(partition, query) {
|
|
365
580
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
366
581
|
const firstVariable = variables[0];
|
|
@@ -498,7 +713,7 @@ export class SqliteServerStorage {
|
|
|
498
713
|
.all(partition);
|
|
499
714
|
return rows.map((r) => r.blob_id);
|
|
500
715
|
}
|
|
501
|
-
// -- admin/console read surface
|
|
716
|
+
// -- admin/console read surface --------------------------------------------
|
|
502
717
|
async listClientRecords(partition) {
|
|
503
718
|
const records = this.db
|
|
504
719
|
.query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
|
package/dist/storage.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Requirements it encodes:
|
|
5
5
|
* - commit log with per-partition `commitSeq` (§2.1) and a commit→scope
|
|
6
|
-
* inverted index
|
|
6
|
+
* inverted index (§3.1): pulls filter
|
|
7
7
|
* by scope via the index, never by scanning the log;
|
|
8
8
|
* - idempotency results per §2.3 persisted in the same transaction as the
|
|
9
9
|
* commit's writes (§6.4);
|
|
@@ -59,6 +59,71 @@ export interface StoredCommit {
|
|
|
59
59
|
readonly actorId: string;
|
|
60
60
|
readonly changes: readonly StoredChange[];
|
|
61
61
|
}
|
|
62
|
+
/** JSON values accepted by the durable reaction payload/failure store. */
|
|
63
|
+
export type DurableJsonValue = null | boolean | number | string | readonly DurableJsonValue[] | {
|
|
64
|
+
readonly [key: string]: DurableJsonValue;
|
|
65
|
+
};
|
|
66
|
+
export type ReactionStatus = 'pending' | 'leased' | 'completed' | 'dead-letter';
|
|
67
|
+
export interface ReactionFailure {
|
|
68
|
+
/** Stable application or Syncular error identity. */
|
|
69
|
+
readonly code: string;
|
|
70
|
+
readonly atMs: number;
|
|
71
|
+
/** Bounded JSON metadata. Diagnostic exception text is never persisted. */
|
|
72
|
+
readonly details?: {
|
|
73
|
+
readonly [key: string]: DurableJsonValue;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** A validated reaction row written with its source commit. */
|
|
77
|
+
export interface NewReaction {
|
|
78
|
+
readonly idempotencyKey: string;
|
|
79
|
+
readonly type: string;
|
|
80
|
+
readonly version: number;
|
|
81
|
+
readonly payload: DurableJsonValue;
|
|
82
|
+
readonly sourceClientId: string;
|
|
83
|
+
readonly sourceClientCommitId: string;
|
|
84
|
+
readonly sourceCommitSeq: number;
|
|
85
|
+
readonly createdAtMs: number;
|
|
86
|
+
readonly maxAttempts: number;
|
|
87
|
+
}
|
|
88
|
+
/** Durable reaction state returned to workers and administrative readers. */
|
|
89
|
+
export interface StoredReaction extends NewReaction {
|
|
90
|
+
readonly status: ReactionStatus;
|
|
91
|
+
/** Incremented atomically when a worker claims the reaction. */
|
|
92
|
+
readonly attempts: number;
|
|
93
|
+
readonly availableAtMs: number;
|
|
94
|
+
readonly leaseOwner?: string;
|
|
95
|
+
readonly leaseExpiresAtMs?: number;
|
|
96
|
+
readonly completedAtMs?: number;
|
|
97
|
+
readonly lastFailure?: ReactionFailure;
|
|
98
|
+
}
|
|
99
|
+
export interface ReactionClaimQuery {
|
|
100
|
+
/** Opaque token unique to this claim operation. */
|
|
101
|
+
readonly leaseOwner: string;
|
|
102
|
+
readonly types: readonly string[];
|
|
103
|
+
readonly nowMs: number;
|
|
104
|
+
readonly leaseDurationMs: number;
|
|
105
|
+
readonly limit: number;
|
|
106
|
+
}
|
|
107
|
+
export interface ReactionListQuery {
|
|
108
|
+
readonly statuses?: readonly ReactionStatus[];
|
|
109
|
+
readonly types?: readonly string[];
|
|
110
|
+
readonly limit: number;
|
|
111
|
+
}
|
|
112
|
+
export interface ReactionFailureUpdate {
|
|
113
|
+
readonly leaseOwner: string;
|
|
114
|
+
readonly failure: ReactionFailure;
|
|
115
|
+
/** Present for retry; absent moves the row to the dead-letter state. */
|
|
116
|
+
readonly retryAtMs?: number;
|
|
117
|
+
}
|
|
118
|
+
export interface ReactionPruneQuery {
|
|
119
|
+
readonly completedBeforeMs: number;
|
|
120
|
+
readonly deadLetterBeforeMs: number;
|
|
121
|
+
readonly limit: number;
|
|
122
|
+
}
|
|
123
|
+
export interface PrunedReactionCounts {
|
|
124
|
+
readonly completed: number;
|
|
125
|
+
readonly deadLetter: number;
|
|
126
|
+
}
|
|
62
127
|
/** Persisted push outcome for idempotent replay (§2.3, §6.3). */
|
|
63
128
|
export interface StoredPushResult {
|
|
64
129
|
readonly status: 'applied' | 'rejected';
|
|
@@ -166,6 +231,20 @@ export interface ScopeActivityQuery {
|
|
|
166
231
|
readonly value: string;
|
|
167
232
|
readonly limit: number;
|
|
168
233
|
}
|
|
234
|
+
/** A registered SELECT executed against the authoritative row projection. */
|
|
235
|
+
export type AuthoritativeQueryValue = string | number | bigint | boolean | Uint8Array | null;
|
|
236
|
+
export interface AuthoritativeQueryRequest {
|
|
237
|
+
/** Generated, positional SQLite-family SQL. It never comes from the request. */
|
|
238
|
+
readonly sql: string;
|
|
239
|
+
readonly params: readonly AuthoritativeQueryValue[];
|
|
240
|
+
/** Generated dependency set, used to validate and partition every relation. */
|
|
241
|
+
readonly tables: readonly string[];
|
|
242
|
+
}
|
|
243
|
+
/** One transactionally consistent authoritative query snapshot. */
|
|
244
|
+
export interface AuthoritativeQueryResult {
|
|
245
|
+
readonly rows: readonly Readonly<Record<string, unknown>>[];
|
|
246
|
+
readonly maxCommitSeq: number;
|
|
247
|
+
}
|
|
169
248
|
/**
|
|
170
249
|
* One transaction per push commit (§6.4): all row writes, the appended
|
|
171
250
|
* commit (with its scope-index entries), and the idempotency record either
|
|
@@ -222,6 +301,12 @@ export interface StorageTransaction {
|
|
|
222
301
|
deleteRow(table: string, rowId: string): Promise<void>;
|
|
223
302
|
/** Allocates the next per-partition commitSeq and appends the commit. */
|
|
224
303
|
appendCommit(commit: NewCommit): Promise<number>;
|
|
304
|
+
/**
|
|
305
|
+
* Persist reaction rows in this authoritative transaction. Required when
|
|
306
|
+
* the host configures a reaction planner. Reactions survive commit-log
|
|
307
|
+
* pruning because they live outside the commit/change tables.
|
|
308
|
+
*/
|
|
309
|
+
enqueueReactions?(reactions: readonly NewReaction[]): Promise<void>;
|
|
225
310
|
/**
|
|
226
311
|
* Persist an idempotency outcome only when the key is still absent. The
|
|
227
312
|
* first writer wins; callers read the canonical value after commit.
|
|
@@ -241,7 +326,7 @@ export interface StorageTransaction {
|
|
|
241
326
|
export interface ServerStorage {
|
|
242
327
|
/**
|
|
243
328
|
* Create/migrate the relational per-app row tables for `schema`
|
|
244
|
-
*
|
|
329
|
+
* Idempotent and cheap when the
|
|
245
330
|
* stored schema version already matches (one marker read, memoized per
|
|
246
331
|
* instance); on first use it creates the tables, on a version bump it
|
|
247
332
|
* applies the migration subset (CREATE TABLE / ADD COLUMN / CREATE
|
|
@@ -272,6 +357,24 @@ export interface ServerStorage {
|
|
|
272
357
|
* when a persisted result exists but cannot be read (§6.3).
|
|
273
358
|
*/
|
|
274
359
|
getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
|
|
360
|
+
/**
|
|
361
|
+
* Atomically lease due or expired-lease reactions for one worker. Optional
|
|
362
|
+
* for compatibility with custom storages; reaction runners fail closed when
|
|
363
|
+
* any lifecycle method is absent.
|
|
364
|
+
*/
|
|
365
|
+
claimReactions?(partition: string, query: ReactionClaimQuery): Promise<StoredReaction[]>;
|
|
366
|
+
/** Acknowledge only while `leaseOwner` still owns the lease. */
|
|
367
|
+
completeReaction?(partition: string, idempotencyKey: string, leaseOwner: string, completedAtMs: number): Promise<boolean>;
|
|
368
|
+
/** Extend only while `leaseOwner` still owns the active lease. */
|
|
369
|
+
extendReactionLease?(partition: string, idempotencyKey: string, leaseOwner: string, leaseExpiresAtMs: number): Promise<boolean>;
|
|
370
|
+
/** Retry or dead-letter only while `leaseOwner` still owns the lease. */
|
|
371
|
+
failReaction?(partition: string, idempotencyKey: string, update: ReactionFailureUpdate): Promise<boolean>;
|
|
372
|
+
/** Reset a dead-lettered row for an explicit operator retry. */
|
|
373
|
+
retryReaction?(partition: string, idempotencyKey: string, nowMs: number): Promise<boolean>;
|
|
374
|
+
getReaction?(partition: string, idempotencyKey: string): Promise<StoredReaction | undefined>;
|
|
375
|
+
listReactions?(partition: string, query: ReactionListQuery): Promise<StoredReaction[]>;
|
|
376
|
+
/** Delete a bounded set of aged terminal rows. Never deletes active work. */
|
|
377
|
+
pruneReactions?(partition: string, query: ReactionPruneQuery): Promise<PrunedReactionCounts>;
|
|
275
378
|
/**
|
|
276
379
|
* Matching commits in the window, oldest first, each carrying only its
|
|
277
380
|
* matching changes for `table`. Stops once accumulated matching changes
|
|
@@ -280,6 +383,12 @@ export interface ServerStorage {
|
|
|
280
383
|
readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
|
|
281
384
|
/** Scope-filtered snapshot scan, ordered by rowId (bootstrap paging). */
|
|
282
385
|
scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
|
|
386
|
+
/**
|
|
387
|
+
* Optional registered-query capability. The implementation MUST replace
|
|
388
|
+
* every generated app-table relation with a partition-filtered relation and
|
|
389
|
+
* return rows plus maxCommitSeq from one consistent database snapshot.
|
|
390
|
+
*/
|
|
391
|
+
queryAuthoritative?(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
|
|
283
392
|
/**
|
|
284
393
|
* Optional trusted-host exact lookup through a declared relational index.
|
|
285
394
|
* This capability is outside client scope/subscription authorization and
|
|
@@ -305,7 +414,7 @@ export interface ServerStorage {
|
|
|
305
414
|
listRowsReferencingBlob?(partition: string, blobId: string): Promise<BlobReferencingRow[]>;
|
|
306
415
|
listReferencedBlobIds?(partition: string): Promise<string[]>;
|
|
307
416
|
/**
|
|
308
|
-
* Admin/console read surface (`SyncularAdmin
|
|
417
|
+
* Admin/console read surface (`SyncularAdmin`): ADDITIVE,
|
|
309
418
|
* optional. All read-only, partition-scoped, and JSON-able. A backend
|
|
310
419
|
* that omits these simply cannot serve the console; the sync path never
|
|
311
420
|
* calls them.
|
package/dist/validate.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.46",
|
|
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.15.
|
|
56
|
+
"@syncular/core": "0.15.46"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/admin.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `SyncularAdmin`
|
|
3
|
-
*
|
|
2
|
+
* `SyncularAdmin` is the operator-facing read surface over the server core.
|
|
3
|
+
* It is a read-only, partition-scoped, JSON-able query layer over
|
|
4
4
|
* `ServerStorage`, the optional segment/blob store stats, and an in-memory
|
|
5
5
|
* event ring. It delivers the 80% operator value (who's connected, what's
|
|
6
6
|
* flowing, horizon health, the event tail) as a handful of queries in the
|
|
7
|
-
* server package — no separate
|
|
8
|
-
*
|
|
9
|
-
* this is host surface, mirrored in the server README).
|
|
7
|
+
* server package — no separate UI package, no framework, and no wire-protocol
|
|
8
|
+
* surface. This host surface is mirrored in the server README.
|
|
10
9
|
*
|
|
11
10
|
* Nothing here is on the sync hot path. Every method is a plain read; the
|
|
12
11
|
* additive optional storage/store methods it depends on are documented as
|
|
@@ -25,8 +24,10 @@ import type { SegmentStore, SegmentStoreStats } from './segment-store';
|
|
|
25
24
|
import type {
|
|
26
25
|
ClientRecord,
|
|
27
26
|
CommitMetadata,
|
|
27
|
+
ReactionStatus,
|
|
28
28
|
ScopeCommitActivity,
|
|
29
29
|
ServerStorage,
|
|
30
|
+
StoredReaction,
|
|
30
31
|
} from './storage';
|
|
31
32
|
|
|
32
33
|
/** A connected/known client as the console sees it (§4.5, §8.1). */
|
|
@@ -137,6 +138,12 @@ export interface AdminScopeActivityOptions {
|
|
|
137
138
|
readonly limit?: number;
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
export interface AdminListReactionsOptions {
|
|
142
|
+
readonly statuses?: readonly ReactionStatus[];
|
|
143
|
+
readonly types?: readonly string[];
|
|
144
|
+
readonly limit?: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
140
147
|
export interface AdminStats {
|
|
141
148
|
readonly segments?: SegmentStoreStats;
|
|
142
149
|
readonly blobs?: BlobStoreStats;
|
|
@@ -164,6 +171,7 @@ export interface SyncularAdminOptions {
|
|
|
164
171
|
|
|
165
172
|
const DEFAULT_COMMIT_LIMIT = 50;
|
|
166
173
|
const DEFAULT_SCOPE_LIMIT = 50;
|
|
174
|
+
const DEFAULT_REACTION_LIMIT = 100;
|
|
167
175
|
const DEFAULT_CLIENT_EVENT_LIMIT = 100;
|
|
168
176
|
const DEFAULT_METRICS_WINDOW_MS = 5 * 60 * 1000;
|
|
169
177
|
const DEFAULT_METRICS_BUCKETS = 30;
|
|
@@ -179,7 +187,7 @@ function percentile(sample: readonly number[], q: number): number {
|
|
|
179
187
|
function required<T>(value: T | undefined, what: string): T {
|
|
180
188
|
if (value === undefined) {
|
|
181
189
|
throw new Error(
|
|
182
|
-
`SyncularAdmin: the configured ${what} does not implement this
|
|
190
|
+
`SyncularAdmin: the configured ${what} does not implement this optional read`,
|
|
183
191
|
);
|
|
184
192
|
}
|
|
185
193
|
return value;
|
|
@@ -313,6 +321,22 @@ export class SyncularAdmin {
|
|
|
313
321
|
});
|
|
314
322
|
}
|
|
315
323
|
|
|
324
|
+
/** Pending, leased, completed, and dead-lettered durable reactions. */
|
|
325
|
+
async listReactions(
|
|
326
|
+
partition: string,
|
|
327
|
+
options: AdminListReactionsOptions = {},
|
|
328
|
+
): Promise<StoredReaction[]> {
|
|
329
|
+
const read = required(
|
|
330
|
+
this.#storage.listReactions?.bind(this.#storage),
|
|
331
|
+
'storage',
|
|
332
|
+
);
|
|
333
|
+
return read(partition, {
|
|
334
|
+
limit: options.limit ?? DEFAULT_REACTION_LIMIT,
|
|
335
|
+
...(options.statuses !== undefined ? { statuses: options.statuses } : {}),
|
|
336
|
+
...(options.types !== undefined ? { types: options.types } : {}),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
316
340
|
/**
|
|
317
341
|
* Inspect a single row: current server_version, stored scopes, and the
|
|
318
342
|
* blobIds it references (when the store tracks references). Payload bytes
|