@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/src/sqlite-storage.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
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 {
|
|
11
|
+
bindAuthoritativePartition,
|
|
12
|
+
prepareAuthoritativeQuery,
|
|
13
|
+
} from './authoritative-query';
|
|
10
14
|
import { syncError } from './errors';
|
|
11
15
|
import {
|
|
12
16
|
commitWindowPageSql,
|
|
@@ -42,14 +46,25 @@ import {
|
|
|
42
46
|
toStoredRow,
|
|
43
47
|
} from './sqlite-dialect';
|
|
44
48
|
import type {
|
|
49
|
+
AuthoritativeQueryRequest,
|
|
50
|
+
AuthoritativeQueryResult,
|
|
51
|
+
AuthoritativeQueryValue,
|
|
45
52
|
ClientCursorInfo,
|
|
46
53
|
ClientRecord,
|
|
47
54
|
ClientSubscription,
|
|
48
55
|
CommitMetadata,
|
|
49
56
|
CommitMetadataQuery,
|
|
50
57
|
CommitWindowQuery,
|
|
58
|
+
DurableJsonValue,
|
|
51
59
|
IndexRowScanQuery,
|
|
52
60
|
NewCommit,
|
|
61
|
+
NewReaction,
|
|
62
|
+
PrunedReactionCounts,
|
|
63
|
+
ReactionClaimQuery,
|
|
64
|
+
ReactionFailure,
|
|
65
|
+
ReactionFailureUpdate,
|
|
66
|
+
ReactionListQuery,
|
|
67
|
+
ReactionPruneQuery,
|
|
53
68
|
RowScanQuery,
|
|
54
69
|
ScopeActivityQuery,
|
|
55
70
|
ScopeCommitActivity,
|
|
@@ -57,6 +72,7 @@ import type {
|
|
|
57
72
|
StorageTransaction,
|
|
58
73
|
StoredCommit,
|
|
59
74
|
StoredPushResult,
|
|
75
|
+
StoredReaction,
|
|
60
76
|
StoredRow,
|
|
61
77
|
} from './storage';
|
|
62
78
|
import {
|
|
@@ -65,6 +81,53 @@ import {
|
|
|
65
81
|
} from './storage-errors';
|
|
66
82
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query';
|
|
67
83
|
|
|
84
|
+
interface SqliteReactionRecord {
|
|
85
|
+
partition: string;
|
|
86
|
+
idempotency_key: string;
|
|
87
|
+
type: string;
|
|
88
|
+
version: number;
|
|
89
|
+
payload: string;
|
|
90
|
+
source_client_id: string;
|
|
91
|
+
source_client_commit_id: string;
|
|
92
|
+
source_commit_seq: number;
|
|
93
|
+
created_at_ms: number;
|
|
94
|
+
available_at_ms: number;
|
|
95
|
+
status: StoredReaction['status'];
|
|
96
|
+
attempts: number;
|
|
97
|
+
max_attempts: number;
|
|
98
|
+
lease_owner: string | null;
|
|
99
|
+
lease_expires_at_ms: number | null;
|
|
100
|
+
completed_at_ms: number | null;
|
|
101
|
+
last_failure: string | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toStoredReaction(record: SqliteReactionRecord): StoredReaction {
|
|
105
|
+
return {
|
|
106
|
+
idempotencyKey: record.idempotency_key,
|
|
107
|
+
type: record.type,
|
|
108
|
+
version: record.version,
|
|
109
|
+
payload: JSON.parse(record.payload) as DurableJsonValue,
|
|
110
|
+
sourceClientId: record.source_client_id,
|
|
111
|
+
sourceClientCommitId: record.source_client_commit_id,
|
|
112
|
+
sourceCommitSeq: record.source_commit_seq,
|
|
113
|
+
createdAtMs: record.created_at_ms,
|
|
114
|
+
maxAttempts: record.max_attempts,
|
|
115
|
+
status: record.status,
|
|
116
|
+
attempts: record.attempts,
|
|
117
|
+
availableAtMs: record.available_at_ms,
|
|
118
|
+
...(record.lease_owner !== null ? { leaseOwner: record.lease_owner } : {}),
|
|
119
|
+
...(record.lease_expires_at_ms !== null
|
|
120
|
+
? { leaseExpiresAtMs: record.lease_expires_at_ms }
|
|
121
|
+
: {}),
|
|
122
|
+
...(record.completed_at_ms !== null
|
|
123
|
+
? { completedAtMs: record.completed_at_ms }
|
|
124
|
+
: {}),
|
|
125
|
+
...(record.last_failure !== null
|
|
126
|
+
? { lastFailure: JSON.parse(record.last_failure) as ReactionFailure }
|
|
127
|
+
: {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
68
131
|
class SqliteTransaction implements StorageTransaction {
|
|
69
132
|
#storage: SqliteServerStorage;
|
|
70
133
|
#partition: string;
|
|
@@ -256,6 +319,32 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
256
319
|
);
|
|
257
320
|
}
|
|
258
321
|
|
|
322
|
+
async enqueueReactions(reactions: readonly NewReaction[]): Promise<void> {
|
|
323
|
+
this.#assertOpen();
|
|
324
|
+
const statement = this.#storage.db.query(
|
|
325
|
+
`INSERT INTO sync_reactions(
|
|
326
|
+
partition, idempotency_key, type, version, payload,
|
|
327
|
+
source_client_id, source_client_commit_id, source_commit_seq,
|
|
328
|
+
created_at_ms, available_at_ms, status, attempts, max_attempts
|
|
329
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,'pending',0,?)`,
|
|
330
|
+
);
|
|
331
|
+
for (const reaction of reactions) {
|
|
332
|
+
statement.run(
|
|
333
|
+
this.#partition,
|
|
334
|
+
reaction.idempotencyKey,
|
|
335
|
+
reaction.type,
|
|
336
|
+
reaction.version,
|
|
337
|
+
JSON.stringify(reaction.payload),
|
|
338
|
+
reaction.sourceClientId,
|
|
339
|
+
reaction.sourceClientCommitId,
|
|
340
|
+
reaction.sourceCommitSeq,
|
|
341
|
+
reaction.createdAtMs,
|
|
342
|
+
reaction.createdAtMs,
|
|
343
|
+
reaction.maxAttempts,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
259
348
|
async commit(): Promise<void> {
|
|
260
349
|
this.#assertOpen();
|
|
261
350
|
try {
|
|
@@ -297,6 +386,20 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
297
386
|
#tables: ReadonlyMap<string, CompiledTable> | undefined;
|
|
298
387
|
#schemaVersion: number | undefined;
|
|
299
388
|
|
|
389
|
+
async #serializeReactionWrite<T>(operation: () => T): Promise<T> {
|
|
390
|
+
const previous = this.#transactionTail;
|
|
391
|
+
let release!: () => void;
|
|
392
|
+
this.#transactionTail = new Promise<void>((resolve) => {
|
|
393
|
+
release = resolve;
|
|
394
|
+
});
|
|
395
|
+
await previous;
|
|
396
|
+
try {
|
|
397
|
+
return operation();
|
|
398
|
+
} finally {
|
|
399
|
+
release();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
300
403
|
constructor(db: Database | string = ':memory:') {
|
|
301
404
|
this.db = typeof db === 'string' ? new Database(db) : db;
|
|
302
405
|
this.db.exec(SQLITE_DDL);
|
|
@@ -396,8 +499,8 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
396
499
|
}
|
|
397
500
|
|
|
398
501
|
/**
|
|
399
|
-
* Migration rewrite
|
|
400
|
-
*
|
|
502
|
+
* Migration rewrite: keyset-paged walk of a row table. When `oldLayout` is
|
|
503
|
+
* given every payload re-encodes under
|
|
401
504
|
* the current columns, and the projection (when materialized) refreshes
|
|
402
505
|
* from the payload either way.
|
|
403
506
|
*/
|
|
@@ -496,6 +599,55 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
496
599
|
return row?.max_commit_seq ?? 0;
|
|
497
600
|
}
|
|
498
601
|
|
|
602
|
+
async queryAuthoritative(
|
|
603
|
+
partition: string,
|
|
604
|
+
query: AuthoritativeQueryRequest,
|
|
605
|
+
): Promise<AuthoritativeQueryResult> {
|
|
606
|
+
if (this.#tables === undefined) {
|
|
607
|
+
throw new Error(
|
|
608
|
+
'ensureSchema(schema) must run before registered queries',
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
const prepared = bindAuthoritativePartition(
|
|
612
|
+
prepareAuthoritativeQuery(
|
|
613
|
+
query.sql,
|
|
614
|
+
query.params,
|
|
615
|
+
query.tables,
|
|
616
|
+
this.#tables,
|
|
617
|
+
),
|
|
618
|
+
partition,
|
|
619
|
+
);
|
|
620
|
+
const previous = this.#transactionTail;
|
|
621
|
+
let release!: () => void;
|
|
622
|
+
this.#transactionTail = new Promise<void>((resolve) => {
|
|
623
|
+
release = resolve;
|
|
624
|
+
});
|
|
625
|
+
await previous;
|
|
626
|
+
let open = false;
|
|
627
|
+
try {
|
|
628
|
+
this.db.exec('BEGIN');
|
|
629
|
+
open = true;
|
|
630
|
+
const rows = this.db
|
|
631
|
+
.query<Readonly<Record<string, unknown>>, AuthoritativeQueryValue[]>(
|
|
632
|
+
prepared.sql,
|
|
633
|
+
)
|
|
634
|
+
.all(...prepared.params);
|
|
635
|
+
const cursor = this.db
|
|
636
|
+
.query<{ max_commit_seq: number }, [string]>(
|
|
637
|
+
'SELECT max_commit_seq FROM sync_partitions WHERE partition=?',
|
|
638
|
+
)
|
|
639
|
+
.get(partition);
|
|
640
|
+
this.db.exec('COMMIT');
|
|
641
|
+
open = false;
|
|
642
|
+
return { rows, maxCommitSeq: cursor?.max_commit_seq ?? 0 };
|
|
643
|
+
} catch (error) {
|
|
644
|
+
if (open) this.db.exec('ROLLBACK');
|
|
645
|
+
throw error;
|
|
646
|
+
} finally {
|
|
647
|
+
release();
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
499
651
|
async getHorizonSeq(partition: string): Promise<number> {
|
|
500
652
|
const row = this.db
|
|
501
653
|
.query<{ horizon_seq: number }, [string]>(
|
|
@@ -575,6 +727,219 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
575
727
|
}
|
|
576
728
|
}
|
|
577
729
|
|
|
730
|
+
async claimReactions(
|
|
731
|
+
partition: string,
|
|
732
|
+
query: ReactionClaimQuery,
|
|
733
|
+
): Promise<StoredReaction[]> {
|
|
734
|
+
if (query.types.length === 0 || query.limit <= 0) return [];
|
|
735
|
+
return this.#serializeReactionWrite(() => {
|
|
736
|
+
const typeParams = query.types.map(() => '?').join(',');
|
|
737
|
+
const records = this.db
|
|
738
|
+
.query<SqliteReactionRecord, (string | number)[]>(
|
|
739
|
+
`UPDATE sync_reactions
|
|
740
|
+
SET status='leased', attempts=attempts+1,
|
|
741
|
+
lease_owner=?, lease_expires_at_ms=?, completed_at_ms=NULL
|
|
742
|
+
WHERE (partition, idempotency_key) IN (
|
|
743
|
+
SELECT partition, idempotency_key
|
|
744
|
+
FROM sync_reactions
|
|
745
|
+
WHERE partition=? AND type IN (${typeParams})
|
|
746
|
+
AND ((status='pending' AND available_at_ms<=?)
|
|
747
|
+
OR (status='leased' AND lease_expires_at_ms<=?))
|
|
748
|
+
ORDER BY CASE WHEN status='leased' THEN lease_expires_at_ms
|
|
749
|
+
ELSE available_at_ms END,
|
|
750
|
+
created_at_ms, idempotency_key
|
|
751
|
+
LIMIT ?
|
|
752
|
+
)
|
|
753
|
+
RETURNING *`,
|
|
754
|
+
)
|
|
755
|
+
.all(
|
|
756
|
+
query.leaseOwner,
|
|
757
|
+
Math.min(
|
|
758
|
+
Number.MAX_SAFE_INTEGER,
|
|
759
|
+
query.nowMs + query.leaseDurationMs,
|
|
760
|
+
),
|
|
761
|
+
partition,
|
|
762
|
+
...query.types,
|
|
763
|
+
query.nowMs,
|
|
764
|
+
query.nowMs,
|
|
765
|
+
query.limit,
|
|
766
|
+
);
|
|
767
|
+
return records
|
|
768
|
+
.map(toStoredReaction)
|
|
769
|
+
.sort(
|
|
770
|
+
(a, b) =>
|
|
771
|
+
a.createdAtMs - b.createdAtMs ||
|
|
772
|
+
a.idempotencyKey.localeCompare(b.idempotencyKey),
|
|
773
|
+
);
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async completeReaction(
|
|
778
|
+
partition: string,
|
|
779
|
+
idempotencyKey: string,
|
|
780
|
+
leaseOwner: string,
|
|
781
|
+
completedAtMs: number,
|
|
782
|
+
): Promise<boolean> {
|
|
783
|
+
return this.#serializeReactionWrite(() => {
|
|
784
|
+
const result = this.db
|
|
785
|
+
.query(
|
|
786
|
+
`UPDATE sync_reactions
|
|
787
|
+
SET status='completed', completed_at_ms=?,
|
|
788
|
+
lease_owner=NULL, lease_expires_at_ms=NULL
|
|
789
|
+
WHERE partition=? AND idempotency_key=?
|
|
790
|
+
AND status='leased' AND lease_owner=?`,
|
|
791
|
+
)
|
|
792
|
+
.run(completedAtMs, partition, idempotencyKey, leaseOwner);
|
|
793
|
+
return Number(result.changes) === 1;
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async extendReactionLease(
|
|
798
|
+
partition: string,
|
|
799
|
+
idempotencyKey: string,
|
|
800
|
+
leaseOwner: string,
|
|
801
|
+
leaseExpiresAtMs: number,
|
|
802
|
+
): Promise<boolean> {
|
|
803
|
+
return this.#serializeReactionWrite(() => {
|
|
804
|
+
const result = this.db
|
|
805
|
+
.query(
|
|
806
|
+
`UPDATE sync_reactions SET lease_expires_at_ms=?
|
|
807
|
+
WHERE partition=? AND idempotency_key=?
|
|
808
|
+
AND status='leased' AND lease_owner=?`,
|
|
809
|
+
)
|
|
810
|
+
.run(leaseExpiresAtMs, partition, idempotencyKey, leaseOwner);
|
|
811
|
+
return Number(result.changes) === 1;
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async failReaction(
|
|
816
|
+
partition: string,
|
|
817
|
+
idempotencyKey: string,
|
|
818
|
+
update: ReactionFailureUpdate,
|
|
819
|
+
): Promise<boolean> {
|
|
820
|
+
const retry = update.retryAtMs !== undefined;
|
|
821
|
+
return this.#serializeReactionWrite(() => {
|
|
822
|
+
const result = this.db
|
|
823
|
+
.query(
|
|
824
|
+
`UPDATE sync_reactions
|
|
825
|
+
SET status=?, available_at_ms=?, last_failure=?,
|
|
826
|
+
lease_owner=NULL, lease_expires_at_ms=NULL
|
|
827
|
+
WHERE partition=? AND idempotency_key=?
|
|
828
|
+
AND status='leased' AND lease_owner=?`,
|
|
829
|
+
)
|
|
830
|
+
.run(
|
|
831
|
+
retry ? 'pending' : 'dead-letter',
|
|
832
|
+
update.retryAtMs ?? update.failure.atMs,
|
|
833
|
+
JSON.stringify(update.failure),
|
|
834
|
+
partition,
|
|
835
|
+
idempotencyKey,
|
|
836
|
+
update.leaseOwner,
|
|
837
|
+
);
|
|
838
|
+
return Number(result.changes) === 1;
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async retryReaction(
|
|
843
|
+
partition: string,
|
|
844
|
+
idempotencyKey: string,
|
|
845
|
+
nowMs: number,
|
|
846
|
+
): Promise<boolean> {
|
|
847
|
+
return this.#serializeReactionWrite(() => {
|
|
848
|
+
const result = this.db
|
|
849
|
+
.query(
|
|
850
|
+
`UPDATE sync_reactions
|
|
851
|
+
SET status='pending', attempts=0, available_at_ms=?,
|
|
852
|
+
last_failure=NULL, lease_owner=NULL, lease_expires_at_ms=NULL,
|
|
853
|
+
completed_at_ms=NULL
|
|
854
|
+
WHERE partition=? AND idempotency_key=? AND status='dead-letter'`,
|
|
855
|
+
)
|
|
856
|
+
.run(nowMs, partition, idempotencyKey);
|
|
857
|
+
return Number(result.changes) === 1;
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async getReaction(
|
|
862
|
+
partition: string,
|
|
863
|
+
idempotencyKey: string,
|
|
864
|
+
): Promise<StoredReaction | undefined> {
|
|
865
|
+
const record = this.db
|
|
866
|
+
.query<SqliteReactionRecord, [string, string]>(
|
|
867
|
+
'SELECT * FROM sync_reactions WHERE partition=? AND idempotency_key=?',
|
|
868
|
+
)
|
|
869
|
+
.get(partition, idempotencyKey);
|
|
870
|
+
return record === null ? undefined : toStoredReaction(record);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async listReactions(
|
|
874
|
+
partition: string,
|
|
875
|
+
query: ReactionListQuery,
|
|
876
|
+
): Promise<StoredReaction[]> {
|
|
877
|
+
const where = ['partition=?'];
|
|
878
|
+
const params: (string | number)[] = [partition];
|
|
879
|
+
if (query.statuses !== undefined && query.statuses.length > 0) {
|
|
880
|
+
where.push(`status IN (${query.statuses.map(() => '?').join(',')})`);
|
|
881
|
+
params.push(...query.statuses);
|
|
882
|
+
}
|
|
883
|
+
if (query.types !== undefined && query.types.length > 0) {
|
|
884
|
+
where.push(`type IN (${query.types.map(() => '?').join(',')})`);
|
|
885
|
+
params.push(...query.types);
|
|
886
|
+
}
|
|
887
|
+
params.push(query.limit);
|
|
888
|
+
const records = this.db
|
|
889
|
+
.query<SqliteReactionRecord, (string | number)[]>(
|
|
890
|
+
`SELECT * FROM sync_reactions WHERE ${where.join(' AND ')}
|
|
891
|
+
ORDER BY created_at_ms DESC, idempotency_key DESC LIMIT ?`,
|
|
892
|
+
)
|
|
893
|
+
.all(...params);
|
|
894
|
+
return records.map(toStoredReaction);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async pruneReactions(
|
|
898
|
+
partition: string,
|
|
899
|
+
query: ReactionPruneQuery,
|
|
900
|
+
): Promise<PrunedReactionCounts> {
|
|
901
|
+
if (query.limit <= 0) return { completed: 0, deadLetter: 0 };
|
|
902
|
+
return this.#serializeReactionWrite(() => {
|
|
903
|
+
const records = this.db
|
|
904
|
+
.query<
|
|
905
|
+
{ status: 'completed' | 'dead-letter' },
|
|
906
|
+
[string, string, number, number, number, number, number]
|
|
907
|
+
>(
|
|
908
|
+
`DELETE FROM sync_reactions
|
|
909
|
+
WHERE partition=? AND idempotency_key IN (
|
|
910
|
+
SELECT idempotency_key FROM sync_reactions
|
|
911
|
+
WHERE partition=?
|
|
912
|
+
AND ((status='completed' AND completed_at_ms IS NOT NULL
|
|
913
|
+
AND completed_at_ms<?)
|
|
914
|
+
OR (status='dead-letter' AND available_at_ms<?))
|
|
915
|
+
ORDER BY CASE WHEN status='completed' THEN completed_at_ms
|
|
916
|
+
ELSE available_at_ms END,
|
|
917
|
+
idempotency_key
|
|
918
|
+
LIMIT ?
|
|
919
|
+
)
|
|
920
|
+
AND ((status='completed' AND completed_at_ms IS NOT NULL
|
|
921
|
+
AND completed_at_ms<?)
|
|
922
|
+
OR (status='dead-letter' AND available_at_ms<?))
|
|
923
|
+
RETURNING status`,
|
|
924
|
+
)
|
|
925
|
+
.all(
|
|
926
|
+
partition,
|
|
927
|
+
partition,
|
|
928
|
+
query.completedBeforeMs,
|
|
929
|
+
query.deadLetterBeforeMs,
|
|
930
|
+
query.limit,
|
|
931
|
+
query.completedBeforeMs,
|
|
932
|
+
query.deadLetterBeforeMs,
|
|
933
|
+
);
|
|
934
|
+
return {
|
|
935
|
+
completed: records.filter((record) => record.status === 'completed')
|
|
936
|
+
.length,
|
|
937
|
+
deadLetter: records.filter((record) => record.status === 'dead-letter')
|
|
938
|
+
.length,
|
|
939
|
+
};
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
|
|
578
943
|
async readCommitWindow(
|
|
579
944
|
partition: string,
|
|
580
945
|
query: CommitWindowQuery,
|
|
@@ -808,7 +1173,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
808
1173
|
return rows.map((r) => r.blob_id);
|
|
809
1174
|
}
|
|
810
1175
|
|
|
811
|
-
// -- admin/console read surface
|
|
1176
|
+
// -- admin/console read surface --------------------------------------------
|
|
812
1177
|
|
|
813
1178
|
async listClientRecords(partition: string): Promise<ClientRecord[]> {
|
|
814
1179
|
const records = this.db
|
package/src/storage.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);
|
|
@@ -65,6 +65,83 @@ export interface StoredCommit {
|
|
|
65
65
|
readonly changes: readonly StoredChange[];
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** JSON values accepted by the durable reaction payload/failure store. */
|
|
69
|
+
export type DurableJsonValue =
|
|
70
|
+
| null
|
|
71
|
+
| boolean
|
|
72
|
+
| number
|
|
73
|
+
| string
|
|
74
|
+
| readonly DurableJsonValue[]
|
|
75
|
+
| { readonly [key: string]: DurableJsonValue };
|
|
76
|
+
|
|
77
|
+
export type ReactionStatus = 'pending' | 'leased' | 'completed' | 'dead-letter';
|
|
78
|
+
|
|
79
|
+
export interface ReactionFailure {
|
|
80
|
+
/** Stable application or Syncular error identity. */
|
|
81
|
+
readonly code: string;
|
|
82
|
+
readonly atMs: number;
|
|
83
|
+
/** Bounded JSON metadata. Diagnostic exception text is never persisted. */
|
|
84
|
+
readonly details?: { readonly [key: string]: DurableJsonValue };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A validated reaction row written with its source commit. */
|
|
88
|
+
export interface NewReaction {
|
|
89
|
+
readonly idempotencyKey: string;
|
|
90
|
+
readonly type: string;
|
|
91
|
+
readonly version: number;
|
|
92
|
+
readonly payload: DurableJsonValue;
|
|
93
|
+
readonly sourceClientId: string;
|
|
94
|
+
readonly sourceClientCommitId: string;
|
|
95
|
+
readonly sourceCommitSeq: number;
|
|
96
|
+
readonly createdAtMs: number;
|
|
97
|
+
readonly maxAttempts: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Durable reaction state returned to workers and administrative readers. */
|
|
101
|
+
export interface StoredReaction extends NewReaction {
|
|
102
|
+
readonly status: ReactionStatus;
|
|
103
|
+
/** Incremented atomically when a worker claims the reaction. */
|
|
104
|
+
readonly attempts: number;
|
|
105
|
+
readonly availableAtMs: number;
|
|
106
|
+
readonly leaseOwner?: string;
|
|
107
|
+
readonly leaseExpiresAtMs?: number;
|
|
108
|
+
readonly completedAtMs?: number;
|
|
109
|
+
readonly lastFailure?: ReactionFailure;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ReactionClaimQuery {
|
|
113
|
+
/** Opaque token unique to this claim operation. */
|
|
114
|
+
readonly leaseOwner: string;
|
|
115
|
+
readonly types: readonly string[];
|
|
116
|
+
readonly nowMs: number;
|
|
117
|
+
readonly leaseDurationMs: number;
|
|
118
|
+
readonly limit: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface ReactionListQuery {
|
|
122
|
+
readonly statuses?: readonly ReactionStatus[];
|
|
123
|
+
readonly types?: readonly string[];
|
|
124
|
+
readonly limit: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ReactionFailureUpdate {
|
|
128
|
+
readonly leaseOwner: string;
|
|
129
|
+
readonly failure: ReactionFailure;
|
|
130
|
+
/** Present for retry; absent moves the row to the dead-letter state. */
|
|
131
|
+
readonly retryAtMs?: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ReactionPruneQuery {
|
|
135
|
+
readonly completedBeforeMs: number;
|
|
136
|
+
readonly deadLetterBeforeMs: number;
|
|
137
|
+
readonly limit: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface PrunedReactionCounts {
|
|
141
|
+
readonly completed: number;
|
|
142
|
+
readonly deadLetter: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
68
145
|
/** Persisted push outcome for idempotent replay (§2.3, §6.3). */
|
|
69
146
|
export interface StoredPushResult {
|
|
70
147
|
readonly status: 'applied' | 'rejected';
|
|
@@ -183,6 +260,29 @@ export interface ScopeActivityQuery {
|
|
|
183
260
|
readonly limit: number;
|
|
184
261
|
}
|
|
185
262
|
|
|
263
|
+
/** A registered SELECT executed against the authoritative row projection. */
|
|
264
|
+
export type AuthoritativeQueryValue =
|
|
265
|
+
| string
|
|
266
|
+
| number
|
|
267
|
+
| bigint
|
|
268
|
+
| boolean
|
|
269
|
+
| Uint8Array
|
|
270
|
+
| null;
|
|
271
|
+
|
|
272
|
+
export interface AuthoritativeQueryRequest {
|
|
273
|
+
/** Generated, positional SQLite-family SQL. It never comes from the request. */
|
|
274
|
+
readonly sql: string;
|
|
275
|
+
readonly params: readonly AuthoritativeQueryValue[];
|
|
276
|
+
/** Generated dependency set, used to validate and partition every relation. */
|
|
277
|
+
readonly tables: readonly string[];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** One transactionally consistent authoritative query snapshot. */
|
|
281
|
+
export interface AuthoritativeQueryResult {
|
|
282
|
+
readonly rows: readonly Readonly<Record<string, unknown>>[];
|
|
283
|
+
readonly maxCommitSeq: number;
|
|
284
|
+
}
|
|
285
|
+
|
|
186
286
|
/**
|
|
187
287
|
* One transaction per push commit (§6.4): all row writes, the appended
|
|
188
288
|
* commit (with its scope-index entries), and the idempotency record either
|
|
@@ -248,6 +348,12 @@ export interface StorageTransaction {
|
|
|
248
348
|
deleteRow(table: string, rowId: string): Promise<void>;
|
|
249
349
|
/** Allocates the next per-partition commitSeq and appends the commit. */
|
|
250
350
|
appendCommit(commit: NewCommit): Promise<number>;
|
|
351
|
+
/**
|
|
352
|
+
* Persist reaction rows in this authoritative transaction. Required when
|
|
353
|
+
* the host configures a reaction planner. Reactions survive commit-log
|
|
354
|
+
* pruning because they live outside the commit/change tables.
|
|
355
|
+
*/
|
|
356
|
+
enqueueReactions?(reactions: readonly NewReaction[]): Promise<void>;
|
|
251
357
|
/**
|
|
252
358
|
* Persist an idempotency outcome only when the key is still absent. The
|
|
253
359
|
* first writer wins; callers read the canonical value after commit.
|
|
@@ -276,7 +382,7 @@ export interface StorageTransaction {
|
|
|
276
382
|
export interface ServerStorage {
|
|
277
383
|
/**
|
|
278
384
|
* Create/migrate the relational per-app row tables for `schema`
|
|
279
|
-
*
|
|
385
|
+
* Idempotent and cheap when the
|
|
280
386
|
* stored schema version already matches (one marker read, memoized per
|
|
281
387
|
* instance); on first use it creates the tables, on a version bump it
|
|
282
388
|
* applies the migration subset (CREATE TABLE / ADD COLUMN / CREATE
|
|
@@ -323,6 +429,55 @@ export interface ServerStorage {
|
|
|
323
429
|
clientCommitId: string,
|
|
324
430
|
): Promise<StoredPushResult | undefined>;
|
|
325
431
|
|
|
432
|
+
/**
|
|
433
|
+
* Atomically lease due or expired-lease reactions for one worker. Optional
|
|
434
|
+
* for compatibility with custom storages; reaction runners fail closed when
|
|
435
|
+
* any lifecycle method is absent.
|
|
436
|
+
*/
|
|
437
|
+
claimReactions?(
|
|
438
|
+
partition: string,
|
|
439
|
+
query: ReactionClaimQuery,
|
|
440
|
+
): Promise<StoredReaction[]>;
|
|
441
|
+
/** Acknowledge only while `leaseOwner` still owns the lease. */
|
|
442
|
+
completeReaction?(
|
|
443
|
+
partition: string,
|
|
444
|
+
idempotencyKey: string,
|
|
445
|
+
leaseOwner: string,
|
|
446
|
+
completedAtMs: number,
|
|
447
|
+
): Promise<boolean>;
|
|
448
|
+
/** Extend only while `leaseOwner` still owns the active lease. */
|
|
449
|
+
extendReactionLease?(
|
|
450
|
+
partition: string,
|
|
451
|
+
idempotencyKey: string,
|
|
452
|
+
leaseOwner: string,
|
|
453
|
+
leaseExpiresAtMs: number,
|
|
454
|
+
): Promise<boolean>;
|
|
455
|
+
/** Retry or dead-letter only while `leaseOwner` still owns the lease. */
|
|
456
|
+
failReaction?(
|
|
457
|
+
partition: string,
|
|
458
|
+
idempotencyKey: string,
|
|
459
|
+
update: ReactionFailureUpdate,
|
|
460
|
+
): Promise<boolean>;
|
|
461
|
+
/** Reset a dead-lettered row for an explicit operator retry. */
|
|
462
|
+
retryReaction?(
|
|
463
|
+
partition: string,
|
|
464
|
+
idempotencyKey: string,
|
|
465
|
+
nowMs: number,
|
|
466
|
+
): Promise<boolean>;
|
|
467
|
+
getReaction?(
|
|
468
|
+
partition: string,
|
|
469
|
+
idempotencyKey: string,
|
|
470
|
+
): Promise<StoredReaction | undefined>;
|
|
471
|
+
listReactions?(
|
|
472
|
+
partition: string,
|
|
473
|
+
query: ReactionListQuery,
|
|
474
|
+
): Promise<StoredReaction[]>;
|
|
475
|
+
/** Delete a bounded set of aged terminal rows. Never deletes active work. */
|
|
476
|
+
pruneReactions?(
|
|
477
|
+
partition: string,
|
|
478
|
+
query: ReactionPruneQuery,
|
|
479
|
+
): Promise<PrunedReactionCounts>;
|
|
480
|
+
|
|
326
481
|
/**
|
|
327
482
|
* Matching commits in the window, oldest first, each carrying only its
|
|
328
483
|
* matching changes for `table`. Stops once accumulated matching changes
|
|
@@ -336,6 +491,16 @@ export interface ServerStorage {
|
|
|
336
491
|
/** Scope-filtered snapshot scan, ordered by rowId (bootstrap paging). */
|
|
337
492
|
scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
|
|
338
493
|
|
|
494
|
+
/**
|
|
495
|
+
* Optional registered-query capability. The implementation MUST replace
|
|
496
|
+
* every generated app-table relation with a partition-filtered relation and
|
|
497
|
+
* return rows plus maxCommitSeq from one consistent database snapshot.
|
|
498
|
+
*/
|
|
499
|
+
queryAuthoritative?(
|
|
500
|
+
partition: string,
|
|
501
|
+
query: AuthoritativeQueryRequest,
|
|
502
|
+
): Promise<AuthoritativeQueryResult>;
|
|
503
|
+
|
|
339
504
|
/**
|
|
340
505
|
* Optional trusted-host exact lookup through a declared relational index.
|
|
341
506
|
* This capability is outside client scope/subscription authorization and
|
|
@@ -373,7 +538,7 @@ export interface ServerStorage {
|
|
|
373
538
|
listReferencedBlobIds?(partition: string): Promise<string[]>;
|
|
374
539
|
|
|
375
540
|
/**
|
|
376
|
-
* Admin/console read surface (`SyncularAdmin
|
|
541
|
+
* Admin/console read surface (`SyncularAdmin`): ADDITIVE,
|
|
377
542
|
* optional. All read-only, partition-scoped, and JSON-able. A backend
|
|
378
543
|
* that omits these simply cannot serve the console; the sync path never
|
|
379
544
|
* calls them.
|