@syncular/server 0.15.48 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -9
- package/dist/admin.js +1 -5
- package/dist/authoritative-query.d.ts +14 -6
- package/dist/authoritative-query.js +60 -82
- package/dist/d1-storage.d.ts +13 -1
- package/dist/d1-storage.js +468 -121
- package/dist/operations.d.ts +2 -0
- package/dist/operations.js +23 -4
- package/dist/postgres-storage.d.ts +6 -1
- package/dist/postgres-storage.js +80 -24
- package/dist/prune.d.ts +3 -1
- package/dist/prune.js +18 -14
- package/dist/pull.js +79 -39
- package/dist/push.js +5 -5
- package/dist/realtime.d.ts +1 -1
- package/dist/realtime.js +16 -10
- package/dist/relational-rows.d.ts +7 -1
- package/dist/relational-rows.js +17 -2
- package/dist/sqlite-bun.js +2 -2
- package/dist/sqlite-image.d.ts +3 -3
- package/dist/sqlite-image.js +10 -6
- package/dist/sqlite-node.js +2 -2
- package/dist/sqlite-storage.d.ts +6 -1
- package/dist/sqlite-storage.js +93 -32
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +7 -0
- package/dist/storage.d.ts +29 -5
- package/package.json +2 -2
- package/src/admin.ts +4 -6
- package/src/authoritative-query.ts +89 -94
- package/src/d1-storage.ts +641 -159
- package/src/operations.ts +31 -3
- package/src/postgres-storage.ts +146 -46
- package/src/prune.ts +29 -15
- package/src/pull.ts +102 -49
- package/src/push.ts +5 -5
- package/src/realtime.ts +20 -9
- package/src/relational-rows.ts +18 -2
- package/src/sqlite-bun.ts +2 -2
- package/src/sqlite-image.ts +26 -15
- package/src/sqlite-node.ts +2 -2
- package/src/sqlite-storage.ts +131 -37
- package/src/storage-errors.ts +22 -1
- package/src/storage.ts +50 -5
package/dist/operations.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AuthoritativeRelationPlan } from './authoritative-query.js';
|
|
1
2
|
import { type RemoteOperationResponse, type RowValue } from '@syncular/core';
|
|
2
3
|
import type { SyncRequestContext } from './context.js';
|
|
3
4
|
import type { AuthoritativeQueryValue } from './storage.js';
|
|
@@ -20,6 +21,7 @@ export interface AuthoritativeQueryDescriptor<Params = undefined> {
|
|
|
20
21
|
readonly hasParams: boolean;
|
|
21
22
|
readonly sql: string;
|
|
22
23
|
readonly tables: readonly string[];
|
|
24
|
+
readonly relationPlans: readonly AuthoritativeRelationPlan[];
|
|
23
25
|
readonly resultColumns: readonly {
|
|
24
26
|
readonly name: string;
|
|
25
27
|
readonly type: 'string' | 'integer' | 'float' | 'boolean' | 'json' | 'bytes' | 'blob_ref' | 'crdt';
|
package/dist/operations.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateAuthoritativeRelationPlan, } from './authoritative-query.js';
|
|
1
2
|
import { decodeRow, decodeRemoteOperationRequest, encodeRow, encodeRemoteOperationResponse, } from '@syncular/core';
|
|
2
3
|
import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
|
|
3
4
|
import { SyncError, syncError } from './errors.js';
|
|
@@ -97,13 +98,19 @@ function normalizeQueryRows(rows, columns) {
|
|
|
97
98
|
}
|
|
98
99
|
/** Register one generated named query as an authoritative remote operation. */
|
|
99
100
|
export function registerRemoteQuery(descriptor, options) {
|
|
100
|
-
if (descriptor.
|
|
101
|
+
if (!Array.isArray(descriptor.relationPlans) ||
|
|
102
|
+
!descriptor.relationPlans.some((plan) => plan.sql === descriptor.sql) ||
|
|
103
|
+
descriptor.relationPlans.some((plan) => !Array.isArray(plan.relations)) ||
|
|
104
|
+
descriptor.id.length === 0 ||
|
|
101
105
|
new Set(descriptor.tables).size !== descriptor.tables.length ||
|
|
102
106
|
!Array.isArray(descriptor.resultColumns) ||
|
|
103
107
|
descriptor.resultColumns.length === 0 ||
|
|
104
108
|
new Set(descriptor.resultColumns.map((column) => column.name)).size !==
|
|
105
109
|
descriptor.resultColumns.length) {
|
|
106
|
-
throw new Error('remote query requires a non-empty id and unique tables and result columns');
|
|
110
|
+
throw new Error('remote query requires generated relation plans, a non-empty id, and unique tables and result columns; regenerate queries');
|
|
111
|
+
}
|
|
112
|
+
for (const plan of descriptor.relationPlans) {
|
|
113
|
+
validateAuthoritativeRelationPlan(plan, descriptor.tables);
|
|
107
114
|
}
|
|
108
115
|
if (!Number.isSafeInteger(options.maxRows) ||
|
|
109
116
|
options.maxRows < 1 ||
|
|
@@ -173,12 +180,24 @@ export function registerRemoteQuery(descriptor, options) {
|
|
|
173
180
|
if (ctx.storage.queryAuthoritative === undefined) {
|
|
174
181
|
throw syncError('operation.storage_unsupported', 'configured storage does not implement authoritative queries');
|
|
175
182
|
}
|
|
176
|
-
await ctx.storage.ensureSchema(schema);
|
|
177
183
|
const selectedSql = descriptor.sqlFor?.(params) ?? descriptor.sql;
|
|
184
|
+
const plan = descriptor.relationPlans.find((candidate) => candidate.sql === selectedSql);
|
|
185
|
+
if (plan === undefined) {
|
|
186
|
+
throw syncError('operation.invalid_request', 'selected SQL has no generated relation plan; regenerate queries');
|
|
187
|
+
}
|
|
188
|
+
await ctx.storage.ensureSchema(schema);
|
|
189
|
+
const prefix = 'SELECT * FROM (';
|
|
178
190
|
let result;
|
|
179
191
|
try {
|
|
180
192
|
result = await ctx.storage.queryAuthoritative(ctx.partition, {
|
|
181
|
-
|
|
193
|
+
plan: {
|
|
194
|
+
sql: `${prefix}${selectedSql}) AS "_syncular_registered_query" LIMIT ?`,
|
|
195
|
+
relations: plan.relations.map((relation) => ({
|
|
196
|
+
...relation,
|
|
197
|
+
start: relation.start + prefix.length,
|
|
198
|
+
end: relation.end + prefix.length,
|
|
199
|
+
})),
|
|
200
|
+
},
|
|
182
201
|
params: [...descriptor.bind(params), options.maxRows + 1],
|
|
183
202
|
tables: descriptor.tables,
|
|
184
203
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
1
2
|
import { type PgExecutor } from './pg-executor.js';
|
|
2
3
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
3
4
|
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
@@ -46,9 +47,10 @@ export declare class PostgresServerStorage implements ServerStorage {
|
|
|
46
47
|
begin(partition: string): Promise<StorageTransaction>;
|
|
47
48
|
getMaxCommitSeq(partition: string): Promise<number>;
|
|
48
49
|
queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
|
|
50
|
+
getPartitionLogEpoch(partition: string): Promise<string | undefined>;
|
|
49
51
|
getHorizonSeq(partition: string): Promise<number>;
|
|
50
52
|
setHorizonSeq(partition: string, seq: number): Promise<void>;
|
|
51
|
-
pruneCommitsThrough(partition: string,
|
|
53
|
+
pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
|
|
52
54
|
getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
|
|
53
55
|
getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
|
|
54
56
|
getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
|
|
@@ -65,6 +67,9 @@ export declare class PostgresServerStorage implements ServerStorage {
|
|
|
65
67
|
scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
66
68
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
67
69
|
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
70
|
+
advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
|
|
71
|
+
updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
|
|
72
|
+
getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
|
|
68
73
|
listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
|
|
69
74
|
listRowsReferencingBlob(partition: string, blobId: string): Promise<{
|
|
70
75
|
readonly table: string;
|
package/dist/postgres-storage.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { validateCommitPruneQuery } from './prune.js';
|
|
2
|
+
import { StorageQueryError } from './storage-errors.js';
|
|
1
3
|
import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
2
4
|
import { syncError } from './errors.js';
|
|
3
5
|
import { asBytes, asNumber, } from './pg-executor.js';
|
|
@@ -107,6 +109,16 @@ CREATE TABLE IF NOT EXISTS sync_blob_refs(
|
|
|
107
109
|
CREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob
|
|
108
110
|
ON sync_blob_refs(partition, blob_id);
|
|
109
111
|
`;
|
|
112
|
+
async function lockPartitionOn(client, partition) {
|
|
113
|
+
const locked = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
|
|
114
|
+
if (locked.rows.length > 0)
|
|
115
|
+
return;
|
|
116
|
+
// The first writer initializes the partition. Concurrent initializers may
|
|
117
|
+
// wait on this insert, so acquire the row lock again before applying writes.
|
|
118
|
+
await client.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
|
|
119
|
+
ON CONFLICT (partition) DO NOTHING`, [partition]);
|
|
120
|
+
await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
|
|
121
|
+
}
|
|
110
122
|
function toBase64(bytes) {
|
|
111
123
|
return Buffer.from(bytes).toString('base64');
|
|
112
124
|
}
|
|
@@ -381,9 +393,7 @@ class PostgresTransaction {
|
|
|
381
393
|
}
|
|
382
394
|
async lockPartitionForPush() {
|
|
383
395
|
this.#assertOpen();
|
|
384
|
-
await this.#client
|
|
385
|
-
ON CONFLICT (partition) DO NOTHING`, [this.#partition]);
|
|
386
|
-
await this.#client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [this.#partition]);
|
|
396
|
+
await lockPartitionOn(this.#client, this.#partition);
|
|
387
397
|
await this.#client.query('SAVEPOINT syncular_push_candidate');
|
|
388
398
|
this.#pushApplySavepoint = true;
|
|
389
399
|
}
|
|
@@ -433,26 +443,37 @@ class PostgresTransaction {
|
|
|
433
443
|
// Allocate the next dense commitSeq under a per-partition row lock: the
|
|
434
444
|
// UPDATE … RETURNING serializes concurrent pushes to this partition and
|
|
435
445
|
// never leaves a gap on rollback (see the file header).
|
|
436
|
-
const { rows } = await q.query(`
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
446
|
+
const { rows } = await q.query(`WITH allocated AS (
|
|
447
|
+
INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 1)
|
|
448
|
+
ON CONFLICT (partition) DO UPDATE
|
|
449
|
+
SET max_commit_seq = sync_partitions.max_commit_seq + 1
|
|
450
|
+
RETURNING max_commit_seq
|
|
451
|
+
)
|
|
452
|
+
INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms)
|
|
453
|
+
SELECT $1, max_commit_seq, $2, $3, $4, $5 FROM allocated
|
|
454
|
+
RETURNING commit_seq`, [
|
|
443
455
|
p,
|
|
444
|
-
commitSeq,
|
|
445
456
|
commit.clientId,
|
|
446
457
|
commit.clientCommitId,
|
|
447
458
|
commit.actorId,
|
|
448
459
|
commit.createdAtMs,
|
|
449
460
|
]);
|
|
461
|
+
const commitSeq = asNumber(rows[0]?.commit_seq);
|
|
450
462
|
for (let idx = 0; idx < commit.changes.length; idx++) {
|
|
451
463
|
const change = commit.changes[idx];
|
|
452
464
|
if (change === undefined)
|
|
453
465
|
continue;
|
|
454
|
-
|
|
455
|
-
|
|
466
|
+
// Bind serialized scopes as text before parsing JSONB. Drivers that
|
|
467
|
+
// encode JSONB parameters would otherwise store this string as a scalar.
|
|
468
|
+
await q.query(`WITH inserted AS (
|
|
469
|
+
INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
|
|
470
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8::text::jsonb,$9)
|
|
471
|
+
RETURNING partition, tbl, commit_seq, scopes
|
|
472
|
+
)
|
|
473
|
+
INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
|
|
474
|
+
SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
|
|
475
|
+
FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
|
|
476
|
+
ON CONFLICT DO NOTHING`, [
|
|
456
477
|
p,
|
|
457
478
|
commitSeq,
|
|
458
479
|
idx,
|
|
@@ -463,10 +484,6 @@ class PostgresTransaction {
|
|
|
463
484
|
JSON.stringify(change.scopes),
|
|
464
485
|
change.payload ?? null,
|
|
465
486
|
]);
|
|
466
|
-
for (const [variable, value] of Object.entries(change.scopes)) {
|
|
467
|
-
await q.query(`INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
|
|
468
|
-
VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`, [p, change.table, variable, value, commitSeq]);
|
|
469
|
-
}
|
|
470
487
|
}
|
|
471
488
|
return commitSeq;
|
|
472
489
|
}
|
|
@@ -617,6 +634,9 @@ export class PostgresServerStorage {
|
|
|
617
634
|
await client.query('DELETE FROM sync_row_scopes WHERE tbl=$1', [
|
|
618
635
|
tableName,
|
|
619
636
|
]);
|
|
637
|
+
await client.query('DELETE FROM sync_blob_refs WHERE tbl=$1', [
|
|
638
|
+
tableName,
|
|
639
|
+
]);
|
|
620
640
|
await client.query(dropTableDdl(tableName));
|
|
621
641
|
}
|
|
622
642
|
for (const statement of schemaDdl(schema, existing, 'postgres', existingIndexes)) {
|
|
@@ -661,6 +681,7 @@ export class PostgresServerStorage {
|
|
|
661
681
|
if (logEpoch.length === 0)
|
|
662
682
|
throw new Error('log epoch must be non-empty');
|
|
663
683
|
await this.#exec.transaction(async (client) => {
|
|
684
|
+
await lockPartitionOn(client, partition);
|
|
664
685
|
await client.query(`INSERT INTO sync_partition_registry(
|
|
665
686
|
partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
666
687
|
) VALUES ($1,$2,TRUE,$3)
|
|
@@ -732,7 +753,7 @@ export class PostgresServerStorage {
|
|
|
732
753
|
if (this.#tables === undefined) {
|
|
733
754
|
throw new Error('ensureSchema(schema) must run before registered queries');
|
|
734
755
|
}
|
|
735
|
-
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.
|
|
756
|
+
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
|
|
736
757
|
return this.#exec.transaction(async (client) => {
|
|
737
758
|
await client.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
|
738
759
|
const result = await client.query(postgresPlaceholders(prepared.sql), prepared.params);
|
|
@@ -745,19 +766,38 @@ export class PostgresServerStorage {
|
|
|
745
766
|
};
|
|
746
767
|
});
|
|
747
768
|
}
|
|
769
|
+
async getPartitionLogEpoch(partition) {
|
|
770
|
+
const { rows } = await this.#exec.query('SELECT log_epoch FROM sync_partition_registry WHERE partition=$1', [partition]);
|
|
771
|
+
return rows[0]?.log_epoch;
|
|
772
|
+
}
|
|
748
773
|
async getHorizonSeq(partition) {
|
|
749
774
|
const { rows } = await this.#exec.query('SELECT horizon_seq FROM sync_partitions WHERE partition=$1', [partition]);
|
|
750
775
|
return rows[0] === undefined ? 0 : asNumber(rows[0].horizon_seq);
|
|
751
776
|
}
|
|
752
777
|
async setHorizonSeq(partition, seq) {
|
|
753
778
|
await this.#exec.query(`INSERT INTO sync_partitions(partition, horizon_seq) VALUES ($1,$2)
|
|
754
|
-
ON CONFLICT (partition) DO UPDATE SET horizon_seq=EXCLUDED.horizon_seq`, [partition, seq]);
|
|
779
|
+
ON CONFLICT (partition) DO UPDATE SET horizon_seq=GREATEST(sync_partitions.horizon_seq,EXCLUDED.horizon_seq)`, [partition, seq]);
|
|
755
780
|
}
|
|
756
|
-
async pruneCommitsThrough(partition,
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
781
|
+
async pruneCommitsThrough(partition, query) {
|
|
782
|
+
validateCommitPruneQuery(query);
|
|
783
|
+
return this.#exec.transaction(async (client) => {
|
|
784
|
+
await lockPartitionOn(client, partition);
|
|
785
|
+
const epoch = await client.query('SELECT log_epoch FROM sync_partition_registry WHERE partition=$1 FOR UPDATE', [partition]);
|
|
786
|
+
if (epoch.rows[0]?.log_epoch !== query.logEpoch)
|
|
787
|
+
throw new StorageQueryError('sync.storage.prune_epoch_mismatch');
|
|
788
|
+
const previous = await client.query('SELECT horizon_seq FROM sync_partitions WHERE partition=$1', [partition]);
|
|
789
|
+
const previousHorizonSeq = asNumber(previous.rows[0]?.horizon_seq);
|
|
790
|
+
const horizonSeq = Math.max(previousHorizonSeq, query.throughSeq);
|
|
791
|
+
await client.query('UPDATE sync_partitions SET horizon_seq=$2 WHERE partition=$1', [partition, horizonSeq]);
|
|
792
|
+
const removed = await client.query('DELETE FROM sync_commits WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
|
|
793
|
+
await client.query('DELETE FROM sync_changes WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
|
|
794
|
+
await client.query('DELETE FROM sync_change_scopes WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
|
|
795
|
+
return {
|
|
796
|
+
previousHorizonSeq,
|
|
797
|
+
horizonSeq,
|
|
798
|
+
removedCommits: removed.rowCount,
|
|
799
|
+
};
|
|
800
|
+
});
|
|
761
801
|
}
|
|
762
802
|
async getCommitSeqBefore(partition, createdBeforeMs) {
|
|
763
803
|
const { rows } = await this.#exec.query('SELECT max(commit_seq) AS seq FROM sync_commits WHERE partition=$1 AND created_at_ms<$2', [partition, createdBeforeMs]);
|
|
@@ -1069,6 +1109,22 @@ export class PostgresServerStorage {
|
|
|
1069
1109
|
record.updatedAtMs,
|
|
1070
1110
|
]);
|
|
1071
1111
|
}
|
|
1112
|
+
async advanceClientCursor(partition, clientId, actorId, logEpoch, cursor, updatedAtMs) {
|
|
1113
|
+
await this.#exec.query(`UPDATE sync_clients
|
|
1114
|
+
SET cursor=GREATEST(cursor, $1), updated_at_ms=GREATEST(updated_at_ms, $2)
|
|
1115
|
+
WHERE partition=$3 AND client_id=$4 AND actor_id=$5
|
|
1116
|
+
AND EXISTS (SELECT 1 FROM sync_partition_registry
|
|
1117
|
+
WHERE partition=sync_clients.partition AND log_epoch=$6)`, [cursor, updatedAtMs, partition, clientId, actorId, logEpoch]);
|
|
1118
|
+
}
|
|
1119
|
+
async updateClientCursor(partition, clientId, cursor, updatedAtMs) {
|
|
1120
|
+
await this.#exec.query(`UPDATE sync_clients
|
|
1121
|
+
SET cursor=GREATEST(cursor, $3), updated_at_ms=GREATEST(updated_at_ms, $4)
|
|
1122
|
+
WHERE partition=$1 AND client_id=$2`, [partition, clientId, cursor, updatedAtMs]);
|
|
1123
|
+
}
|
|
1124
|
+
async getActiveClientCursorFloor(partition, cutoffMs) {
|
|
1125
|
+
const { rows } = await this.#exec.query('SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=$1 AND updated_at_ms>=$2', [partition, cutoffMs]);
|
|
1126
|
+
return rows[0].cursor === null ? null : asNumber(rows[0].cursor);
|
|
1127
|
+
}
|
|
1072
1128
|
async listClientCursors(partition) {
|
|
1073
1129
|
const { rows } = await this.#exec.query('SELECT client_id, cursor, updated_at_ms FROM sync_clients WHERE partition=$1', [partition]);
|
|
1074
1130
|
return rows.map((r) => ({
|
package/dist/prune.d.ts
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* least the newest `minRetainedCommits` commits are always retained.
|
|
8
8
|
*/
|
|
9
9
|
import { type SyncularServerEvents } from './events.js';
|
|
10
|
-
import type { ServerStorage } from './storage.js';
|
|
10
|
+
import type { CommitPruneQuery, ServerStorage } from './storage.js';
|
|
11
|
+
/** Shared validation for the built-in atomic pruning adapters. */
|
|
12
|
+
export declare function validateCommitPruneQuery(query: CommitPruneQuery): void;
|
|
11
13
|
export interface RetentionPolicy {
|
|
12
14
|
/** Active window for laggard cursors (default 14 days). */
|
|
13
15
|
readonly activeWindowMs: number;
|
package/dist/prune.js
CHANGED
|
@@ -7,6 +7,16 @@
|
|
|
7
7
|
* least the newest `minRetainedCommits` commits are always retained.
|
|
8
8
|
*/
|
|
9
9
|
import { emitEvent } from './events.js';
|
|
10
|
+
import { StorageQueryError } from './storage-errors.js';
|
|
11
|
+
/** Shared validation for the built-in atomic pruning adapters. */
|
|
12
|
+
export function validateCommitPruneQuery(query) {
|
|
13
|
+
if (!Number.isSafeInteger(query.throughSeq) ||
|
|
14
|
+
query.throughSeq < 0 ||
|
|
15
|
+
typeof query.logEpoch !== 'string' ||
|
|
16
|
+
query.logEpoch.length === 0) {
|
|
17
|
+
throw new StorageQueryError('sync.storage.invalid_prune_cursor');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
10
20
|
export const DEFAULT_RETENTION = {
|
|
11
21
|
activeWindowMs: 14 * 24 * 60 * 60 * 1000,
|
|
12
22
|
ageForceMs: 30 * 24 * 60 * 60 * 1000,
|
|
@@ -16,24 +26,18 @@ export const DEFAULT_RETENTION = {
|
|
|
16
26
|
export async function pruneCommitLog(options) {
|
|
17
27
|
const { storage, partition, nowMs } = options;
|
|
18
28
|
const policy = { ...DEFAULT_RETENTION, ...options.retention };
|
|
29
|
+
const logEpoch = await storage.getPartitionLogEpoch(partition);
|
|
30
|
+
if (logEpoch === undefined)
|
|
31
|
+
throw new StorageQueryError('sync.storage.partition_unregistered');
|
|
19
32
|
const maxSeq = await storage.getMaxCommitSeq(partition);
|
|
20
|
-
const
|
|
21
|
-
const activeCursors = cursors
|
|
22
|
-
.filter((c) => c.updatedAtMs >= nowMs - policy.activeWindowMs)
|
|
23
|
-
.map((c) => c.cursor);
|
|
24
|
-
const cursorFloor = activeCursors.length > 0
|
|
25
|
-
? Math.min(...activeCursors)
|
|
26
|
-
: Number.MAX_SAFE_INTEGER;
|
|
33
|
+
const cursorFloor = (await storage.getActiveClientCursorFloor(partition, nowMs - policy.activeWindowMs)) ?? Number.MAX_SAFE_INTEGER;
|
|
27
34
|
const forcedSeq = await storage.getCommitSeqBefore(partition, nowMs - policy.ageForceMs);
|
|
28
35
|
const retainFloor = maxSeq - policy.minRetainedCommits;
|
|
29
36
|
const target = Math.min(Math.max(cursorFloor, forcedSeq), retainFloor);
|
|
30
|
-
const current = await storage.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
await storage.setHorizonSeq(partition, horizon);
|
|
35
|
-
removedCommits = await storage.pruneCommitsThrough(partition, horizon);
|
|
36
|
-
}
|
|
37
|
+
const { previousHorizonSeq: current, horizonSeq: horizon, removedCommits, } = await storage.pruneCommitsThrough(partition, {
|
|
38
|
+
logEpoch,
|
|
39
|
+
throughSeq: Math.max(0, target),
|
|
40
|
+
});
|
|
37
41
|
const events = options.events;
|
|
38
42
|
if (events !== undefined) {
|
|
39
43
|
emitEvent(events, {
|
package/dist/pull.js
CHANGED
|
@@ -6,6 +6,8 @@ import { decodeRow, encodeRowsSegment, } from '@syncular/core';
|
|
|
6
6
|
import { clockOf, limitsOf } from './context.js';
|
|
7
7
|
import { scopeDigest } from './scopes.js';
|
|
8
8
|
import { issueSegmentUrl } from './signed-url.js';
|
|
9
|
+
// One artifact build per owning storage pair and complete immutable identity.
|
|
10
|
+
const imageBuilds = new WeakMap();
|
|
9
11
|
/**
|
|
10
12
|
* Resolve the §5.3 image builder: the host-injected one if present, else the
|
|
11
13
|
* in-tree `buildSqliteImage` on a Bun runtime (dynamic import so `bun:sqlite`
|
|
@@ -177,33 +179,17 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
|
|
|
177
179
|
const buildImage = await resolveImageBuilder(ctx);
|
|
178
180
|
if (buildImage === undefined)
|
|
179
181
|
return false;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const rows = [...probe];
|
|
185
|
-
let afterRowId = probe[probe.length - 1]?.rowId ?? null;
|
|
186
|
-
for (;;) {
|
|
187
|
-
const scanned = await storage.scanRows(partition, {
|
|
188
|
-
table: plan.table.name,
|
|
189
|
-
scopeFilter: plan.effective,
|
|
190
|
-
afterRowId,
|
|
191
|
-
limit: 50_000,
|
|
192
|
-
});
|
|
193
|
-
rows.push(...scanned);
|
|
194
|
-
const last = scanned[scanned.length - 1];
|
|
195
|
-
if (scanned.length < 50_000 || last === undefined)
|
|
196
|
-
break;
|
|
197
|
-
afterRowId = last.rowId;
|
|
182
|
+
let stores = imageBuilds.get(storage);
|
|
183
|
+
if (stores === undefined) {
|
|
184
|
+
stores = new WeakMap();
|
|
185
|
+
imageBuilds.set(storage, stores);
|
|
198
186
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
});
|
|
206
|
-
const record = await segments.put({
|
|
187
|
+
let builds = stores.get(segments);
|
|
188
|
+
if (builds === undefined) {
|
|
189
|
+
builds = new Map();
|
|
190
|
+
stores.set(segments, builds);
|
|
191
|
+
}
|
|
192
|
+
const identity = {
|
|
207
193
|
partition,
|
|
208
194
|
logEpoch,
|
|
209
195
|
table: plan.table.name,
|
|
@@ -211,18 +197,63 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
|
|
|
211
197
|
mediaType: 'sqlite',
|
|
212
198
|
scopeDigest: digest,
|
|
213
199
|
asOfCommitSeq: asOf,
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
200
|
+
};
|
|
201
|
+
const key = JSON.stringify(identity);
|
|
202
|
+
let building = builds.get(key);
|
|
203
|
+
let builtHere = false;
|
|
204
|
+
if (building === undefined) {
|
|
205
|
+
building = (async () => {
|
|
206
|
+
// Another request can finish while this one's eligibility probe awaits.
|
|
207
|
+
const cached = await segments.find(identity, clockOf(ctx)());
|
|
208
|
+
if (cached !== undefined)
|
|
209
|
+
return cached;
|
|
210
|
+
builtHere = true;
|
|
211
|
+
let rowCount = 0;
|
|
212
|
+
const bytes = await buildImage({
|
|
213
|
+
table: plan.table,
|
|
214
|
+
schemaVersion: schema.version,
|
|
215
|
+
asOfCommitSeq: asOf,
|
|
216
|
+
scopeDigest: digest,
|
|
217
|
+
rowBatches: (async function* () {
|
|
218
|
+
rowCount += probe.length;
|
|
219
|
+
yield probe;
|
|
220
|
+
let afterRowId = probe[probe.length - 1].rowId;
|
|
221
|
+
for (;;) {
|
|
222
|
+
const rows = await storage.scanRows(partition, {
|
|
223
|
+
table: plan.table.name,
|
|
224
|
+
scopeFilter: plan.effective,
|
|
225
|
+
afterRowId,
|
|
226
|
+
limit: 5_000,
|
|
227
|
+
});
|
|
228
|
+
rowCount += rows.length;
|
|
229
|
+
yield rows;
|
|
230
|
+
const last = rows[rows.length - 1];
|
|
231
|
+
if (rows.length < 5_000 || last === undefined)
|
|
232
|
+
break;
|
|
233
|
+
afterRowId = last.rowId;
|
|
234
|
+
}
|
|
235
|
+
})(),
|
|
236
|
+
});
|
|
237
|
+
return segments.put({ ...identity, rowCount, rowCursor: null, nextRowCursor: null }, bytes, clockOf(ctx)());
|
|
238
|
+
})();
|
|
239
|
+
builds.set(key, building);
|
|
240
|
+
}
|
|
241
|
+
let record;
|
|
242
|
+
try {
|
|
243
|
+
record = await building;
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
if (builds.get(key) === building)
|
|
247
|
+
builds.delete(key);
|
|
248
|
+
}
|
|
218
249
|
trace?.segments.push({
|
|
219
250
|
mediaType: 'sqlite',
|
|
220
251
|
delivery: 'ref',
|
|
221
|
-
origin: 'built',
|
|
252
|
+
origin: builtHere ? 'built' : 'reused',
|
|
222
253
|
bytes: record.byteLength,
|
|
223
254
|
rows: record.rowCount,
|
|
224
255
|
});
|
|
225
|
-
yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest,
|
|
256
|
+
yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest, clockOf(ctx)()));
|
|
226
257
|
return true;
|
|
227
258
|
}
|
|
228
259
|
async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch) {
|
|
@@ -330,6 +361,22 @@ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, ho
|
|
|
330
361
|
return { nextCursor: sub.cursor, active: false };
|
|
331
362
|
}
|
|
332
363
|
const token = parseBootstrapToken(sub.bootstrapState, sub.table);
|
|
364
|
+
let commits = [];
|
|
365
|
+
if (token === undefined &&
|
|
366
|
+
sub.cursor >= 0 &&
|
|
367
|
+
sub.cursor >= horizonSeq &&
|
|
368
|
+
sub.cursor <= maxSeq) {
|
|
369
|
+
commits = await ctx.storage.readCommitWindow(ctx.partition, {
|
|
370
|
+
table: sub.table,
|
|
371
|
+
scopeFilter: plan.effective,
|
|
372
|
+
afterSeq: sub.cursor,
|
|
373
|
+
throughSeq: maxSeq,
|
|
374
|
+
limitChanges: limits.limitCommits + 1,
|
|
375
|
+
});
|
|
376
|
+
// Validate continuity before committing to an active section. A prune
|
|
377
|
+
// during a paged read can otherwise make an incomplete window look empty.
|
|
378
|
+
horizonSeq = Math.max(horizonSeq, await ctx.storage.getHorizonSeq(ctx.partition));
|
|
379
|
+
}
|
|
333
380
|
// §4.6: a cursor behind the horizon (and not resuming a bootstrap)
|
|
334
381
|
// cannot compute deltas — answer `reset` and echo the cursor.
|
|
335
382
|
if (token === undefined && sub.cursor >= 0 && sub.cursor < horizonSeq) {
|
|
@@ -390,13 +437,6 @@ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, ho
|
|
|
390
437
|
effectiveScopes: plan.effective,
|
|
391
438
|
bootstrap: false,
|
|
392
439
|
};
|
|
393
|
-
const commits = await ctx.storage.readCommitWindow(ctx.partition, {
|
|
394
|
-
table: sub.table,
|
|
395
|
-
scopeFilter: plan.effective,
|
|
396
|
-
afterSeq: sub.cursor,
|
|
397
|
-
throughSeq: maxSeq,
|
|
398
|
-
limitChanges: limits.limitCommits + 1,
|
|
399
|
-
});
|
|
400
440
|
let delivered = 0;
|
|
401
441
|
let deliveredCommits = 0;
|
|
402
442
|
let lastDeliveredSeq = sub.cursor;
|
package/dist/push.js
CHANGED
|
@@ -98,7 +98,7 @@ async function runValidator(validators, table, op, rowId, values, storedValues,
|
|
|
98
98
|
// §6.7: a non-ValidationRejection throw is still a rejection, mapped to
|
|
99
99
|
// the generic server-side constraint code (§10.2) — the validator's
|
|
100
100
|
// failure never crashes the request or leaks its message as a code.
|
|
101
|
-
return errorRecord(opIndex, 'sync.constraint_violation',
|
|
101
|
+
return errorRecord(opIndex, 'sync.constraint_violation', 'write validator failed');
|
|
102
102
|
}
|
|
103
103
|
return undefined;
|
|
104
104
|
}
|
|
@@ -124,7 +124,7 @@ async function mergeCrdtColumns(table, values, storedValues, opIndex, mergers) {
|
|
|
124
124
|
continue; // NULL clear or absent
|
|
125
125
|
const merger = mergers?.[crdtType];
|
|
126
126
|
if (merger === undefined) {
|
|
127
|
-
return errorRecord(opIndex, 'sync.crdt_merge_failed',
|
|
127
|
+
return errorRecord(opIndex, 'sync.crdt_merge_failed', 'no CRDT merger registered');
|
|
128
128
|
}
|
|
129
129
|
const storedRaw = storedValues?.[index];
|
|
130
130
|
const stored = storedRaw instanceof Uint8Array ? storedRaw : null;
|
|
@@ -132,8 +132,8 @@ async function mergeCrdtColumns(table, values, storedValues, opIndex, mergers) {
|
|
|
132
132
|
try {
|
|
133
133
|
merged = await merger(stored, incoming);
|
|
134
134
|
}
|
|
135
|
-
catch
|
|
136
|
-
return errorRecord(opIndex, 'sync.crdt_merge_failed',
|
|
135
|
+
catch {
|
|
136
|
+
return errorRecord(opIndex, 'sync.crdt_merge_failed', 'CRDT merger failed');
|
|
137
137
|
}
|
|
138
138
|
values[index] = merged;
|
|
139
139
|
changed = true;
|
|
@@ -452,7 +452,7 @@ async function runCommitValidator(validator, tx, schema, clientId, clientCommitI
|
|
|
452
452
|
if (error instanceof ValidationRejection) {
|
|
453
453
|
return errorRecord(operations[0]?.opIndex ?? 0, error.code, error.message, false, error.details);
|
|
454
454
|
}
|
|
455
|
-
return errorRecord(operations[0]?.opIndex ?? 0, 'sync.constraint_violation',
|
|
455
|
+
return errorRecord(operations[0]?.opIndex ?? 0, 'sync.constraint_violation', 'whole-commit validator failed');
|
|
456
456
|
}
|
|
457
457
|
return undefined;
|
|
458
458
|
}
|
package/dist/realtime.d.ts
CHANGED
|
@@ -137,7 +137,7 @@ export declare class RealtimeSession {
|
|
|
137
137
|
handleBinary(bytes: Uint8Array): Promise<void> | undefined;
|
|
138
138
|
sendHeartbeat(): void;
|
|
139
139
|
sendWake(reason: WakeReason): void;
|
|
140
|
-
/** Called by the hub for every applied commit
|
|
140
|
+
/** Called by the hub for every applied commit notification. */
|
|
141
141
|
deliverCommit(commit: StoredCommit): void;
|
|
142
142
|
close(): void;
|
|
143
143
|
}
|
package/dist/realtime.js
CHANGED
|
@@ -191,6 +191,10 @@ export class RealtimeSession {
|
|
|
191
191
|
if (typeof cursor !== 'number' || !Number.isSafeInteger(cursor))
|
|
192
192
|
return;
|
|
193
193
|
this.cursor = Math.max(this.cursor, cursor);
|
|
194
|
+
// The client may have caught up through another binding (§8.4). Its ack
|
|
195
|
+
// proves those commits no longer need socket notifications, so the next
|
|
196
|
+
// notification must be adjacent to the acknowledged cursor.
|
|
197
|
+
this.lastKnownSeq = Math.max(this.lastKnownSeq, this.cursor);
|
|
194
198
|
if (this.cursor >= this.lastKnownSeq)
|
|
195
199
|
this.wakePending = false;
|
|
196
200
|
// §8.2: acks update the client cursor record without an HTTP pull.
|
|
@@ -468,14 +472,7 @@ export class RealtimeSession {
|
|
|
468
472
|
}
|
|
469
473
|
async #persistCursor() {
|
|
470
474
|
try {
|
|
471
|
-
|
|
472
|
-
if (record === undefined)
|
|
473
|
-
return;
|
|
474
|
-
await this.#storage.putClientRecord(this.partition, {
|
|
475
|
-
...record,
|
|
476
|
-
cursor: Math.max(record.cursor, this.cursor),
|
|
477
|
-
updatedAtMs: this.#clock(),
|
|
478
|
-
});
|
|
475
|
+
await this.#storage.advanceClientCursor(this.partition, this.clientId, this.actorId, this.logEpoch, this.cursor, this.#clock());
|
|
479
476
|
}
|
|
480
477
|
catch {
|
|
481
478
|
// Cursor persistence is best-effort; the next pull repairs it.
|
|
@@ -523,8 +520,9 @@ export class RealtimeSession {
|
|
|
523
520
|
});
|
|
524
521
|
}
|
|
525
522
|
}
|
|
526
|
-
/** Called by the hub for every applied commit
|
|
523
|
+
/** Called by the hub for every applied commit notification. */
|
|
527
524
|
deliverCommit(commit) {
|
|
525
|
+
const contiguous = commit.commitSeq === this.lastKnownSeq + 1;
|
|
528
526
|
this.lastKnownSeq = Math.max(this.lastKnownSeq, commit.commitSeq);
|
|
529
527
|
const sections = [];
|
|
530
528
|
for (const registration of this.registrations) {
|
|
@@ -544,6 +542,14 @@ export class RealtimeSession {
|
|
|
544
542
|
if (changes.length > 0)
|
|
545
543
|
sections.push({ registration, changes });
|
|
546
544
|
}
|
|
545
|
+
if (!contiguous) {
|
|
546
|
+
// Pushes allocate commitSeq under the partition lock but notify after
|
|
547
|
+
// commit, so racing notifications may arrive out of order. A gap,
|
|
548
|
+
// duplicate, or regression cannot be a delta: the client would apply
|
|
549
|
+
// and acknowledge a cursor that does not prove the intervening log.
|
|
550
|
+
this.sendWake('catchup-required');
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
547
553
|
if (sections.length === 0)
|
|
548
554
|
return;
|
|
549
555
|
if (this.#activeRound !== undefined) {
|
|
@@ -600,7 +606,7 @@ export class RealtimeSession {
|
|
|
600
606
|
tagged[0] = REALTIME_TAG_DELTA;
|
|
601
607
|
tagged.set(bytes, 1);
|
|
602
608
|
this.#sendSafe(tagged);
|
|
603
|
-
this.cursor = commit.commitSeq;
|
|
609
|
+
this.cursor = Math.max(this.cursor, commit.commitSeq);
|
|
604
610
|
const events = this.#events;
|
|
605
611
|
if (events !== undefined) {
|
|
606
612
|
emitEvent(events, {
|
|
@@ -87,7 +87,7 @@ export declare const SYNC_INDEX_PREFIX = "sync_ix_";
|
|
|
87
87
|
export declare function physicalIndexName(declaredName: string): string;
|
|
88
88
|
/**
|
|
89
89
|
* CREATE INDEX IF NOT EXISTS for the table's user-declared indexes. These use
|
|
90
|
-
* the
|
|
90
|
+
* the declared columns with a leading server partition column. Cross-table
|
|
91
91
|
* index-name uniqueness is the user's schema concern, as it is client-side.
|
|
92
92
|
* Server-side the physical name carries the {@link SYNC_INDEX_PREFIX}
|
|
93
93
|
* ownership marker.
|
|
@@ -181,6 +181,12 @@ export declare function commitWindowPageSql(valueCount: number, dialect: Relatio
|
|
|
181
181
|
export declare function selectRowScopesSql(table: CompiledTable, dialect: RelationalDialect): string;
|
|
182
182
|
/** DELETE one stored row. Params: [partition, rowId]. */
|
|
183
183
|
export declare function deleteRowSql(table: CompiledTable, dialect: RelationalDialect): string;
|
|
184
|
+
/**
|
|
185
|
+
* SQLite: remove the old row's exact scope keys before replacing or deleting the row.
|
|
186
|
+
* Params: [partition, table, rowId, partition, rowId]. The caller keeps this
|
|
187
|
+
* statement and the row write in the same transaction (or D1 atomic batch).
|
|
188
|
+
*/
|
|
189
|
+
export declare function deleteSqliteRowScopesSql(table: CompiledTable): string;
|
|
184
190
|
/**
|
|
185
191
|
* The schema-version marker table gates DDL work. `ensureSchema` compares the
|
|
186
192
|
* stored version and skips
|