@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/postgres-storage.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
1
2
|
import { syncError } from './errors.js';
|
|
2
3
|
import { asBytes, asNumber, } from './pg-executor.js';
|
|
3
4
|
import { commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
@@ -63,6 +64,26 @@ CREATE TABLE IF NOT EXISTS sync_push_results(
|
|
|
63
64
|
client_commit_id TEXT NOT NULL, result JSONB NOT NULL,
|
|
64
65
|
PRIMARY KEY(partition, client_id, client_commit_id)
|
|
65
66
|
);
|
|
67
|
+
CREATE TABLE IF NOT EXISTS sync_reactions(
|
|
68
|
+
partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,
|
|
69
|
+
type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,
|
|
70
|
+
source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,
|
|
71
|
+
source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,
|
|
72
|
+
available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,
|
|
73
|
+
attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,
|
|
74
|
+
lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,
|
|
75
|
+
last_failure JSONB,
|
|
76
|
+
PRIMARY KEY(partition, idempotency_key),
|
|
77
|
+
CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))
|
|
78
|
+
);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS sync_reactions_due
|
|
80
|
+
ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS sync_reactions_lease
|
|
82
|
+
ON sync_reactions(partition, status, lease_expires_at_ms);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS sync_reactions_completed
|
|
84
|
+
ON sync_reactions(partition, status, completed_at_ms, idempotency_key);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS sync_reactions_dead_letter
|
|
86
|
+
ON sync_reactions(partition, status, available_at_ms, idempotency_key);
|
|
66
87
|
CREATE TABLE IF NOT EXISTS sync_clients(
|
|
67
88
|
partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,
|
|
68
89
|
cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,
|
|
@@ -83,6 +104,32 @@ function toBase64(bytes) {
|
|
|
83
104
|
function fromBase64(text) {
|
|
84
105
|
return new Uint8Array(Buffer.from(text, 'base64'));
|
|
85
106
|
}
|
|
107
|
+
function toStoredReaction(record) {
|
|
108
|
+
return {
|
|
109
|
+
idempotencyKey: record.idempotency_key,
|
|
110
|
+
type: record.type,
|
|
111
|
+
version: asNumber(record.version),
|
|
112
|
+
payload: asJson(record.payload),
|
|
113
|
+
sourceClientId: record.source_client_id,
|
|
114
|
+
sourceClientCommitId: record.source_client_commit_id,
|
|
115
|
+
sourceCommitSeq: asNumber(record.source_commit_seq),
|
|
116
|
+
createdAtMs: asNumber(record.created_at_ms),
|
|
117
|
+
maxAttempts: asNumber(record.max_attempts),
|
|
118
|
+
status: record.status,
|
|
119
|
+
attempts: asNumber(record.attempts),
|
|
120
|
+
availableAtMs: asNumber(record.available_at_ms),
|
|
121
|
+
...(record.lease_owner !== null ? { leaseOwner: record.lease_owner } : {}),
|
|
122
|
+
...(record.lease_expires_at_ms !== null
|
|
123
|
+
? { leaseExpiresAtMs: asNumber(record.lease_expires_at_ms) }
|
|
124
|
+
: {}),
|
|
125
|
+
...(record.completed_at_ms !== null
|
|
126
|
+
? { completedAtMs: asNumber(record.completed_at_ms) }
|
|
127
|
+
: {}),
|
|
128
|
+
...(record.last_failure !== null
|
|
129
|
+
? { lastFailure: asJson(record.last_failure) }
|
|
130
|
+
: {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
86
133
|
/**
|
|
87
134
|
* Serialize a push result to a JSON-able object (stored in a JSONB column).
|
|
88
135
|
* `serverRow` bytes are base64-encoded — JSONB cannot hold raw bytes.
|
|
@@ -425,6 +472,27 @@ class PostgresTransaction {
|
|
|
425
472
|
JSON.stringify(serializePushResult(result)),
|
|
426
473
|
]);
|
|
427
474
|
}
|
|
475
|
+
async enqueueReactions(reactions) {
|
|
476
|
+
this.#assertOpen();
|
|
477
|
+
for (const reaction of reactions) {
|
|
478
|
+
await this.#client.query(`INSERT INTO sync_reactions(
|
|
479
|
+
partition, idempotency_key, type, version, payload,
|
|
480
|
+
source_client_id, source_client_commit_id, source_commit_seq,
|
|
481
|
+
created_at_ms, available_at_ms, status, attempts, max_attempts
|
|
482
|
+
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$9,'pending',0,$10)`, [
|
|
483
|
+
this.#partition,
|
|
484
|
+
reaction.idempotencyKey,
|
|
485
|
+
reaction.type,
|
|
486
|
+
reaction.version,
|
|
487
|
+
JSON.stringify(reaction.payload),
|
|
488
|
+
reaction.sourceClientId,
|
|
489
|
+
reaction.sourceClientCommitId,
|
|
490
|
+
reaction.sourceCommitSeq,
|
|
491
|
+
reaction.createdAtMs,
|
|
492
|
+
reaction.maxAttempts,
|
|
493
|
+
]);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
428
496
|
async commit() {
|
|
429
497
|
this.#assertOpen();
|
|
430
498
|
this.#open = false;
|
|
@@ -599,6 +667,23 @@ export class PostgresServerStorage {
|
|
|
599
667
|
const { rows } = await this.#exec.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
|
|
600
668
|
return rows[0] === undefined ? 0 : asNumber(rows[0].max_commit_seq);
|
|
601
669
|
}
|
|
670
|
+
async queryAuthoritative(partition, query) {
|
|
671
|
+
if (this.#tables === undefined) {
|
|
672
|
+
throw new Error('ensureSchema(schema) must run before registered queries');
|
|
673
|
+
}
|
|
674
|
+
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.sql, query.params, query.tables, this.#tables), partition);
|
|
675
|
+
return this.#exec.transaction(async (client) => {
|
|
676
|
+
await client.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
|
677
|
+
const result = await client.query(postgresPlaceholders(prepared.sql), prepared.params);
|
|
678
|
+
const cursor = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
|
|
679
|
+
return {
|
|
680
|
+
rows: result.rows,
|
|
681
|
+
maxCommitSeq: cursor.rows[0] === undefined
|
|
682
|
+
? 0
|
|
683
|
+
: asNumber(cursor.rows[0].max_commit_seq),
|
|
684
|
+
};
|
|
685
|
+
});
|
|
686
|
+
}
|
|
602
687
|
async getHorizonSeq(partition) {
|
|
603
688
|
const { rows } = await this.#exec.query('SELECT horizon_seq FROM sync_partitions WHERE partition=$1', [partition]);
|
|
604
689
|
return rows[0] === undefined ? 0 : asNumber(rows[0].horizon_seq);
|
|
@@ -624,6 +709,141 @@ export class PostgresServerStorage {
|
|
|
624
709
|
getPushResult(partition, clientId, clientCommitId) {
|
|
625
710
|
return getPushResultOn(this.#exec, partition, clientId, clientCommitId);
|
|
626
711
|
}
|
|
712
|
+
async claimReactions(partition, query) {
|
|
713
|
+
if (query.types.length === 0 || query.limit <= 0)
|
|
714
|
+
return [];
|
|
715
|
+
const typeParams = query.types.map((_, index) => `$${index + 2}`).join(',');
|
|
716
|
+
const nowParam = query.types.length + 2;
|
|
717
|
+
const limitParam = nowParam + 1;
|
|
718
|
+
const workerParam = limitParam + 1;
|
|
719
|
+
const expiresParam = workerParam + 1;
|
|
720
|
+
const { rows } = await this.#exec.query(`WITH due AS (
|
|
721
|
+
SELECT partition, idempotency_key
|
|
722
|
+
FROM sync_reactions
|
|
723
|
+
WHERE partition=$1 AND type IN (${typeParams})
|
|
724
|
+
AND ((status='pending' AND available_at_ms<=$${nowParam})
|
|
725
|
+
OR (status='leased' AND lease_expires_at_ms<=$${nowParam}))
|
|
726
|
+
ORDER BY CASE WHEN status='leased' THEN lease_expires_at_ms
|
|
727
|
+
ELSE available_at_ms END,
|
|
728
|
+
created_at_ms, idempotency_key
|
|
729
|
+
FOR UPDATE SKIP LOCKED
|
|
730
|
+
LIMIT $${limitParam}
|
|
731
|
+
)
|
|
732
|
+
UPDATE sync_reactions AS reaction
|
|
733
|
+
SET status='leased', attempts=reaction.attempts+1,
|
|
734
|
+
lease_owner=$${workerParam}, lease_expires_at_ms=$${expiresParam},
|
|
735
|
+
completed_at_ms=NULL
|
|
736
|
+
FROM due
|
|
737
|
+
WHERE reaction.partition=due.partition
|
|
738
|
+
AND reaction.idempotency_key=due.idempotency_key
|
|
739
|
+
RETURNING reaction.*`, [
|
|
740
|
+
partition,
|
|
741
|
+
...query.types,
|
|
742
|
+
query.nowMs,
|
|
743
|
+
query.limit,
|
|
744
|
+
query.leaseOwner,
|
|
745
|
+
Math.min(Number.MAX_SAFE_INTEGER, query.nowMs + query.leaseDurationMs),
|
|
746
|
+
]);
|
|
747
|
+
return rows
|
|
748
|
+
.map(toStoredReaction)
|
|
749
|
+
.sort((a, b) => a.createdAtMs - b.createdAtMs ||
|
|
750
|
+
a.idempotencyKey.localeCompare(b.idempotencyKey));
|
|
751
|
+
}
|
|
752
|
+
async completeReaction(partition, idempotencyKey, leaseOwner, completedAtMs) {
|
|
753
|
+
const result = await this.#exec.query(`UPDATE sync_reactions
|
|
754
|
+
SET status='completed', completed_at_ms=$4,
|
|
755
|
+
lease_owner=NULL, lease_expires_at_ms=NULL
|
|
756
|
+
WHERE partition=$1 AND idempotency_key=$2
|
|
757
|
+
AND status='leased' AND lease_owner=$3`, [partition, idempotencyKey, leaseOwner, completedAtMs]);
|
|
758
|
+
return result.rowCount === 1;
|
|
759
|
+
}
|
|
760
|
+
async extendReactionLease(partition, idempotencyKey, leaseOwner, leaseExpiresAtMs) {
|
|
761
|
+
const result = await this.#exec.query(`UPDATE sync_reactions SET lease_expires_at_ms=$4
|
|
762
|
+
WHERE partition=$1 AND idempotency_key=$2
|
|
763
|
+
AND status='leased' AND lease_owner=$3`, [partition, idempotencyKey, leaseOwner, leaseExpiresAtMs]);
|
|
764
|
+
return result.rowCount === 1;
|
|
765
|
+
}
|
|
766
|
+
async failReaction(partition, idempotencyKey, update) {
|
|
767
|
+
const result = await this.#exec.query(`UPDATE sync_reactions
|
|
768
|
+
SET status=$4, available_at_ms=$5, last_failure=$6,
|
|
769
|
+
lease_owner=NULL, lease_expires_at_ms=NULL
|
|
770
|
+
WHERE partition=$1 AND idempotency_key=$2
|
|
771
|
+
AND status='leased' AND lease_owner=$3`, [
|
|
772
|
+
partition,
|
|
773
|
+
idempotencyKey,
|
|
774
|
+
update.leaseOwner,
|
|
775
|
+
update.retryAtMs === undefined ? 'dead-letter' : 'pending',
|
|
776
|
+
update.retryAtMs ?? update.failure.atMs,
|
|
777
|
+
JSON.stringify(update.failure),
|
|
778
|
+
]);
|
|
779
|
+
return result.rowCount === 1;
|
|
780
|
+
}
|
|
781
|
+
async retryReaction(partition, idempotencyKey, nowMs) {
|
|
782
|
+
const result = await this.#exec.query(`UPDATE sync_reactions
|
|
783
|
+
SET status='pending', attempts=0, available_at_ms=$3,
|
|
784
|
+
last_failure=NULL, lease_owner=NULL, lease_expires_at_ms=NULL,
|
|
785
|
+
completed_at_ms=NULL
|
|
786
|
+
WHERE partition=$1 AND idempotency_key=$2 AND status='dead-letter'`, [partition, idempotencyKey, nowMs]);
|
|
787
|
+
return result.rowCount === 1;
|
|
788
|
+
}
|
|
789
|
+
async getReaction(partition, idempotencyKey) {
|
|
790
|
+
const { rows } = await this.#exec.query('SELECT * FROM sync_reactions WHERE partition=$1 AND idempotency_key=$2', [partition, idempotencyKey]);
|
|
791
|
+
return rows[0] === undefined ? undefined : toStoredReaction(rows[0]);
|
|
792
|
+
}
|
|
793
|
+
async listReactions(partition, query) {
|
|
794
|
+
const where = ['partition=$1'];
|
|
795
|
+
const params = [partition];
|
|
796
|
+
if (query.statuses !== undefined && query.statuses.length > 0) {
|
|
797
|
+
const placeholders = query.statuses.map((_, index) => `$${params.length + index + 1}`);
|
|
798
|
+
where.push(`status IN (${placeholders.join(',')})`);
|
|
799
|
+
params.push(...query.statuses);
|
|
800
|
+
}
|
|
801
|
+
if (query.types !== undefined && query.types.length > 0) {
|
|
802
|
+
const placeholders = query.types.map((_, index) => `$${params.length + index + 1}`);
|
|
803
|
+
where.push(`type IN (${placeholders.join(',')})`);
|
|
804
|
+
params.push(...query.types);
|
|
805
|
+
}
|
|
806
|
+
params.push(query.limit);
|
|
807
|
+
const { rows } = await this.#exec.query(`SELECT * FROM sync_reactions WHERE ${where.join(' AND ')}
|
|
808
|
+
ORDER BY created_at_ms DESC, idempotency_key DESC LIMIT $${params.length}`, params);
|
|
809
|
+
return rows.map(toStoredReaction);
|
|
810
|
+
}
|
|
811
|
+
async pruneReactions(partition, query) {
|
|
812
|
+
if (query.limit <= 0)
|
|
813
|
+
return { completed: 0, deadLetter: 0 };
|
|
814
|
+
const { rows } = await this.#exec.query(`WITH targets AS (
|
|
815
|
+
SELECT partition, idempotency_key
|
|
816
|
+
FROM sync_reactions
|
|
817
|
+
WHERE partition=$1
|
|
818
|
+
AND ((status='completed' AND completed_at_ms IS NOT NULL
|
|
819
|
+
AND completed_at_ms<$2)
|
|
820
|
+
OR (status='dead-letter' AND available_at_ms<$3))
|
|
821
|
+
ORDER BY CASE WHEN status='completed' THEN completed_at_ms
|
|
822
|
+
ELSE available_at_ms END,
|
|
823
|
+
idempotency_key
|
|
824
|
+
FOR UPDATE SKIP LOCKED
|
|
825
|
+
LIMIT $4
|
|
826
|
+
)
|
|
827
|
+
DELETE FROM sync_reactions AS reaction
|
|
828
|
+
USING targets
|
|
829
|
+
WHERE reaction.partition=targets.partition
|
|
830
|
+
AND reaction.idempotency_key=targets.idempotency_key
|
|
831
|
+
AND ((reaction.status='completed'
|
|
832
|
+
AND reaction.completed_at_ms IS NOT NULL
|
|
833
|
+
AND reaction.completed_at_ms<$2)
|
|
834
|
+
OR (reaction.status='dead-letter'
|
|
835
|
+
AND reaction.available_at_ms<$3))
|
|
836
|
+
RETURNING reaction.status`, [
|
|
837
|
+
partition,
|
|
838
|
+
query.completedBeforeMs,
|
|
839
|
+
query.deadLetterBeforeMs,
|
|
840
|
+
query.limit,
|
|
841
|
+
]);
|
|
842
|
+
return {
|
|
843
|
+
completed: rows.filter((row) => row.status === 'completed').length,
|
|
844
|
+
deadLetter: rows.filter((row) => row.status === 'dead-letter').length,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
627
847
|
async readCommitWindow(partition, query) {
|
|
628
848
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
629
849
|
const firstVariable = variables[0];
|
package/dist/push.d.ts
CHANGED
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
* merge); a validator throw rejects the whole commit atomically with a
|
|
18
18
|
* host code.
|
|
19
19
|
*/
|
|
20
|
-
import { type PushCommitFrame, type PushResultFrame } from '@syncular/core';
|
|
20
|
+
import { type PushCommitFrame, type PushOperation, type PushResultFrame } from '@syncular/core';
|
|
21
21
|
import type { SyncRequestContext } from './context.js';
|
|
22
22
|
import type { CompiledSchema } from './schema.js';
|
|
23
23
|
import type { ResolvedScopes } from './scopes.js';
|
|
24
|
-
import type { StoredCommit } from './storage.js';
|
|
24
|
+
import type { StorageTransaction, StoredCommit } from './storage.js';
|
|
25
25
|
export interface ProcessedPushCommit {
|
|
26
26
|
readonly frame: PushResultFrame;
|
|
27
27
|
/** True when this request observed an already-recorded idempotency outcome. */
|
|
@@ -43,3 +43,9 @@ export declare function processPushCommit(ctx: SyncRequestContext, schema: Compi
|
|
|
43
43
|
* cache provenance needed by structured events and server helpers.
|
|
44
44
|
*/
|
|
45
45
|
export declare function processPushCommitWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, frame: PushCommitFrame): Promise<ProcessedPushCommit>;
|
|
46
|
+
/**
|
|
47
|
+
* Shared serialized apply path for SSP2 commits and authoritative commands.
|
|
48
|
+
* The builder runs after the partition lock and idempotency re-check, so its
|
|
49
|
+
* reads and the returned operations share the transaction that is committed.
|
|
50
|
+
*/
|
|
51
|
+
export declare function processPushOperationsWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, clientCommitId: string, buildOperations: (tx: StorageTransaction) => Promise<readonly PushOperation[]>): Promise<ProcessedPushCommit>;
|
package/dist/push.js
CHANGED
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
import { decodeRow, encodeRow, parseBlobRef, } from '@syncular/core';
|
|
21
21
|
import { clockOf } from './context.js';
|
|
22
22
|
import { SyncError } from './errors.js';
|
|
23
|
+
import { emitEvent } from './events.js';
|
|
24
|
+
import { prepareReactions, toNewReactions, } from './reactions.js';
|
|
23
25
|
import { authorizeWrite, renderScopeValue, storedScopesForRow } from './scopes.js';
|
|
24
26
|
import { StorageConstraintError } from './storage-errors.js';
|
|
25
27
|
import { CommitValidationRejection, toValidateRow, ValidationRejection, } from './validate.js';
|
|
@@ -514,10 +516,18 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
514
516
|
* cache provenance needed by structured events and server helpers.
|
|
515
517
|
*/
|
|
516
518
|
export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame) {
|
|
519
|
+
return processPushOperationsWithTrace(ctx, schema, resolved, clientId, frame.clientCommitId, async () => frame.operations);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Shared serialized apply path for SSP2 commits and authoritative commands.
|
|
523
|
+
* The builder runs after the partition lock and idempotency re-check, so its
|
|
524
|
+
* reads and the returned operations share the transaction that is committed.
|
|
525
|
+
*/
|
|
526
|
+
export async function processPushOperationsWithTrace(ctx, schema, resolved, clientId, clientCommitId, buildOperations) {
|
|
517
527
|
const { storage, partition } = ctx;
|
|
518
528
|
let persisted;
|
|
519
529
|
try {
|
|
520
|
-
persisted = await storage.getPushResult(partition, clientId,
|
|
530
|
+
persisted = await storage.getPushResult(partition, clientId, clientCommitId);
|
|
521
531
|
}
|
|
522
532
|
catch (error) {
|
|
523
533
|
if (error instanceof SyncError &&
|
|
@@ -525,28 +535,30 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
|
|
|
525
535
|
// §6.3: answer the retryable cache-miss for this commit rather than
|
|
526
536
|
// re-applying. Not persisted — a retry may find a readable record.
|
|
527
537
|
return {
|
|
528
|
-
frame: idempotencyCacheMissFrame(
|
|
538
|
+
frame: idempotencyCacheMissFrame(clientCommitId, error),
|
|
529
539
|
replayed: false,
|
|
530
540
|
};
|
|
531
541
|
}
|
|
532
542
|
throw error;
|
|
533
543
|
}
|
|
534
544
|
if (persisted !== undefined) {
|
|
535
|
-
return processedPushCommit(
|
|
545
|
+
return processedPushCommit(clientCommitId, persisted, true);
|
|
536
546
|
}
|
|
537
547
|
const createdAtMs = clockOf(ctx)();
|
|
538
548
|
const blobCtx = { store: ctx.blobs, partition };
|
|
539
549
|
const crdtMergers = ctx.crdtMergers;
|
|
540
550
|
const validators = ctx.validators;
|
|
541
551
|
const commitValidator = ctx.commitValidator;
|
|
552
|
+
const reactionPlanner = ctx.reactionPlanner;
|
|
542
553
|
const tx = await storage.begin(partition);
|
|
543
554
|
const lockPartitionForPush = tx.lockPartitionForPush?.bind(tx) ??
|
|
544
555
|
tx.lockPartitionForCommitValidation?.bind(tx);
|
|
545
556
|
const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
|
|
546
557
|
try {
|
|
547
558
|
if (lockPartitionForPush === undefined ||
|
|
548
|
-
commitRejectedPushResult === undefined
|
|
549
|
-
|
|
559
|
+
commitRejectedPushResult === undefined ||
|
|
560
|
+
(reactionPlanner !== undefined && tx.enqueueReactions === undefined)) {
|
|
561
|
+
throw new Error('storage transaction does not support serialized push apply, atomic rejection finalization, and configured durable reactions');
|
|
550
562
|
}
|
|
551
563
|
await lockPartitionForPush();
|
|
552
564
|
// The optimistic lookup above may have raced another delivery. Re-check
|
|
@@ -559,11 +571,11 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
|
|
|
559
571
|
// transaction-scoped read observes it.
|
|
560
572
|
try {
|
|
561
573
|
const serializedPersisted = tx.getPushResult !== undefined
|
|
562
|
-
? await tx.getPushResult(clientId,
|
|
563
|
-
: await storage.getPushResult(partition, clientId,
|
|
574
|
+
? await tx.getPushResult(clientId, clientCommitId)
|
|
575
|
+
: await storage.getPushResult(partition, clientId, clientCommitId);
|
|
564
576
|
if (serializedPersisted !== undefined) {
|
|
565
577
|
await tx.rollback();
|
|
566
|
-
return processedPushCommit(
|
|
578
|
+
return processedPushCommit(clientCommitId, serializedPersisted, true);
|
|
567
579
|
}
|
|
568
580
|
}
|
|
569
581
|
catch (error) {
|
|
@@ -571,7 +583,7 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
|
|
|
571
583
|
error.code === 'sync.idempotency_cache_miss') {
|
|
572
584
|
await tx.rollback();
|
|
573
585
|
return {
|
|
574
|
-
frame: idempotencyCacheMissFrame(
|
|
586
|
+
frame: idempotencyCacheMissFrame(clientCommitId, error),
|
|
575
587
|
replayed: false,
|
|
576
588
|
};
|
|
577
589
|
}
|
|
@@ -581,8 +593,9 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
|
|
|
581
593
|
const changes = [];
|
|
582
594
|
const validatedOperations = [];
|
|
583
595
|
let terminated;
|
|
584
|
-
|
|
585
|
-
|
|
596
|
+
const operations = await buildOperations(tx);
|
|
597
|
+
for (let opIndex = 0; opIndex < operations.length; opIndex++) {
|
|
598
|
+
const op = operations[opIndex];
|
|
586
599
|
if (op === undefined)
|
|
587
600
|
continue;
|
|
588
601
|
const outcome = await applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMergers, validators, partition, ctx.actorId);
|
|
@@ -596,11 +609,22 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
|
|
|
596
609
|
changes.push(outcome.change);
|
|
597
610
|
}
|
|
598
611
|
if (terminated === undefined) {
|
|
599
|
-
const commitReject = await runCommitValidator(commitValidator, tx, schema, clientId,
|
|
612
|
+
const commitReject = await runCommitValidator(commitValidator, tx, schema, clientId, clientCommitId, ctx.actorId, partition, validatedOperations);
|
|
600
613
|
if (commitReject?.kind === 'terminate') {
|
|
601
614
|
terminated = commitReject.record;
|
|
602
615
|
}
|
|
603
616
|
}
|
|
617
|
+
let preparedReactions = [];
|
|
618
|
+
if (terminated === undefined && reactionPlanner !== undefined) {
|
|
619
|
+
preparedReactions = await prepareReactions(reactionPlanner, {
|
|
620
|
+
clientId,
|
|
621
|
+
clientCommitId,
|
|
622
|
+
actorId: ctx.actorId,
|
|
623
|
+
partition,
|
|
624
|
+
operations: validatedOperations,
|
|
625
|
+
read: commitValidationReader(tx, schema),
|
|
626
|
+
});
|
|
627
|
+
}
|
|
604
628
|
if (terminated !== undefined) {
|
|
605
629
|
// §6.3 rejected: only the terminating operation's record; §6.4:
|
|
606
630
|
// every write of the commit rolls back.
|
|
@@ -610,27 +634,57 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
|
|
|
610
634
|
});
|
|
611
635
|
// Discard candidates and persist the rejection while retaining the same
|
|
612
636
|
// partition lock. There is no unlock gap in which a duplicate can rerun.
|
|
613
|
-
await commitRejectedPushResult(clientId,
|
|
614
|
-
const canonical = await storage.getPushResult(partition, clientId,
|
|
637
|
+
await commitRejectedPushResult(clientId, clientCommitId, stored);
|
|
638
|
+
const canonical = await storage.getPushResult(partition, clientId, clientCommitId);
|
|
615
639
|
if (canonical === undefined) {
|
|
616
640
|
throw new Error('push rejection finalization did not persist an outcome');
|
|
617
641
|
}
|
|
618
|
-
return processedPushCommit(
|
|
642
|
+
return processedPushCommit(clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
|
|
619
643
|
}
|
|
620
644
|
const commitSeq = await tx.appendCommit({
|
|
621
645
|
clientId,
|
|
622
|
-
clientCommitId
|
|
646
|
+
clientCommitId,
|
|
623
647
|
actorId: ctx.actorId,
|
|
624
648
|
createdAtMs,
|
|
625
649
|
changes,
|
|
626
650
|
});
|
|
651
|
+
const newReactions = toNewReactions(preparedReactions, {
|
|
652
|
+
clientId,
|
|
653
|
+
clientCommitId,
|
|
654
|
+
commitSeq,
|
|
655
|
+
createdAtMs,
|
|
656
|
+
});
|
|
657
|
+
if (newReactions.length > 0) {
|
|
658
|
+
const enqueueReactions = tx.enqueueReactions;
|
|
659
|
+
if (enqueueReactions === undefined) {
|
|
660
|
+
throw new Error('storage lost durable reaction enqueue support');
|
|
661
|
+
}
|
|
662
|
+
await enqueueReactions.call(tx, newReactions);
|
|
663
|
+
}
|
|
627
664
|
const stored = newStoredPushResult(createdAtMs, {
|
|
628
665
|
status: 'applied',
|
|
629
666
|
commitSeq,
|
|
630
667
|
results,
|
|
631
668
|
});
|
|
632
|
-
await tx.putPushResult(clientId,
|
|
669
|
+
await tx.putPushResult(clientId, clientCommitId, stored);
|
|
633
670
|
await tx.commit();
|
|
671
|
+
if (ctx.events !== undefined) {
|
|
672
|
+
const atMs = clockOf(ctx)();
|
|
673
|
+
for (const reaction of newReactions) {
|
|
674
|
+
emitEvent(ctx.events, {
|
|
675
|
+
type: 'reaction.queued',
|
|
676
|
+
atMs,
|
|
677
|
+
partition,
|
|
678
|
+
actorId: ctx.actorId,
|
|
679
|
+
clientId,
|
|
680
|
+
clientCommitId,
|
|
681
|
+
commitSeq,
|
|
682
|
+
idempotencyKey: reaction.idempotencyKey,
|
|
683
|
+
reactionType: reaction.type,
|
|
684
|
+
version: reaction.version,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
634
688
|
if (ctx.realtime !== undefined && changes.length > 0) {
|
|
635
689
|
await ctx.realtime.notifyCommit(partition, {
|
|
636
690
|
commitSeq,
|
|
@@ -639,7 +693,7 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
|
|
|
639
693
|
changes,
|
|
640
694
|
});
|
|
641
695
|
}
|
|
642
|
-
return processedPushCommit(
|
|
696
|
+
return processedPushCommit(clientCommitId, stored, false);
|
|
643
697
|
}
|
|
644
698
|
catch (error) {
|
|
645
699
|
if (error instanceof StorageConstraintError) {
|
|
@@ -660,12 +714,12 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
|
|
|
660
714
|
throw new Error('storage transaction lost atomic push rejection finalization support');
|
|
661
715
|
}
|
|
662
716
|
try {
|
|
663
|
-
await commitRejectedPushResult(clientId,
|
|
664
|
-
const canonical = await storage.getPushResult(partition, clientId,
|
|
717
|
+
await commitRejectedPushResult(clientId, clientCommitId, stored);
|
|
718
|
+
const canonical = await storage.getPushResult(partition, clientId, clientCommitId);
|
|
665
719
|
if (canonical === undefined) {
|
|
666
720
|
throw new Error('push rejection finalization did not persist an outcome');
|
|
667
721
|
}
|
|
668
|
-
return processedPushCommit(
|
|
722
|
+
return processedPushCommit(clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
|
|
669
723
|
}
|
|
670
724
|
catch (finalizationError) {
|
|
671
725
|
// A failed finalization must still release the transaction: on
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable post-commit reactions. Planning runs inside the authoritative push
|
|
3
|
+
* transaction and may only produce bounded data. Delivery runs later under a
|
|
4
|
+
* lease and is at-least-once, so handlers receive the stable idempotency key.
|
|
5
|
+
*/
|
|
6
|
+
import type { SyncularServerEvents } from './events.js';
|
|
7
|
+
import type { DurableJsonValue, NewReaction, ServerStorage } from './storage.js';
|
|
8
|
+
import type { CommitValidationReader, ValidateCommitOperation } from './validate.js';
|
|
9
|
+
export declare const MAX_REACTIONS_PER_COMMIT = 100;
|
|
10
|
+
export declare const MAX_REACTION_PAYLOAD_BYTES: number;
|
|
11
|
+
export declare const MAX_REACTION_FAILURE_DETAILS_BYTES: number;
|
|
12
|
+
export declare const DEFAULT_REACTION_MAX_ATTEMPTS = 10;
|
|
13
|
+
export declare const DEFAULT_REACTION_LEASE_MS = 30000;
|
|
14
|
+
export declare const DEFAULT_REACTION_INITIAL_BACKOFF_MS = 1000;
|
|
15
|
+
export declare const DEFAULT_REACTION_MAX_BACKOFF_MS: number;
|
|
16
|
+
export interface ReactionRetentionPolicy {
|
|
17
|
+
/** Keep completed rows for at least this duration (default 30 days). */
|
|
18
|
+
readonly completedRetentionMs: number;
|
|
19
|
+
/** Keep dead letters for at least this duration (default 90 days). */
|
|
20
|
+
readonly deadLetterRetentionMs: number;
|
|
21
|
+
/** Maximum terminal rows removed by one pass (default 1000). */
|
|
22
|
+
readonly batchSize: number;
|
|
23
|
+
}
|
|
24
|
+
export declare const DEFAULT_REACTION_RETENTION: ReactionRetentionPolicy;
|
|
25
|
+
export type ReactionTypeMap = Readonly<Record<string, DurableJsonValue>>;
|
|
26
|
+
export interface ReactionPlan {
|
|
27
|
+
/** Unique within the source client commit. */
|
|
28
|
+
readonly key: string;
|
|
29
|
+
readonly type: string;
|
|
30
|
+
readonly version: number;
|
|
31
|
+
readonly payload: DurableJsonValue;
|
|
32
|
+
readonly maxAttempts?: number;
|
|
33
|
+
}
|
|
34
|
+
export type PlannedReaction<Reactions extends ReactionTypeMap = ReactionTypeMap> = {
|
|
35
|
+
[Type in keyof Reactions & string]: {
|
|
36
|
+
/** Unique within the source client commit. */
|
|
37
|
+
readonly key: string;
|
|
38
|
+
readonly type: Type;
|
|
39
|
+
readonly version: number;
|
|
40
|
+
readonly payload: Reactions[Type];
|
|
41
|
+
readonly maxAttempts?: number;
|
|
42
|
+
};
|
|
43
|
+
}[keyof Reactions & string];
|
|
44
|
+
export interface ReactionPlannerInput {
|
|
45
|
+
readonly clientId: string;
|
|
46
|
+
readonly clientCommitId: string;
|
|
47
|
+
readonly actorId: string;
|
|
48
|
+
readonly partition: string;
|
|
49
|
+
readonly operations: readonly ValidateCommitOperation[];
|
|
50
|
+
/** Candidate-state reads from the still-open authoritative transaction. */
|
|
51
|
+
readonly read: CommitValidationReader;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A pure planner over an accepted candidate commit. It may read candidate
|
|
55
|
+
* state and return durable data. It must not execute user-visible side effects.
|
|
56
|
+
*/
|
|
57
|
+
export type ReactionPlanner<Reactions extends ReactionTypeMap = ReactionTypeMap> = (input: ReactionPlannerInput) => readonly PlannedReaction<Reactions>[] | Promise<readonly PlannedReaction<Reactions>[]>;
|
|
58
|
+
/** Erased planner shape stored on non-generic server configuration. */
|
|
59
|
+
export type AnyReactionPlanner = (input: ReactionPlannerInput) => readonly ReactionPlan[] | Promise<readonly ReactionPlan[]>;
|
|
60
|
+
export interface ReactionHandlerInput<Payload extends DurableJsonValue> {
|
|
61
|
+
readonly partition: string;
|
|
62
|
+
readonly idempotencyKey: string;
|
|
63
|
+
readonly type: string;
|
|
64
|
+
readonly version: number;
|
|
65
|
+
readonly payload: Payload;
|
|
66
|
+
readonly attempt: number;
|
|
67
|
+
readonly maxAttempts: number;
|
|
68
|
+
readonly sourceClientId: string;
|
|
69
|
+
readonly sourceClientCommitId: string;
|
|
70
|
+
readonly sourceCommitSeq: number;
|
|
71
|
+
/** Extend a long-running handler's lease; throws after ownership is lost. */
|
|
72
|
+
readonly extendLease: () => Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
export type ReactionHandler<Payload extends DurableJsonValue> = (input: ReactionHandlerInput<Payload>) => void | Promise<void>;
|
|
75
|
+
export type ReactionHandlers<Reactions extends ReactionTypeMap = ReactionTypeMap> = {
|
|
76
|
+
readonly [Type in keyof Reactions & string]: ReactionHandler<Reactions[Type]>;
|
|
77
|
+
};
|
|
78
|
+
/** Stable handler idempotency key for one planned item in a client commit. */
|
|
79
|
+
export declare function reactionIdempotencyKey(partition: string, clientId: string, clientCommitId: string, plannerKey: string): string;
|
|
80
|
+
export interface PreparedReaction {
|
|
81
|
+
readonly idempotencyKey: string;
|
|
82
|
+
readonly type: string;
|
|
83
|
+
readonly version: number;
|
|
84
|
+
readonly payload: DurableJsonValue;
|
|
85
|
+
readonly maxAttempts: number;
|
|
86
|
+
}
|
|
87
|
+
/** Internal push seam, exported for focused planner tests and custom hosts. */
|
|
88
|
+
export declare function prepareReactions(planner: AnyReactionPlanner, input: ReactionPlannerInput): Promise<PreparedReaction[]>;
|
|
89
|
+
declare class ReactionDeliveryError extends Error {
|
|
90
|
+
readonly code: string;
|
|
91
|
+
readonly details?: {
|
|
92
|
+
readonly [key: string]: DurableJsonValue;
|
|
93
|
+
};
|
|
94
|
+
constructor(name: string, code: string, details?: {
|
|
95
|
+
readonly [key: string]: DurableJsonValue;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** A handler failure that should be retried until its attempt limit. */
|
|
99
|
+
export declare class RetryableReactionError extends ReactionDeliveryError {
|
|
100
|
+
constructor(code: string, details?: {
|
|
101
|
+
readonly [key: string]: DurableJsonValue;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/** A handler failure that should be dead-lettered immediately. */
|
|
105
|
+
export declare class PermanentReactionError extends ReactionDeliveryError {
|
|
106
|
+
constructor(code: string, details?: {
|
|
107
|
+
readonly [key: string]: DurableJsonValue;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
export interface ReactionRunnerOptions<Reactions extends ReactionTypeMap = ReactionTypeMap> {
|
|
111
|
+
readonly storage: ServerStorage;
|
|
112
|
+
readonly partition: string;
|
|
113
|
+
readonly workerId: string;
|
|
114
|
+
readonly handlers: ReactionHandlers<Reactions>;
|
|
115
|
+
readonly events?: SyncularServerEvents;
|
|
116
|
+
readonly clock?: () => number;
|
|
117
|
+
readonly leaseDurationMs?: number;
|
|
118
|
+
readonly batchSize?: number;
|
|
119
|
+
readonly initialBackoffMs?: number;
|
|
120
|
+
readonly maxBackoffMs?: number;
|
|
121
|
+
}
|
|
122
|
+
export interface ReactionRunResult {
|
|
123
|
+
readonly claimed: number;
|
|
124
|
+
readonly completed: number;
|
|
125
|
+
readonly retried: number;
|
|
126
|
+
readonly deadLettered: number;
|
|
127
|
+
/** Lease ownership changed before this worker could persist its outcome. */
|
|
128
|
+
readonly lostLeases: number;
|
|
129
|
+
}
|
|
130
|
+
export interface PruneReactionsOptions {
|
|
131
|
+
readonly storage: ServerStorage;
|
|
132
|
+
readonly partition: string;
|
|
133
|
+
readonly nowMs: number;
|
|
134
|
+
readonly retention?: Partial<ReactionRetentionPolicy>;
|
|
135
|
+
readonly events?: SyncularServerEvents;
|
|
136
|
+
}
|
|
137
|
+
export interface ReactionPruneResult {
|
|
138
|
+
readonly completedBeforeMs: number;
|
|
139
|
+
readonly deadLetterBeforeMs: number;
|
|
140
|
+
readonly removedCompleted: number;
|
|
141
|
+
readonly removedDeadLetter: number;
|
|
142
|
+
/** True when the bounded pass filled its batch and another pass may help. */
|
|
143
|
+
readonly mayHaveMore: boolean;
|
|
144
|
+
}
|
|
145
|
+
/** Host-driven worker. Call `runOnce` from the host scheduler or queue wake. */
|
|
146
|
+
export declare class ReactionRunner<Reactions extends ReactionTypeMap = ReactionTypeMap> {
|
|
147
|
+
#private;
|
|
148
|
+
constructor(options: ReactionRunnerOptions<Reactions>);
|
|
149
|
+
runOnce(): Promise<ReactionRunResult>;
|
|
150
|
+
}
|
|
151
|
+
/** Explicit operator action for a dead-lettered reaction. */
|
|
152
|
+
export declare function retryDeadLetterReaction(options: {
|
|
153
|
+
readonly storage: ServerStorage;
|
|
154
|
+
readonly partition: string;
|
|
155
|
+
readonly idempotencyKey: string;
|
|
156
|
+
readonly nowMs?: number;
|
|
157
|
+
}): Promise<boolean>;
|
|
158
|
+
/** Delete one bounded batch of aged completed and dead-lettered rows. */
|
|
159
|
+
export declare function pruneReactions(options: PruneReactionsOptions): Promise<ReactionPruneResult>;
|
|
160
|
+
/** Helper used by the push path after commit sequence allocation. */
|
|
161
|
+
export declare function toNewReactions(prepared: readonly PreparedReaction[], source: {
|
|
162
|
+
readonly clientId: string;
|
|
163
|
+
readonly clientCommitId: string;
|
|
164
|
+
readonly commitSeq: number;
|
|
165
|
+
readonly createdAtMs: number;
|
|
166
|
+
}): NewReaction[];
|
|
167
|
+
export {};
|