@syncular/server 0.15.45 → 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 +134 -4
- package/dist/admin.d.ts +10 -4
- package/dist/admin.js +10 -0
- package/dist/authoritative-query.d.ts +20 -0
- package/dist/authoritative-query.js +184 -0
- package/dist/context.d.ts +9 -0
- package/dist/context.js +2 -0
- package/dist/d1-storage.d.ts +10 -1
- package/dist/d1-storage.js +216 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +43 -1
- package/dist/events.d.ts +52 -3
- package/dist/handler.js +4 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- 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/postgres-storage.d.ts +11 -2
- package/dist/postgres-storage.js +220 -0
- 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/sqlite-dialect.d.ts +1 -1
- package/dist/sqlite-dialect.js +20 -0
- package/dist/sqlite-storage.d.ts +10 -1
- package/dist/sqlite-storage.js +215 -0
- package/dist/storage.d.ts +109 -0
- package/dist/validate.js +1 -0
- package/package.json +2 -2
- package/src/admin.ts +27 -3
- package/src/authoritative-query.ts +218 -0
- package/src/context.ts +10 -0
- package/src/d1-storage.ts +352 -0
- package/src/errors.ts +43 -1
- package/src/events.ts +64 -2
- package/src/handler.ts +13 -1
- package/src/index.ts +32 -0
- package/src/operations-realtime.ts +272 -0
- package/src/operations.ts +720 -0
- package/src/postgres-storage.ts +351 -0
- package/src/push.ts +97 -29
- package/src/reactions.ts +741 -0
- package/src/realtime.ts +7 -1
- package/src/sqlite-dialect.ts +20 -0
- package/src/sqlite-storage.ts +365 -0
- package/src/storage.ts +165 -0
- package/src/validate.ts +1 -0
package/dist/d1-storage.js
CHANGED
|
@@ -41,12 +41,39 @@ var _a;
|
|
|
41
41
|
* rather than a lock D1 does not expose.
|
|
42
42
|
*/
|
|
43
43
|
import { decodeRow } from '@syncular/core';
|
|
44
|
+
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
44
45
|
import { syncError } from './errors.js';
|
|
45
46
|
import { commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
|
|
46
47
|
import { matchesEffective } from './scopes.js';
|
|
47
48
|
import { asUint8Array, collectCommitWindowPage, deserializePushResult, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
|
|
48
49
|
import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
|
|
49
50
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
51
|
+
function toStoredReaction(record) {
|
|
52
|
+
return {
|
|
53
|
+
idempotencyKey: record.idempotency_key,
|
|
54
|
+
type: record.type,
|
|
55
|
+
version: record.version,
|
|
56
|
+
payload: JSON.parse(record.payload),
|
|
57
|
+
sourceClientId: record.source_client_id,
|
|
58
|
+
sourceClientCommitId: record.source_client_commit_id,
|
|
59
|
+
sourceCommitSeq: record.source_commit_seq,
|
|
60
|
+
createdAtMs: record.created_at_ms,
|
|
61
|
+
maxAttempts: record.max_attempts,
|
|
62
|
+
status: record.status,
|
|
63
|
+
attempts: record.attempts,
|
|
64
|
+
availableAtMs: record.available_at_ms,
|
|
65
|
+
...(record.lease_owner !== null ? { leaseOwner: record.lease_owner } : {}),
|
|
66
|
+
...(record.lease_expires_at_ms !== null
|
|
67
|
+
? { leaseExpiresAtMs: record.lease_expires_at_ms }
|
|
68
|
+
: {}),
|
|
69
|
+
...(record.completed_at_ms !== null
|
|
70
|
+
? { completedAtMs: record.completed_at_ms }
|
|
71
|
+
: {}),
|
|
72
|
+
...(record.last_failure !== null
|
|
73
|
+
? { lastFailure: JSON.parse(record.last_failure) }
|
|
74
|
+
: {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
50
77
|
function relationalValuesEqual(left, right) {
|
|
51
78
|
if (left instanceof Uint8Array && right instanceof Uint8Array) {
|
|
52
79
|
return (left.length === right.length &&
|
|
@@ -376,6 +403,28 @@ class D1Transaction {
|
|
|
376
403
|
this.#assertOpen();
|
|
377
404
|
this.#buffer_('INSERT OR IGNORE INTO sync_push_results(partition, client_id, client_commit_id, result) VALUES (?,?,?,?)', [this.#partition, clientId, clientCommitId, serializePushResult(result)]);
|
|
378
405
|
}
|
|
406
|
+
async enqueueReactions(reactions) {
|
|
407
|
+
this.#assertOpen();
|
|
408
|
+
for (const reaction of reactions) {
|
|
409
|
+
this.#buffer_(`INSERT INTO sync_reactions(
|
|
410
|
+
partition, idempotency_key, type, version, payload,
|
|
411
|
+
source_client_id, source_client_commit_id, source_commit_seq,
|
|
412
|
+
created_at_ms, available_at_ms, status, attempts, max_attempts
|
|
413
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,'pending',0,?)`, [
|
|
414
|
+
this.#partition,
|
|
415
|
+
reaction.idempotencyKey,
|
|
416
|
+
reaction.type,
|
|
417
|
+
reaction.version,
|
|
418
|
+
JSON.stringify(reaction.payload),
|
|
419
|
+
reaction.sourceClientId,
|
|
420
|
+
reaction.sourceClientCommitId,
|
|
421
|
+
reaction.sourceCommitSeq,
|
|
422
|
+
reaction.createdAtMs,
|
|
423
|
+
reaction.createdAtMs,
|
|
424
|
+
reaction.maxAttempts,
|
|
425
|
+
]);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
379
428
|
async commit() {
|
|
380
429
|
this.#assertOpen();
|
|
381
430
|
if (this.#buffer.length === 0) {
|
|
@@ -555,6 +604,41 @@ export class D1ServerStorage {
|
|
|
555
604
|
.first();
|
|
556
605
|
return row?.max_commit_seq ?? 0;
|
|
557
606
|
}
|
|
607
|
+
async queryAuthoritative(partition, query) {
|
|
608
|
+
if (this.#tables === undefined) {
|
|
609
|
+
throw new Error('ensureSchema(schema) must run before registered queries');
|
|
610
|
+
}
|
|
611
|
+
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.sql, query.params, query.tables, this.#tables), partition);
|
|
612
|
+
const results = await this.#db.batch([
|
|
613
|
+
this.#db.prepare(prepared.sql).bind(...prepared.params),
|
|
614
|
+
this.#db
|
|
615
|
+
.prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
|
|
616
|
+
.bind(partition),
|
|
617
|
+
]);
|
|
618
|
+
const rowsResult = results[0];
|
|
619
|
+
const cursorResult = results[1];
|
|
620
|
+
if (typeof rowsResult !== 'object' ||
|
|
621
|
+
rowsResult === null ||
|
|
622
|
+
!('results' in rowsResult) ||
|
|
623
|
+
!Array.isArray(rowsResult.results) ||
|
|
624
|
+
typeof cursorResult !== 'object' ||
|
|
625
|
+
cursorResult === null ||
|
|
626
|
+
!('results' in cursorResult) ||
|
|
627
|
+
!Array.isArray(cursorResult.results)) {
|
|
628
|
+
throw new Error('D1 registered query returned an invalid batch result');
|
|
629
|
+
}
|
|
630
|
+
const cursor = cursorResult.results[0];
|
|
631
|
+
const maxCommitSeq = typeof cursor === 'object' &&
|
|
632
|
+
cursor !== null &&
|
|
633
|
+
'max_commit_seq' in cursor &&
|
|
634
|
+
typeof cursor.max_commit_seq === 'number'
|
|
635
|
+
? cursor.max_commit_seq
|
|
636
|
+
: 0;
|
|
637
|
+
return {
|
|
638
|
+
rows: rowsResult.results.filter((row) => typeof row === 'object' && row !== null),
|
|
639
|
+
maxCommitSeq,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
558
642
|
async getHorizonSeq(partition) {
|
|
559
643
|
const row = await this.#db
|
|
560
644
|
.prepare('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
|
|
@@ -614,6 +698,138 @@ export class D1ServerStorage {
|
|
|
614
698
|
throw syncError('sync.idempotency_cache_miss', 'persisted push result unreadable (§6.3)');
|
|
615
699
|
}
|
|
616
700
|
}
|
|
701
|
+
async claimReactions(partition, query) {
|
|
702
|
+
if (query.types.length === 0 || query.limit <= 0)
|
|
703
|
+
return [];
|
|
704
|
+
if (query.types.length + 7 > D1_MAX_BIND_PARAMS) {
|
|
705
|
+
throw new Error('D1 reaction claim exceeds the bound-parameter limit');
|
|
706
|
+
}
|
|
707
|
+
const typeParams = query.types.map(() => '?').join(',');
|
|
708
|
+
const { results } = await this.#db
|
|
709
|
+
.prepare(`UPDATE sync_reactions
|
|
710
|
+
SET status='leased', attempts=attempts+1,
|
|
711
|
+
lease_owner=?, lease_expires_at_ms=?, completed_at_ms=NULL
|
|
712
|
+
WHERE (partition, idempotency_key) IN (
|
|
713
|
+
SELECT partition, idempotency_key
|
|
714
|
+
FROM sync_reactions
|
|
715
|
+
WHERE partition=? AND type IN (${typeParams})
|
|
716
|
+
AND ((status='pending' AND available_at_ms<=?)
|
|
717
|
+
OR (status='leased' AND lease_expires_at_ms<=?))
|
|
718
|
+
ORDER BY CASE WHEN status='leased' THEN lease_expires_at_ms
|
|
719
|
+
ELSE available_at_ms END,
|
|
720
|
+
created_at_ms, idempotency_key
|
|
721
|
+
LIMIT ?
|
|
722
|
+
)
|
|
723
|
+
RETURNING *`)
|
|
724
|
+
.bind(query.leaseOwner, Math.min(Number.MAX_SAFE_INTEGER, query.nowMs + query.leaseDurationMs), partition, ...query.types, query.nowMs, query.nowMs, query.limit)
|
|
725
|
+
.all();
|
|
726
|
+
return results
|
|
727
|
+
.map(toStoredReaction)
|
|
728
|
+
.sort((a, b) => a.createdAtMs - b.createdAtMs ||
|
|
729
|
+
a.idempotencyKey.localeCompare(b.idempotencyKey));
|
|
730
|
+
}
|
|
731
|
+
async completeReaction(partition, idempotencyKey, leaseOwner, completedAtMs) {
|
|
732
|
+
const record = await this.#db
|
|
733
|
+
.prepare(`UPDATE sync_reactions
|
|
734
|
+
SET status='completed', completed_at_ms=?,
|
|
735
|
+
lease_owner=NULL, lease_expires_at_ms=NULL
|
|
736
|
+
WHERE partition=? AND idempotency_key=?
|
|
737
|
+
AND status='leased' AND lease_owner=?
|
|
738
|
+
RETURNING idempotency_key`)
|
|
739
|
+
.bind(completedAtMs, partition, idempotencyKey, leaseOwner)
|
|
740
|
+
.first();
|
|
741
|
+
return record !== null;
|
|
742
|
+
}
|
|
743
|
+
async extendReactionLease(partition, idempotencyKey, leaseOwner, leaseExpiresAtMs) {
|
|
744
|
+
const record = await this.#db
|
|
745
|
+
.prepare(`UPDATE sync_reactions SET lease_expires_at_ms=?
|
|
746
|
+
WHERE partition=? AND idempotency_key=?
|
|
747
|
+
AND status='leased' AND lease_owner=?
|
|
748
|
+
RETURNING idempotency_key`)
|
|
749
|
+
.bind(leaseExpiresAtMs, partition, idempotencyKey, leaseOwner)
|
|
750
|
+
.first();
|
|
751
|
+
return record !== null;
|
|
752
|
+
}
|
|
753
|
+
async failReaction(partition, idempotencyKey, update) {
|
|
754
|
+
const record = await this.#db
|
|
755
|
+
.prepare(`UPDATE sync_reactions
|
|
756
|
+
SET status=?, available_at_ms=?, last_failure=?,
|
|
757
|
+
lease_owner=NULL, lease_expires_at_ms=NULL
|
|
758
|
+
WHERE partition=? AND idempotency_key=?
|
|
759
|
+
AND status='leased' AND lease_owner=?
|
|
760
|
+
RETURNING idempotency_key`)
|
|
761
|
+
.bind(update.retryAtMs === undefined ? 'dead-letter' : 'pending', update.retryAtMs ?? update.failure.atMs, JSON.stringify(update.failure), partition, idempotencyKey, update.leaseOwner)
|
|
762
|
+
.first();
|
|
763
|
+
return record !== null;
|
|
764
|
+
}
|
|
765
|
+
async retryReaction(partition, idempotencyKey, nowMs) {
|
|
766
|
+
const record = await this.#db
|
|
767
|
+
.prepare(`UPDATE sync_reactions
|
|
768
|
+
SET status='pending', attempts=0, available_at_ms=?,
|
|
769
|
+
last_failure=NULL, lease_owner=NULL, lease_expires_at_ms=NULL,
|
|
770
|
+
completed_at_ms=NULL
|
|
771
|
+
WHERE partition=? AND idempotency_key=? AND status='dead-letter'
|
|
772
|
+
RETURNING idempotency_key`)
|
|
773
|
+
.bind(nowMs, partition, idempotencyKey)
|
|
774
|
+
.first();
|
|
775
|
+
return record !== null;
|
|
776
|
+
}
|
|
777
|
+
async getReaction(partition, idempotencyKey) {
|
|
778
|
+
const record = await this.#db
|
|
779
|
+
.prepare('SELECT * FROM sync_reactions WHERE partition=? AND idempotency_key=?')
|
|
780
|
+
.bind(partition, idempotencyKey)
|
|
781
|
+
.first();
|
|
782
|
+
return record === null ? undefined : toStoredReaction(record);
|
|
783
|
+
}
|
|
784
|
+
async listReactions(partition, query) {
|
|
785
|
+
const where = ['partition=?'];
|
|
786
|
+
const params = [partition];
|
|
787
|
+
if (query.statuses !== undefined && query.statuses.length > 0) {
|
|
788
|
+
where.push(`status IN (${query.statuses.map(() => '?').join(',')})`);
|
|
789
|
+
params.push(...query.statuses);
|
|
790
|
+
}
|
|
791
|
+
if (query.types !== undefined && query.types.length > 0) {
|
|
792
|
+
where.push(`type IN (${query.types.map(() => '?').join(',')})`);
|
|
793
|
+
params.push(...query.types);
|
|
794
|
+
}
|
|
795
|
+
if (params.length + 1 > D1_MAX_BIND_PARAMS) {
|
|
796
|
+
throw new Error('D1 reaction list exceeds the bound-parameter limit');
|
|
797
|
+
}
|
|
798
|
+
params.push(query.limit);
|
|
799
|
+
const { results } = await this.#db
|
|
800
|
+
.prepare(`SELECT * FROM sync_reactions WHERE ${where.join(' AND ')}
|
|
801
|
+
ORDER BY created_at_ms DESC, idempotency_key DESC LIMIT ?`)
|
|
802
|
+
.bind(...params)
|
|
803
|
+
.all();
|
|
804
|
+
return results.map(toStoredReaction);
|
|
805
|
+
}
|
|
806
|
+
async pruneReactions(partition, query) {
|
|
807
|
+
if (query.limit <= 0)
|
|
808
|
+
return { completed: 0, deadLetter: 0 };
|
|
809
|
+
const { results } = await this.#db
|
|
810
|
+
.prepare(`DELETE FROM sync_reactions
|
|
811
|
+
WHERE partition=? AND idempotency_key IN (
|
|
812
|
+
SELECT idempotency_key FROM sync_reactions
|
|
813
|
+
WHERE partition=?
|
|
814
|
+
AND ((status='completed' AND completed_at_ms IS NOT NULL
|
|
815
|
+
AND completed_at_ms<?)
|
|
816
|
+
OR (status='dead-letter' AND available_at_ms<?))
|
|
817
|
+
ORDER BY CASE WHEN status='completed' THEN completed_at_ms
|
|
818
|
+
ELSE available_at_ms END,
|
|
819
|
+
idempotency_key
|
|
820
|
+
LIMIT ?
|
|
821
|
+
)
|
|
822
|
+
AND ((status='completed' AND completed_at_ms IS NOT NULL
|
|
823
|
+
AND completed_at_ms<?)
|
|
824
|
+
OR (status='dead-letter' AND available_at_ms<?))
|
|
825
|
+
RETURNING status`)
|
|
826
|
+
.bind(partition, partition, query.completedBeforeMs, query.deadLetterBeforeMs, query.limit, query.completedBeforeMs, query.deadLetterBeforeMs)
|
|
827
|
+
.all();
|
|
828
|
+
return {
|
|
829
|
+
completed: results.filter((row) => row.status === 'completed').length,
|
|
830
|
+
deadLetter: results.filter((row) => row.status === 'dead-letter').length,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
617
833
|
async readCommitWindow(partition, query) {
|
|
618
834
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
619
835
|
const firstVariable = variables[0];
|
package/dist/errors.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface ErrorCatalogEntry {
|
|
|
12
12
|
readonly recommendedAction: string;
|
|
13
13
|
readonly httpStatus: number;
|
|
14
14
|
}
|
|
15
|
-
/** The §10.2 wire catalog
|
|
15
|
+
/** The §10.2 wire catalog, keyed by stable code. */
|
|
16
16
|
export declare const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>>;
|
|
17
17
|
export declare class SyncError extends Error {
|
|
18
18
|
readonly name = "SyncError";
|
package/dist/errors.js
CHANGED
|
@@ -6,8 +6,50 @@
|
|
|
6
6
|
* `recommendedAction`) plus an HTTP status for transport adapters. The
|
|
7
7
|
* catalog is closed: creating a `SyncError` with an unknown code throws.
|
|
8
8
|
*/
|
|
9
|
-
/** The §10.2 wire catalog
|
|
9
|
+
/** The §10.2 wire catalog, keyed by stable code. */
|
|
10
10
|
export const ERROR_CATALOG = {
|
|
11
|
+
'operation.unknown': {
|
|
12
|
+
category: 'not-found',
|
|
13
|
+
retryable: false,
|
|
14
|
+
recommendedAction: 'regenerateClient',
|
|
15
|
+
httpStatus: 404,
|
|
16
|
+
},
|
|
17
|
+
'operation.forbidden': {
|
|
18
|
+
category: 'forbidden',
|
|
19
|
+
retryable: false,
|
|
20
|
+
recommendedAction: 'checkPermissions',
|
|
21
|
+
httpStatus: 403,
|
|
22
|
+
},
|
|
23
|
+
'operation.invalid_request': {
|
|
24
|
+
category: 'invalid-request',
|
|
25
|
+
retryable: false,
|
|
26
|
+
recommendedAction: 'fixRequest',
|
|
27
|
+
httpStatus: 400,
|
|
28
|
+
},
|
|
29
|
+
'operation.result_too_large': {
|
|
30
|
+
category: 'invalid-request',
|
|
31
|
+
retryable: false,
|
|
32
|
+
recommendedAction: 'fixRequest',
|
|
33
|
+
httpStatus: 400,
|
|
34
|
+
},
|
|
35
|
+
'operation.storage_unsupported': {
|
|
36
|
+
category: 'internal',
|
|
37
|
+
retryable: false,
|
|
38
|
+
recommendedAction: 'inspectServer',
|
|
39
|
+
httpStatus: 500,
|
|
40
|
+
},
|
|
41
|
+
'operation.query_failed': {
|
|
42
|
+
category: 'internal',
|
|
43
|
+
retryable: false,
|
|
44
|
+
recommendedAction: 'inspectServer',
|
|
45
|
+
httpStatus: 500,
|
|
46
|
+
},
|
|
47
|
+
'operation.execution_failed': {
|
|
48
|
+
category: 'internal',
|
|
49
|
+
retryable: false,
|
|
50
|
+
recommendedAction: 'inspectServer',
|
|
51
|
+
httpStatus: 500,
|
|
52
|
+
},
|
|
11
53
|
'sync.auth_required': {
|
|
12
54
|
category: 'auth-required',
|
|
13
55
|
retryable: true,
|
package/dist/events.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Structured server events — the one operational seam (host surface,
|
|
3
|
-
* wire protocol
|
|
2
|
+
* Structured server events — the one operational seam (host surface, outside
|
|
3
|
+
* the wire protocol).
|
|
4
4
|
*
|
|
5
5
|
* Design rules:
|
|
6
6
|
* - Every event is a flat, JSON-able object with a stable `type` string:
|
|
@@ -75,6 +75,55 @@ export interface PushConflictedEvent extends PushEventBase {
|
|
|
75
75
|
readonly recordedAtMs?: number;
|
|
76
76
|
readonly cacheIdentity?: string;
|
|
77
77
|
}
|
|
78
|
+
/** A planned reaction committed atomically with its accepted source commit. */
|
|
79
|
+
export interface ReactionQueuedEvent {
|
|
80
|
+
readonly type: 'reaction.queued';
|
|
81
|
+
readonly atMs: number;
|
|
82
|
+
readonly partition: string;
|
|
83
|
+
readonly actorId: string;
|
|
84
|
+
readonly clientId: string;
|
|
85
|
+
readonly clientCommitId: string;
|
|
86
|
+
readonly commitSeq: number;
|
|
87
|
+
readonly idempotencyKey: string;
|
|
88
|
+
readonly reactionType: string;
|
|
89
|
+
readonly version: number;
|
|
90
|
+
}
|
|
91
|
+
interface ReactionDeliveryEventBase {
|
|
92
|
+
readonly atMs: number;
|
|
93
|
+
readonly partition: string;
|
|
94
|
+
readonly workerId: string;
|
|
95
|
+
readonly idempotencyKey: string;
|
|
96
|
+
readonly reactionType: string;
|
|
97
|
+
readonly version: number;
|
|
98
|
+
readonly attempt: number;
|
|
99
|
+
}
|
|
100
|
+
export interface ReactionStartedEvent extends ReactionDeliveryEventBase {
|
|
101
|
+
readonly type: 'reaction.started';
|
|
102
|
+
}
|
|
103
|
+
export interface ReactionRetriedEvent extends ReactionDeliveryEventBase {
|
|
104
|
+
readonly type: 'reaction.retried';
|
|
105
|
+
readonly nextAttemptAtMs: number;
|
|
106
|
+
readonly errorCode: string;
|
|
107
|
+
}
|
|
108
|
+
export interface ReactionCompletedEvent extends ReactionDeliveryEventBase {
|
|
109
|
+
readonly type: 'reaction.completed';
|
|
110
|
+
}
|
|
111
|
+
export interface ReactionDeadLetteredEvent extends ReactionDeliveryEventBase {
|
|
112
|
+
readonly type: 'reaction.dead_lettered';
|
|
113
|
+
readonly errorCode: string;
|
|
114
|
+
}
|
|
115
|
+
/** One bounded terminal-reaction retention pass. */
|
|
116
|
+
export interface ReactionPruneCompletedEvent {
|
|
117
|
+
readonly type: 'reaction.prune_completed';
|
|
118
|
+
readonly atMs: number;
|
|
119
|
+
readonly partition: string;
|
|
120
|
+
readonly completedBeforeMs: number;
|
|
121
|
+
readonly deadLetterBeforeMs: number;
|
|
122
|
+
readonly limit: number;
|
|
123
|
+
readonly removedCompleted: number;
|
|
124
|
+
readonly removedDeadLetter: number;
|
|
125
|
+
readonly mayHaveMore: boolean;
|
|
126
|
+
}
|
|
78
127
|
/** One emitted segment within a pull subscription section. */
|
|
79
128
|
export interface PullSegmentSummary {
|
|
80
129
|
readonly mediaType: 'rows' | 'sqlite';
|
|
@@ -221,7 +270,7 @@ export interface LeaseRevokedEvent {
|
|
|
221
270
|
readonly partition: string;
|
|
222
271
|
readonly leaseId: string;
|
|
223
272
|
}
|
|
224
|
-
export type SyncularServerEvent = RequestHandledEvent | PushAppliedEvent | PushRejectedEvent | PushConflictedEvent | PullServedEvent | SegmentDownloadedEvent | BlobUploadedEvent | BlobDownloadedEvent | BlobSweptEvent | RealtimeOpenedEvent | RealtimeClosedEvent | RealtimeDeltaEvent | RealtimeWakeEvent | PruneCompletedEvent | ScopesResolveFailedEvent | LeaseIssuedEvent | LeaseRevokedEvent;
|
|
273
|
+
export type SyncularServerEvent = RequestHandledEvent | PushAppliedEvent | PushRejectedEvent | PushConflictedEvent | ReactionQueuedEvent | ReactionStartedEvent | ReactionRetriedEvent | ReactionCompletedEvent | ReactionDeadLetteredEvent | ReactionPruneCompletedEvent | PullServedEvent | SegmentDownloadedEvent | BlobUploadedEvent | BlobDownloadedEvent | BlobSweptEvent | RealtimeOpenedEvent | RealtimeClosedEvent | RealtimeDeltaEvent | RealtimeWakeEvent | PruneCompletedEvent | ScopesResolveFailedEvent | LeaseIssuedEvent | LeaseRevokedEvent;
|
|
225
274
|
/**
|
|
226
275
|
* The seam. Optional on the server config — when absent, no event object
|
|
227
276
|
* is ever built. Implementations receive every event synchronously and
|
package/dist/handler.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* frame (§1.6).
|
|
11
11
|
*/
|
|
12
12
|
import { DecodeError, decodeMessage, } from '@syncular/core';
|
|
13
|
-
import { clockOf, limitsOf, RESOLVER_OUTAGE } from './context.js';
|
|
13
|
+
import { clockOf, limitsOf, REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, } from './context.js';
|
|
14
14
|
import { SyncError, syncError } from './errors.js';
|
|
15
15
|
import { emitEvent, } from './events.js';
|
|
16
16
|
import { END_FRAME_BYTES, encodeResponseFrame, RESPONSE_ENVELOPE_HEADER, } from './frame-bytes.js';
|
|
@@ -125,6 +125,9 @@ async function planRequest(request, ctx, schema) {
|
|
|
125
125
|
leaseToEmit: undefined,
|
|
126
126
|
};
|
|
127
127
|
}
|
|
128
|
+
if (header.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
|
|
129
|
+
throw syncError('sync.invalid_client_id', 'clientId uses a reserved server-command namespace (§1.5)');
|
|
130
|
+
}
|
|
128
131
|
// §1.5: a clientId already bound to a different actor is rejected.
|
|
129
132
|
const record = await ctx.storage.getClientRecord(ctx.partition, header.clientId);
|
|
130
133
|
if (record !== undefined && record.actorId !== ctx.actorId) {
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* handler (§5.5), and signed-URL token issuance/verification (§5.4).
|
|
9
9
|
*/
|
|
10
10
|
export * from './admin.js';
|
|
11
|
+
export * from './authoritative-query.js';
|
|
11
12
|
export * from './blob-handlers.js';
|
|
12
13
|
export * from './blob-store.js';
|
|
13
14
|
export * from './content-encoding.js';
|
|
@@ -20,6 +21,8 @@ export * from './events-ring.js';
|
|
|
20
21
|
export * from './frame-bytes.js';
|
|
21
22
|
export * from './handler.js';
|
|
22
23
|
export * from './lease-store.js';
|
|
24
|
+
export * from './operations.js';
|
|
25
|
+
export * from './operations-realtime.js';
|
|
23
26
|
export * from './pg-executor.js';
|
|
24
27
|
export * from './postgres-fanout.js';
|
|
25
28
|
export * from './postgres-storage.js';
|
|
@@ -27,6 +30,7 @@ export * from './prune.js';
|
|
|
27
30
|
export * from './pull.js';
|
|
28
31
|
export * from './push.js';
|
|
29
32
|
export * from './readiness.js';
|
|
33
|
+
export { DEFAULT_REACTION_INITIAL_BACKOFF_MS, DEFAULT_REACTION_LEASE_MS, DEFAULT_REACTION_MAX_ATTEMPTS, DEFAULT_REACTION_MAX_BACKOFF_MS, DEFAULT_REACTION_RETENTION, MAX_REACTION_FAILURE_DETAILS_BYTES, MAX_REACTION_PAYLOAD_BYTES, MAX_REACTIONS_PER_COMMIT, PermanentReactionError, pruneReactions, ReactionRunner, reactionIdempotencyKey, retryDeadLetterReaction, RetryableReactionError, type PlannedReaction, type PruneReactionsOptions, type ReactionHandler, type ReactionHandlerInput, type ReactionHandlers, type ReactionPlan, type ReactionPlanner, type ReactionPlannerInput, type ReactionPruneResult, type ReactionRetentionPolicy, type ReactionRunnerOptions, type ReactionRunResult, type ReactionTypeMap, } from './reactions.js';
|
|
30
34
|
export * from './realtime.js';
|
|
31
35
|
export * from './relational-rows.js';
|
|
32
36
|
export * from './s3-blob-store.js';
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* handler (§5.5), and signed-URL token issuance/verification (§5.4).
|
|
9
9
|
*/
|
|
10
10
|
export * from './admin.js';
|
|
11
|
+
export * from './authoritative-query.js';
|
|
11
12
|
export * from './blob-handlers.js';
|
|
12
13
|
export * from './blob-store.js';
|
|
13
14
|
export * from './content-encoding.js';
|
|
@@ -20,6 +21,8 @@ export * from './events-ring.js';
|
|
|
20
21
|
export * from './frame-bytes.js';
|
|
21
22
|
export * from './handler.js';
|
|
22
23
|
export * from './lease-store.js';
|
|
24
|
+
export * from './operations.js';
|
|
25
|
+
export * from './operations-realtime.js';
|
|
23
26
|
// The `PgExecutor` seam + Postgres storage/fanout are driver-agnostic (zero
|
|
24
27
|
// runtime deps). Concrete driver adapters (pglite for tests; Bun.sql /
|
|
25
28
|
// node-postgres for production, documented in the README) live in separate
|
|
@@ -31,6 +34,7 @@ export * from './prune.js';
|
|
|
31
34
|
export * from './pull.js';
|
|
32
35
|
export * from './push.js';
|
|
33
36
|
export * from './readiness.js';
|
|
37
|
+
export { DEFAULT_REACTION_INITIAL_BACKOFF_MS, DEFAULT_REACTION_LEASE_MS, DEFAULT_REACTION_MAX_ATTEMPTS, DEFAULT_REACTION_MAX_BACKOFF_MS, DEFAULT_REACTION_RETENTION, MAX_REACTION_FAILURE_DETAILS_BYTES, MAX_REACTION_PAYLOAD_BYTES, MAX_REACTIONS_PER_COMMIT, PermanentReactionError, pruneReactions, ReactionRunner, reactionIdempotencyKey, retryDeadLetterReaction, RetryableReactionError, } from './reactions.js';
|
|
34
38
|
export * from './realtime.js';
|
|
35
39
|
export * from './relational-rows.js';
|
|
36
40
|
export * from './s3-blob-store.js';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { RealtimeNotifier, SyncRequestContext } from './context.js';
|
|
2
|
+
import type { RemoteOperationRegistry } from './operations.js';
|
|
3
|
+
import type { StoredCommit } from './storage.js';
|
|
4
|
+
export interface RemoteOperationWatchSession {
|
|
5
|
+
receive(bytes: Uint8Array): Promise<void>;
|
|
6
|
+
close(): void;
|
|
7
|
+
}
|
|
8
|
+
/** In-memory invalidation hub. Every notification reruns affected watches. */
|
|
9
|
+
export declare class RemoteOperationWatchHub implements RealtimeNotifier {
|
|
10
|
+
#private;
|
|
11
|
+
constructor(registry: RemoteOperationRegistry);
|
|
12
|
+
connect(ctx: SyncRequestContext, send: (bytes: Uint8Array) => void): RemoteOperationWatchSession;
|
|
13
|
+
notifyCommit(partition: string, commit: StoredCommit): void;
|
|
14
|
+
}
|
|
15
|
+
/** Fan one applied commit into sync deltas and registered query watches. */
|
|
16
|
+
export declare function composeRealtimeNotifiers(...notifiers: readonly RealtimeNotifier[]): RealtimeNotifier;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { decodeRemoteOperationRealtimeMessage, encodeRemoteOperationRealtimeMessage, } from '@syncular/core';
|
|
2
|
+
import { REMOTE_COMMAND_CLIENT_ID_PREFIX } from './context.js';
|
|
3
|
+
import { SyncError, syncError } from './errors.js';
|
|
4
|
+
class WatchSession {
|
|
5
|
+
#ctx;
|
|
6
|
+
#registry;
|
|
7
|
+
#send;
|
|
8
|
+
#closed;
|
|
9
|
+
#watches = new Map();
|
|
10
|
+
#isClosed = false;
|
|
11
|
+
get partition() {
|
|
12
|
+
return this.#ctx.partition;
|
|
13
|
+
}
|
|
14
|
+
constructor(ctx, registry, send, closed) {
|
|
15
|
+
this.#ctx = ctx;
|
|
16
|
+
this.#registry = registry;
|
|
17
|
+
this.#send = send;
|
|
18
|
+
this.#closed = closed;
|
|
19
|
+
}
|
|
20
|
+
async receive(bytes) {
|
|
21
|
+
if (this.#isClosed)
|
|
22
|
+
return;
|
|
23
|
+
let message;
|
|
24
|
+
try {
|
|
25
|
+
message = decodeRemoteOperationRealtimeMessage(bytes);
|
|
26
|
+
if (typeof message !== 'object' ||
|
|
27
|
+
message === null ||
|
|
28
|
+
message.revision !== 1 ||
|
|
29
|
+
(message.kind !== 'watch' && message.kind !== 'unwatch') ||
|
|
30
|
+
typeof message.watchId !== 'string' ||
|
|
31
|
+
message.watchId.length === 0) {
|
|
32
|
+
throw syncError('operation.invalid_request');
|
|
33
|
+
}
|
|
34
|
+
if (message.kind === 'watch') {
|
|
35
|
+
if (typeof message.clientId !== 'string' ||
|
|
36
|
+
message.clientId.length === 0 ||
|
|
37
|
+
typeof message.operationId !== 'string' ||
|
|
38
|
+
message.operationId.length === 0) {
|
|
39
|
+
throw syncError('operation.invalid_request');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error instanceof SyncError)
|
|
45
|
+
throw error;
|
|
46
|
+
throw syncError('operation.invalid_request');
|
|
47
|
+
}
|
|
48
|
+
if (message.kind === 'unwatch') {
|
|
49
|
+
this.#watches.delete(message.watchId);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (this.#watches.has(message.watchId)) {
|
|
53
|
+
this.#sendError(message.watchId, syncError('operation.invalid_request', 'watchId is already active'));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (message.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
|
|
57
|
+
this.#sendError(message.watchId, syncError('sync.invalid_client_id', 'clientId uses a reserved server-command namespace (§1.5)'));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const clientRecord = await this.#ctx.storage.getClientRecord(this.#ctx.partition, message.clientId);
|
|
61
|
+
if (clientRecord !== undefined &&
|
|
62
|
+
clientRecord.actorId !== this.#ctx.actorId) {
|
|
63
|
+
this.#sendError(message.watchId, syncError('sync.invalid_client_id', 'clientId is bound to a different actor in this partition (§1.5)'));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const operation = this.#registry.get(message.operationId);
|
|
67
|
+
if (operation === undefined || operation.kind !== 'query') {
|
|
68
|
+
this.#sendError(message.watchId, syncError('operation.unknown'));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const state = {
|
|
72
|
+
watchId: message.watchId,
|
|
73
|
+
clientId: message.clientId,
|
|
74
|
+
operation,
|
|
75
|
+
params: message.params,
|
|
76
|
+
running: false,
|
|
77
|
+
dirty: false,
|
|
78
|
+
};
|
|
79
|
+
this.#watches.set(message.watchId, state);
|
|
80
|
+
await this.#refresh(state);
|
|
81
|
+
}
|
|
82
|
+
notify(tables) {
|
|
83
|
+
for (const state of this.#watches.values()) {
|
|
84
|
+
if (!state.operation.tables.some((table) => tables.has(table)))
|
|
85
|
+
continue;
|
|
86
|
+
if (state.running)
|
|
87
|
+
state.dirty = true;
|
|
88
|
+
else
|
|
89
|
+
void this.#refresh(state);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async #refresh(state) {
|
|
93
|
+
if (this.#isClosed || this.#watches.get(state.watchId) !== state)
|
|
94
|
+
return;
|
|
95
|
+
if (state.running) {
|
|
96
|
+
state.dirty = true;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
state.running = true;
|
|
100
|
+
try {
|
|
101
|
+
do {
|
|
102
|
+
state.dirty = false;
|
|
103
|
+
try {
|
|
104
|
+
const response = await state.operation.run(this.#ctx, state.clientId, state.params);
|
|
105
|
+
if (this.#isClosed || this.#watches.get(state.watchId) !== state) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (response.kind !== 'query') {
|
|
109
|
+
throw syncError('operation.query_failed');
|
|
110
|
+
}
|
|
111
|
+
if (!this.#emit(encodeRemoteOperationRealtimeMessage({
|
|
112
|
+
revision: 1,
|
|
113
|
+
kind: 'snapshot',
|
|
114
|
+
watchId: state.watchId,
|
|
115
|
+
operationId: response.operationId,
|
|
116
|
+
rows: response.rows,
|
|
117
|
+
maxCommitSeq: response.maxCommitSeq,
|
|
118
|
+
})))
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (this.#isClosed || this.#watches.get(state.watchId) !== state) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.#sendError(state.watchId, error instanceof SyncError
|
|
126
|
+
? error
|
|
127
|
+
: syncError('operation.query_failed'));
|
|
128
|
+
}
|
|
129
|
+
} while (state.dirty &&
|
|
130
|
+
!this.#isClosed &&
|
|
131
|
+
this.#watches.get(state.watchId) === state);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
state.running = false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
#sendError(watchId, error) {
|
|
138
|
+
this.#emit(encodeRemoteOperationRealtimeMessage({
|
|
139
|
+
revision: 1,
|
|
140
|
+
kind: 'watch_error',
|
|
141
|
+
watchId,
|
|
142
|
+
code: error.code,
|
|
143
|
+
message: error.message,
|
|
144
|
+
retryable: error.retryable,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
#emit(bytes) {
|
|
148
|
+
if (this.#isClosed)
|
|
149
|
+
return false;
|
|
150
|
+
try {
|
|
151
|
+
this.#send(bytes);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
this.close();
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
close() {
|
|
160
|
+
if (this.#isClosed)
|
|
161
|
+
return;
|
|
162
|
+
this.#isClosed = true;
|
|
163
|
+
this.#watches.clear();
|
|
164
|
+
this.#closed();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** In-memory invalidation hub. Every notification reruns affected watches. */
|
|
168
|
+
export class RemoteOperationWatchHub {
|
|
169
|
+
#registry;
|
|
170
|
+
#sessions = new Set();
|
|
171
|
+
constructor(registry) {
|
|
172
|
+
this.#registry = registry;
|
|
173
|
+
}
|
|
174
|
+
connect(ctx, send) {
|
|
175
|
+
const session = new WatchSession(ctx, this.#registry, send, () => {
|
|
176
|
+
this.#sessions.delete(session);
|
|
177
|
+
});
|
|
178
|
+
this.#sessions.add(session);
|
|
179
|
+
return session;
|
|
180
|
+
}
|
|
181
|
+
notifyCommit(partition, commit) {
|
|
182
|
+
const tables = new Set(commit.changes.map((change) => change.table));
|
|
183
|
+
for (const session of this.#sessions) {
|
|
184
|
+
if (session.partition === partition)
|
|
185
|
+
session.notify(tables);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Fan one applied commit into sync deltas and registered query watches. */
|
|
190
|
+
export function composeRealtimeNotifiers(...notifiers) {
|
|
191
|
+
return {
|
|
192
|
+
notifyCommit: async (partition, commit) => {
|
|
193
|
+
await Promise.all(notifiers.map((notifier) => notifier.notifyCommit(partition, commit)));
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|