@syncular/server 0.31.0 → 0.32.1
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 +124 -74
- package/dist/admin.d.ts +394 -50
- package/dist/admin.js +714 -82
- package/dist/d1-storage.d.ts +6 -5
- package/dist/d1-storage.js +136 -21
- package/dist/events-ring.d.ts +12 -2
- package/dist/events-ring.js +27 -2
- package/dist/events.d.ts +21 -3
- package/dist/handler.js +36 -13
- package/dist/operations.js +4 -4
- package/dist/postgres-storage.d.ts +6 -5
- package/dist/postgres-storage.js +102 -18
- package/dist/push.js +28 -7
- package/dist/realtime.d.ts +15 -0
- package/dist/realtime.js +46 -7
- package/dist/seed.js +4 -3
- package/dist/sqlite-storage.d.ts +6 -5
- package/dist/sqlite-storage.js +115 -18
- package/dist/storage.d.ts +58 -7
- package/package.json +2 -2
- package/src/admin.ts +1215 -120
- package/src/d1-storage.ts +199 -25
- package/src/events-ring.ts +31 -2
- package/src/events.ts +27 -4
- package/src/handler.ts +47 -16
- package/src/operations.ts +8 -4
- package/src/postgres-storage.ts +173 -28
- package/src/push.ts +40 -9
- package/src/realtime.ts +60 -7
- package/src/seed.ts +4 -3
- package/src/sqlite-storage.ts +181 -26
- package/src/storage.ts +72 -9
package/dist/postgres-storage.js
CHANGED
|
@@ -5,7 +5,7 @@ import { serveNotReadyError } from './readiness.js';
|
|
|
5
5
|
import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
6
6
|
import { syncError } from './errors.js';
|
|
7
7
|
import { asBytes, asNumber, } from './pg-executor.js';
|
|
8
|
-
import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, LATEST_SCOPED_CHANGE_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowBatchSql, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, prefetchRowsSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
8
|
+
import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, LATEST_SCOPED_CHANGE_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowBatchSql, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, prefetchRowsSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
9
9
|
import { matchesEffective } from './scopes.js';
|
|
10
10
|
import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
|
|
11
11
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
@@ -1659,17 +1659,45 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1659
1659
|
}));
|
|
1660
1660
|
}
|
|
1661
1661
|
async listCommitMetadata(partition, query) {
|
|
1662
|
+
// Newest-first window, resumed by `afterSeq` (exclusive lower bound) and
|
|
1663
|
+
// ended by `beforeSeq` (exclusive upper bound) when paging older. An
|
|
1664
|
+
// omitted bound is the largest safe commitSeq, so one statement serves
|
|
1665
|
+
// both the bounded and unbounded read.
|
|
1666
|
+
const beforeSeq = query.beforeSeq ?? Number.MAX_SAFE_INTEGER;
|
|
1662
1667
|
const { rows } = query.table
|
|
1663
1668
|
? await this.#exec.query(`SELECT c.commit_seq, c.client_id, c.client_commit_id, c.actor_id, c.created_at_ms
|
|
1664
1669
|
FROM sync_commits c
|
|
1665
|
-
WHERE c.partition=$1 AND c.commit_seq>$2
|
|
1670
|
+
WHERE c.partition=$1 AND c.commit_seq>$2 AND c.commit_seq<$3
|
|
1666
1671
|
AND EXISTS (SELECT 1 FROM sync_changes ch
|
|
1667
|
-
WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=$
|
|
1668
|
-
ORDER BY c.commit_seq DESC LIMIT $
|
|
1672
|
+
WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=$4)
|
|
1673
|
+
ORDER BY c.commit_seq DESC LIMIT $5`, [partition, query.afterSeq, beforeSeq, query.table, query.limit])
|
|
1669
1674
|
: await this.#exec.query(`SELECT commit_seq, client_id, client_commit_id, actor_id, created_at_ms
|
|
1670
1675
|
FROM sync_commits
|
|
1671
|
-
WHERE partition=$1 AND commit_seq>$2
|
|
1672
|
-
ORDER BY commit_seq DESC LIMIT $
|
|
1676
|
+
WHERE partition=$1 AND commit_seq>$2 AND commit_seq<$3
|
|
1677
|
+
ORDER BY commit_seq DESC LIMIT $4`, [partition, query.afterSeq, beforeSeq, query.limit]);
|
|
1678
|
+
// `changeSample`: one extra statement for the whole page — the first
|
|
1679
|
+
// `sample` changes (change-index order) of every listed commit.
|
|
1680
|
+
const sampleCount = query.changeSample !== undefined &&
|
|
1681
|
+
Number.isInteger(query.changeSample) &&
|
|
1682
|
+
query.changeSample > 0
|
|
1683
|
+
? query.changeSample
|
|
1684
|
+
: 0;
|
|
1685
|
+
const samples = new Map();
|
|
1686
|
+
if (sampleCount > 0 && rows.length > 0) {
|
|
1687
|
+
const seqs = rows.map((row) => asNumber(row.commit_seq));
|
|
1688
|
+
const { rows: changeRows } = await this.#exec.query(`SELECT commit_seq, tbl, row_id, op FROM sync_changes
|
|
1689
|
+
WHERE partition=$1 AND commit_seq IN (${seqs.map((_, index) => `$${index + 2}`).join(',')}) AND idx<$${seqs.length + 2}
|
|
1690
|
+
ORDER BY commit_seq, idx`, [partition, ...seqs, sampleCount]);
|
|
1691
|
+
for (const change of changeRows) {
|
|
1692
|
+
const sample = samples.get(asNumber(change.commit_seq)) ?? [];
|
|
1693
|
+
sample.push({
|
|
1694
|
+
table: change.tbl,
|
|
1695
|
+
rowId: change.row_id,
|
|
1696
|
+
op: asNumber(change.op) === 1 ? 'upsert' : 'delete',
|
|
1697
|
+
});
|
|
1698
|
+
samples.set(asNumber(change.commit_seq), sample);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1673
1701
|
const out = [];
|
|
1674
1702
|
for (const row of rows) {
|
|
1675
1703
|
const commitSeq = asNumber(row.commit_seq);
|
|
@@ -1682,10 +1710,78 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1682
1710
|
createdAtMs: asNumber(row.created_at_ms),
|
|
1683
1711
|
changeCount: changes.rows.reduce((sum, c) => sum + asNumber(c.n), 0),
|
|
1684
1712
|
tables: changes.rows.map((c) => c.tbl),
|
|
1713
|
+
...(sampleCount > 0 ? { sample: samples.get(commitSeq) ?? [] } : {}),
|
|
1685
1714
|
});
|
|
1686
1715
|
}
|
|
1687
1716
|
return out;
|
|
1688
1717
|
}
|
|
1718
|
+
async getCommit(partition, commitSeq) {
|
|
1719
|
+
const { rows } = await this.#exec.query('SELECT client_id, client_commit_id, actor_id, created_at_ms FROM sync_commits WHERE partition=$1 AND commit_seq=$2', [partition, commitSeq]);
|
|
1720
|
+
const meta = rows[0];
|
|
1721
|
+
if (meta === undefined)
|
|
1722
|
+
return undefined;
|
|
1723
|
+
const { rows: changeRows } = await this.#exec.query('SELECT tbl, row_id, op, row_version, scopes, payload FROM sync_changes WHERE partition=$1 AND commit_seq=$2 ORDER BY idx', [partition, commitSeq]);
|
|
1724
|
+
const changes = changeRows.map(toStoredChange);
|
|
1725
|
+
return {
|
|
1726
|
+
commitSeq,
|
|
1727
|
+
clientId: meta.client_id,
|
|
1728
|
+
clientCommitId: meta.client_commit_id,
|
|
1729
|
+
actorId: meta.actor_id,
|
|
1730
|
+
createdAtMs: asNumber(meta.created_at_ms),
|
|
1731
|
+
changeCount: changes.length,
|
|
1732
|
+
tables: [...new Set(changes.map((change) => change.table))].sort(),
|
|
1733
|
+
changes,
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
async listRowChanges(partition, query) {
|
|
1737
|
+
// Retained-log read for one row: no dedicated index (the write path pays
|
|
1738
|
+
// nothing for it), bounded by retention (§4.6).
|
|
1739
|
+
const { rows } = await this.#exec.query(`SELECT ch.commit_seq, ch.op, ch.row_version, c.actor_id, c.client_id, c.created_at_ms
|
|
1740
|
+
FROM sync_changes ch
|
|
1741
|
+
JOIN sync_commits c
|
|
1742
|
+
ON c.partition=ch.partition AND c.commit_seq=ch.commit_seq
|
|
1743
|
+
WHERE ch.partition=$1 AND ch.tbl=$2 AND ch.row_id=$3
|
|
1744
|
+
ORDER BY ch.commit_seq DESC, ch.idx DESC LIMIT $4`, [partition, query.table, query.rowId, query.limit]);
|
|
1745
|
+
return rows.map((row) => ({
|
|
1746
|
+
commitSeq: asNumber(row.commit_seq),
|
|
1747
|
+
op: asNumber(row.op) === 1 ? 'upsert' : 'delete',
|
|
1748
|
+
...(row.row_version === null || row.row_version === undefined
|
|
1749
|
+
? {}
|
|
1750
|
+
: { rowVersion: asNumber(row.row_version) }),
|
|
1751
|
+
actorId: row.actor_id,
|
|
1752
|
+
clientId: row.client_id,
|
|
1753
|
+
createdAtMs: asNumber(row.created_at_ms),
|
|
1754
|
+
}));
|
|
1755
|
+
}
|
|
1756
|
+
async listRows(partition, query) {
|
|
1757
|
+
// Keyset page over the row table's (partition, rowId) primary key: an
|
|
1758
|
+
// index range read, no scope filter, no per-row lookup.
|
|
1759
|
+
const { rows } = await this.#exec.query(`SELECT ${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, ${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, ${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, ${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, ${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions
|
|
1760
|
+
FROM ${quoteIdent(this.table(query.table).name)}
|
|
1761
|
+
WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>$2
|
|
1762
|
+
ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT $3`, [partition, query.afterRowId ?? '', query.limit]);
|
|
1763
|
+
return rows.map(toStoredRow);
|
|
1764
|
+
}
|
|
1765
|
+
async countRows(partition, table) {
|
|
1766
|
+
const { rows } = await this.#exec.query(`SELECT count(*) AS n FROM ${quoteIdent(this.table(table).name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=$1`, [partition]);
|
|
1767
|
+
return rows[0] === undefined ? 0 : asNumber(rows[0].n);
|
|
1768
|
+
}
|
|
1769
|
+
async listPushResults(partition, clientCommitId) {
|
|
1770
|
+
// Retained outcomes for one clientCommitId: no dedicated index, ordered
|
|
1771
|
+
// by client so the read is deterministic.
|
|
1772
|
+
const { rows } = await this.#exec.query('SELECT client_id, result FROM sync_push_results WHERE partition=$1 AND client_commit_id=$2 ORDER BY client_id', [partition, clientCommitId]);
|
|
1773
|
+
return rows.map((row) => {
|
|
1774
|
+
try {
|
|
1775
|
+
return {
|
|
1776
|
+
clientId: row.client_id,
|
|
1777
|
+
result: deserializePushResult(asJson(row.result)),
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
catch {
|
|
1781
|
+
throw syncError('sync.idempotency_cache_miss', 'persisted push result unreadable (§6.3)');
|
|
1782
|
+
}
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1689
1785
|
async scopeActivity(partition, query) {
|
|
1690
1786
|
const { rows } = await this.#exec.query(`SELECT DISTINCT commit_seq, tbl FROM sync_change_scopes
|
|
1691
1787
|
WHERE partition=$1 AND var=$2 AND value=$3
|
|
@@ -1708,18 +1804,6 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1708
1804
|
}
|
|
1709
1805
|
return out;
|
|
1710
1806
|
}
|
|
1711
|
-
async getRowScopes(partition, table, rowId) {
|
|
1712
|
-
const { rows } = await this.#exec.query(selectRowScopesSql(this.table(table), 'postgres'), [partition, rowId]);
|
|
1713
|
-
const row = rows[0];
|
|
1714
|
-
if (row === undefined)
|
|
1715
|
-
return undefined;
|
|
1716
|
-
return {
|
|
1717
|
-
serverVersion: asNumber(row.server_version),
|
|
1718
|
-
scopes: (typeof row.scopes === 'string'
|
|
1719
|
-
? JSON.parse(row.scopes)
|
|
1720
|
-
: row.scopes),
|
|
1721
|
-
};
|
|
1722
|
-
}
|
|
1723
1807
|
async listPartitions() {
|
|
1724
1808
|
return (await this.listPartitionRegistry()).map((entry) => entry.partition);
|
|
1725
1809
|
}
|
package/dist/push.js
CHANGED
|
@@ -298,6 +298,7 @@ async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blo
|
|
|
298
298
|
for (let i = 0; i < table.columns.length; i++) {
|
|
299
299
|
if (present[i] &&
|
|
300
300
|
!crdtIndexes.has(i) &&
|
|
301
|
+
i !== table.primaryKeyIndex &&
|
|
301
302
|
effectiveVersion(i) > op.baseVersion) {
|
|
302
303
|
marked.push(i);
|
|
303
304
|
}
|
|
@@ -602,7 +603,16 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
|
|
|
602
603
|
const changes = [];
|
|
603
604
|
const operations = [];
|
|
604
605
|
const originByIndex = [];
|
|
606
|
+
const finalByRow = new Map();
|
|
605
607
|
for (const write of staged) {
|
|
608
|
+
const rowKey = `${write.table.name}\u0000${write.rowId}`;
|
|
609
|
+
const existing = finalByRow.get(rowKey);
|
|
610
|
+
finalByRow.set(rowKey, {
|
|
611
|
+
first: existing?.first ?? write,
|
|
612
|
+
last: write,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
for (const { last: write } of finalByRow.values()) {
|
|
606
616
|
if (write.op !== 'upsert' || write.values === undefined)
|
|
607
617
|
continue;
|
|
608
618
|
for (const reference of write.table.references) {
|
|
@@ -636,9 +646,9 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
|
|
|
636
646
|
const scheduled = new Set();
|
|
637
647
|
const processed = new Set();
|
|
638
648
|
const queue = [];
|
|
639
|
-
for (const
|
|
640
|
-
if (
|
|
641
|
-
schedule(
|
|
649
|
+
for (const { first, last } of finalByRow.values()) {
|
|
650
|
+
if (last.op === 'delete' && first.stored !== undefined) {
|
|
651
|
+
schedule(last.table, last.rowId, last.opIndex, scheduled, queue);
|
|
642
652
|
}
|
|
643
653
|
}
|
|
644
654
|
for (let cursor = 0; cursor < queue.length; cursor++) {
|
|
@@ -649,6 +659,12 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
|
|
|
649
659
|
if (processed.has(key))
|
|
650
660
|
continue;
|
|
651
661
|
processed.add(key);
|
|
662
|
+
const parentPkColumn = item.table.columns[item.table.primaryKeyIndex];
|
|
663
|
+
const parentKeyValue = parentPkColumn?.type === 'integer'
|
|
664
|
+
? Number(item.rowId)
|
|
665
|
+
: parentPkColumn?.type === 'boolean'
|
|
666
|
+
? item.rowId === 'true'
|
|
667
|
+
: item.rowId;
|
|
652
668
|
for (const reference of item.table.referencedBy) {
|
|
653
669
|
if (tx.scanRowsByIndex === undefined) {
|
|
654
670
|
throw new Error('storage transaction does not support declared-reference enforcement (scanRowsByIndex)');
|
|
@@ -659,7 +675,7 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
|
|
|
659
675
|
const page = await tx.scanRowsByIndex({
|
|
660
676
|
table: reference.table,
|
|
661
677
|
index: reference.index,
|
|
662
|
-
values: [
|
|
678
|
+
values: [parentKeyValue],
|
|
663
679
|
afterRowId,
|
|
664
680
|
limit: 1_000,
|
|
665
681
|
});
|
|
@@ -684,15 +700,20 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
|
|
|
684
700
|
throw new Error(`unreachable: reference child table ${reference.table} is not compiled`);
|
|
685
701
|
}
|
|
686
702
|
for (const childRow of children) {
|
|
703
|
+
if (reference.onDelete === 'CASCADE') {
|
|
704
|
+
if (!schedule(child, childRow.rowId, item.originOpIndex, scheduled, queue)) {
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
else if (scheduled.has(`${child.name}\u0000${childRow.rowId}`)) {
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
687
711
|
if (originByIndex.length >= cascadeLimit) {
|
|
688
712
|
return {
|
|
689
713
|
kind: 'terminate',
|
|
690
714
|
record: referenceRejection(item.originOpIndex, 'declared-reference cascade exceeded the per-commit operation cap (§6.11)', { reason: 'cascade_limit' }),
|
|
691
715
|
};
|
|
692
716
|
}
|
|
693
|
-
if (!schedule(child, childRow.rowId, item.originOpIndex, scheduled, queue)) {
|
|
694
|
-
continue;
|
|
695
|
-
}
|
|
696
717
|
const syntheticIndex = clientOpCount + originByIndex.length;
|
|
697
718
|
const storedValues = decodeRow(child.columns, childRow.payload);
|
|
698
719
|
if (reference.onDelete === 'CASCADE') {
|
package/dist/realtime.d.ts
CHANGED
|
@@ -169,13 +169,28 @@ export declare class RealtimeSession {
|
|
|
169
169
|
deliverCommit(commit: StoredCommit): void;
|
|
170
170
|
close(): void;
|
|
171
171
|
}
|
|
172
|
+
/** One open realtime socket. */
|
|
173
|
+
export interface RealtimeConnection {
|
|
174
|
+
readonly sessionId: string;
|
|
175
|
+
readonly clientId: string;
|
|
176
|
+
readonly actorId: string;
|
|
177
|
+
readonly openedAtMs: number;
|
|
178
|
+
}
|
|
172
179
|
export declare class RealtimeHub {
|
|
173
180
|
#private;
|
|
174
181
|
/** §8.6 presence registry — ephemeral, in-memory, never persisted. */
|
|
175
182
|
readonly presence: PresenceRegistry;
|
|
176
183
|
constructor(config: RealtimeHubConfig);
|
|
177
184
|
projectPayload(registration: Registration, payload: Uint8Array): Uint8Array;
|
|
185
|
+
/**
|
|
186
|
+
* Compose `sink` into the hub's `events`, so socket rounds and sessions
|
|
187
|
+
* opened afterwards report to it as well. `createSyncularHono` uses it to
|
|
188
|
+
* feed the admin console's event ring.
|
|
189
|
+
*/
|
|
190
|
+
addEventSink(sink: SyncularServerEvents): void;
|
|
178
191
|
get sessionCount(): number;
|
|
192
|
+
/** The partition's open sockets, for the admin console. */
|
|
193
|
+
connections(partition: string): RealtimeConnection[];
|
|
179
194
|
/** §8.6.2 published-document size cap (bytes). */
|
|
180
195
|
get maxPresenceBytes(): number;
|
|
181
196
|
/** §8.6.4 presence rate cap (ms); 0 = off (reference default). */
|
package/dist/realtime.js
CHANGED
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
* one handler, two framings); the only JSON data-plane server event is
|
|
17
17
|
* the `sync` wake-up (§8.3).
|
|
18
18
|
*/
|
|
19
|
-
import { DecodeError, decodeMessage, encodeMessage, encodePresenceError, encodePresenceFanout, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimePresencePublish, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
|
|
19
|
+
import { DecodeError, decodeMessage, encodeMessage, encodePresenceError, encodePresenceFanout, isSupportedProtocolWireVersion, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimePresencePublish, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
|
|
20
20
|
import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
|
|
21
21
|
import { adapterSyncError, reportError, SyncError, syncError, } from './errors.js';
|
|
22
22
|
import { emitEvent } from './events.js';
|
|
23
|
+
import { composeEvents } from './events-ring.js';
|
|
23
24
|
import { createSyncResponseStream } from './handler.js';
|
|
24
25
|
import { compileSchema, } from './schema.js';
|
|
25
26
|
import { schemaWindowOf, projectRowPayload } from './schema-window.js';
|
|
@@ -159,6 +160,8 @@ export class RealtimeSession {
|
|
|
159
160
|
#registrationGeneration = 0;
|
|
160
161
|
/** Generation of the most recent refresh whose result was applied. */
|
|
161
162
|
#registrationApplied = 0;
|
|
163
|
+
/** Highest sequence that required a catch-up wake for this session. */
|
|
164
|
+
#wakeRequiredThroughSeq;
|
|
162
165
|
constructor(hub, options, registrations, cursor, latestSeq, clock, maxDeltaBytes, storage, logEpoch, wireVersion, events) {
|
|
163
166
|
this.sessionId = crypto.randomUUID();
|
|
164
167
|
this.partition = options.partition;
|
|
@@ -169,6 +172,7 @@ export class RealtimeSession {
|
|
|
169
172
|
this.cursor = cursor;
|
|
170
173
|
this.lastKnownSeq = latestSeq;
|
|
171
174
|
this.wakePending = cursor < latestSeq;
|
|
175
|
+
this.#wakeRequiredThroughSeq = cursor < latestSeq ? latestSeq : 0;
|
|
172
176
|
this.registrations = registrations;
|
|
173
177
|
this.openedAtMs = clock();
|
|
174
178
|
this.#send = options.send;
|
|
@@ -207,10 +211,10 @@ export class RealtimeSession {
|
|
|
207
211
|
// proves those commits no longer need socket notifications, so the next
|
|
208
212
|
// notification must be adjacent to the acknowledged cursor.
|
|
209
213
|
this.lastKnownSeq = Math.max(this.lastKnownSeq, this.cursor);
|
|
210
|
-
if (this.cursor >= this
|
|
214
|
+
if (this.cursor >= this.#wakeRequiredThroughSeq)
|
|
211
215
|
this.wakePending = false;
|
|
212
216
|
// §8.2: acks update the client cursor record without an HTTP pull.
|
|
213
|
-
this.#persistCursor();
|
|
217
|
+
this.#persistCursor(cursor);
|
|
214
218
|
}
|
|
215
219
|
/**
|
|
216
220
|
* Resolve once every queued control-plane write has settled (§8.2). A host
|
|
@@ -412,7 +416,7 @@ export class RealtimeSession {
|
|
|
412
416
|
};
|
|
413
417
|
try {
|
|
414
418
|
const requestedWireVersion = (requestBytes[4] ?? 0) | ((requestBytes[5] ?? 0) << 8);
|
|
415
|
-
if (requestedWireVersion
|
|
419
|
+
if (isSupportedProtocolWireVersion(requestedWireVersion)) {
|
|
416
420
|
this.wireVersion = requestedWireVersion;
|
|
417
421
|
}
|
|
418
422
|
let stream;
|
|
@@ -536,12 +540,12 @@ export class RealtimeSession {
|
|
|
536
540
|
this.#closeSocket?.();
|
|
537
541
|
this.#hub.disconnect(this);
|
|
538
542
|
}
|
|
539
|
-
#persistCursor() {
|
|
543
|
+
#persistCursor(cursor) {
|
|
540
544
|
// §8.2: start the write now and keep it on the control tail so `drain()`
|
|
541
545
|
// can await it. Two acks may write concurrently: storage folds them with
|
|
542
546
|
// `MAX(cursor, ?)`, so neither order loses the later cursor.
|
|
543
547
|
const write = (async () => {
|
|
544
|
-
await this.#storage.advanceClientCursor(this.partition, this.clientId, this.actorId, this.logEpoch,
|
|
548
|
+
await this.#storage.advanceClientCursor(this.partition, this.clientId, this.actorId, this.logEpoch, cursor, this.#clock());
|
|
545
549
|
})();
|
|
546
550
|
write.catch((error) => {
|
|
547
551
|
// Best-effort for the connection (the next pull repairs the cursor);
|
|
@@ -570,6 +574,7 @@ export class RealtimeSession {
|
|
|
570
574
|
}
|
|
571
575
|
sendWake(reason) {
|
|
572
576
|
this.wakePending = true;
|
|
577
|
+
this.#wakeRequiredThroughSeq = Math.max(this.#wakeRequiredThroughSeq, this.lastKnownSeq);
|
|
573
578
|
this.#sendSafe(JSON.stringify({
|
|
574
579
|
event: 'sync',
|
|
575
580
|
data: {
|
|
@@ -714,9 +719,32 @@ export class RealtimeHub {
|
|
|
714
719
|
projectPayload(registration, payload) {
|
|
715
720
|
return projectRowPayload(this.#schema.tables.get(registration.table), registration.codec, payload);
|
|
716
721
|
}
|
|
722
|
+
/**
|
|
723
|
+
* Compose `sink` into the hub's `events`, so socket rounds and sessions
|
|
724
|
+
* opened afterwards report to it as well. `createSyncularHono` uses it to
|
|
725
|
+
* feed the admin console's event ring.
|
|
726
|
+
*/
|
|
727
|
+
addEventSink(sink) {
|
|
728
|
+
const events = this.#config.events;
|
|
729
|
+
this.#config = {
|
|
730
|
+
...this.#config,
|
|
731
|
+
events: events === undefined ? sink : composeEvents(events, sink),
|
|
732
|
+
};
|
|
733
|
+
}
|
|
717
734
|
get sessionCount() {
|
|
718
735
|
return this.#sessions.size;
|
|
719
736
|
}
|
|
737
|
+
/** The partition's open sockets, for the admin console. */
|
|
738
|
+
connections(partition) {
|
|
739
|
+
return [...this.#sessions]
|
|
740
|
+
.filter((session) => session.partition === partition)
|
|
741
|
+
.map((session) => ({
|
|
742
|
+
sessionId: session.sessionId,
|
|
743
|
+
clientId: session.clientId,
|
|
744
|
+
actorId: session.actorId,
|
|
745
|
+
openedAtMs: session.openedAtMs,
|
|
746
|
+
}));
|
|
747
|
+
}
|
|
720
748
|
/** §8.6.2 published-document size cap (bytes). */
|
|
721
749
|
get maxPresenceBytes() {
|
|
722
750
|
return this.#config.maxPresenceBytes ?? DEFAULT_MAX_PRESENCE_BYTES;
|
|
@@ -842,6 +870,12 @@ export class RealtimeHub {
|
|
|
842
870
|
...(this.#config.commitValidator !== undefined
|
|
843
871
|
? { commitValidator: this.#config.commitValidator }
|
|
844
872
|
: {}),
|
|
873
|
+
...(this.#config.reactionPlanner !== undefined
|
|
874
|
+
? { reactionPlanner: this.#config.reactionPlanner }
|
|
875
|
+
: {}),
|
|
876
|
+
...(this.#config.checkpoints !== undefined
|
|
877
|
+
? { checkpoints: this.#config.checkpoints }
|
|
878
|
+
: {}),
|
|
845
879
|
...(this.#config.clock !== undefined
|
|
846
880
|
? { clock: this.#config.clock }
|
|
847
881
|
: {}),
|
|
@@ -869,6 +903,9 @@ export class RealtimeHub {
|
|
|
869
903
|
...(this.#config.onError !== undefined
|
|
870
904
|
? { onError: this.#config.onError }
|
|
871
905
|
: {}),
|
|
906
|
+
...(this.#config.mapError !== undefined
|
|
907
|
+
? { mapError: this.#config.mapError }
|
|
908
|
+
: {}),
|
|
872
909
|
realtime: this,
|
|
873
910
|
};
|
|
874
911
|
}
|
|
@@ -905,7 +942,9 @@ export class RealtimeHub {
|
|
|
905
942
|
const registrations = await this.loadRegistrations(options.partition, options.actorId, options.clientId);
|
|
906
943
|
const latestSeq = await storage.getMaxCommitSeq(options.partition);
|
|
907
944
|
const cursor = record?.cursor ?? -1;
|
|
908
|
-
const session = new RealtimeSession(this, options, registrations, cursor, latestSeq, clock, this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES, storage, registry.logEpoch, record
|
|
945
|
+
const session = new RealtimeSession(this, options, registrations, cursor, latestSeq, clock, this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES, storage, registry.logEpoch, record !== undefined && isSupportedProtocolWireVersion(record.wireVersion)
|
|
946
|
+
? record.wireVersion
|
|
947
|
+
: PROTOCOL_WIRE_VERSION, this.#config.events);
|
|
909
948
|
this.#sessions.add(session);
|
|
910
949
|
const helloResult = options.send(JSON.stringify({
|
|
911
950
|
event: 'hello',
|
package/dist/seed.js
CHANGED
|
@@ -13,6 +13,7 @@ import { decodeMessage, encodeMessage, encodeSparseRow, PROTOCOL_WIRE_VERSION, }
|
|
|
13
13
|
import { SyncError } from './errors.js';
|
|
14
14
|
import { composeEvents } from './events-ring.js';
|
|
15
15
|
import { handleSyncRequest } from './handler.js';
|
|
16
|
+
import { renderScopeValue } from './scopes.js';
|
|
16
17
|
/** Structured terminal failure from the real push path used by a seed. */
|
|
17
18
|
export class SeedMutationError extends Error {
|
|
18
19
|
name = 'SeedMutationError';
|
|
@@ -94,9 +95,9 @@ export async function seedMutations(config, target, mutations) {
|
|
|
94
95
|
}
|
|
95
96
|
const values = table.columns.map((column) => (byColumn.get(column.name) ?? null));
|
|
96
97
|
const pkIndex = table.columns.findIndex((c) => c.name === table.primaryKey);
|
|
97
|
-
const rowId = values[pkIndex];
|
|
98
|
-
if (
|
|
99
|
-
throw new SyncError('sync.invalid_request', `seedMutations: table ${table.name}: upsert requires a non-empty
|
|
98
|
+
const rowId = renderScopeValue(values[pkIndex]);
|
|
99
|
+
if (rowId === undefined) {
|
|
100
|
+
throw new SyncError('sync.invalid_request', `seedMutations: table ${table.name}: upsert requires a non-empty primary key`);
|
|
100
101
|
}
|
|
101
102
|
return {
|
|
102
103
|
table: mutation.table,
|
package/dist/sqlite-storage.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
|
2
2
|
import type { ScopeMap } from '@syncular/core';
|
|
3
3
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
4
4
|
import { type SqliteDatabase } from './sqlite-driver.js';
|
|
5
|
-
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
5
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
6
6
|
export declare class SqliteServerStorage implements ServerStorage {
|
|
7
7
|
#private;
|
|
8
8
|
readonly db: SqliteDatabase;
|
|
@@ -69,10 +69,11 @@ export declare class SqliteServerStorage implements ServerStorage {
|
|
|
69
69
|
listReferencedBlobIds(partition: string): Promise<string[]>;
|
|
70
70
|
listClientRecords(partition: string): Promise<ClientRecord[]>;
|
|
71
71
|
listCommitMetadata(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
|
|
72
|
+
getCommit(partition: string, commitSeq: number): Promise<CommitDetail | undefined>;
|
|
73
|
+
listRowChanges(partition: string, query: RowChangesQuery): Promise<RowChangeRecord[]>;
|
|
74
|
+
listRows(partition: string, query: RowListQuery): Promise<StoredRow[]>;
|
|
75
|
+
countRows(partition: string, table: string): Promise<number>;
|
|
76
|
+
listPushResults(partition: string, clientCommitId: string): Promise<ClientPushResult[]>;
|
|
72
77
|
scopeActivity(partition: string, query: ScopeActivityQuery): Promise<ScopeCommitActivity[]>;
|
|
73
|
-
getRowScopes(partition: string, table: string, rowId: string): Promise<{
|
|
74
|
-
serverVersion: number;
|
|
75
|
-
scopes: Record<string, string>;
|
|
76
|
-
} | undefined>;
|
|
77
78
|
listPartitions(): Promise<string[]>;
|
|
78
79
|
}
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -13,9 +13,9 @@ import { serveNotReadyError } from './readiness.js';
|
|
|
13
13
|
*/
|
|
14
14
|
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
15
15
|
import { syncError } from './errors.js';
|
|
16
|
-
import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, latestScopedChangeSeqSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
16
|
+
import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, latestScopedChangeSeqSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
17
17
|
import { matchesEffective } from './scopes.js';
|
|
18
|
-
import { collectCommitWindowPage, deserializePushResult, placeholders, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
|
|
18
|
+
import { collectCommitWindowPage, deserializePushResult, placeholders, SQLITE_DDL, serializePushResult, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
|
|
19
19
|
import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
|
|
20
20
|
import { isSqliteConstraintError, StorageConstraintError, } from './storage-errors.js';
|
|
21
21
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
@@ -1218,22 +1218,51 @@ END`);
|
|
|
1218
1218
|
}));
|
|
1219
1219
|
}
|
|
1220
1220
|
async listCommitMetadata(partition, query) {
|
|
1221
|
-
// Newest-first window, resumed by `afterSeq` (exclusive lower bound)
|
|
1221
|
+
// Newest-first window, resumed by `afterSeq` (exclusive lower bound) and
|
|
1222
|
+
// ended by `beforeSeq` (exclusive upper bound) when paging older. An
|
|
1223
|
+
// omitted bound is the largest safe commitSeq, so one statement serves
|
|
1224
|
+
// both the bounded and unbounded read.
|
|
1225
|
+
const beforeSeq = query.beforeSeq ?? Number.MAX_SAFE_INTEGER;
|
|
1222
1226
|
const rows = query.table
|
|
1223
1227
|
? this.db
|
|
1224
1228
|
.query(`SELECT c.commit_seq, c.client_id, c.client_commit_id, c.actor_id, c.created_at_ms
|
|
1225
1229
|
FROM sync_commits c
|
|
1226
|
-
WHERE c.partition=? AND c.commit_seq>?
|
|
1230
|
+
WHERE c.partition=? AND c.commit_seq>? AND c.commit_seq<?
|
|
1227
1231
|
AND EXISTS (SELECT 1 FROM sync_changes ch
|
|
1228
1232
|
WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=?)
|
|
1229
1233
|
ORDER BY c.commit_seq DESC LIMIT ?`)
|
|
1230
|
-
.all(partition, query.afterSeq, query.table, query.limit)
|
|
1234
|
+
.all(partition, query.afterSeq, beforeSeq, query.table, query.limit)
|
|
1231
1235
|
: this.db
|
|
1232
1236
|
.query(`SELECT commit_seq, client_id, client_commit_id, actor_id, created_at_ms
|
|
1233
1237
|
FROM sync_commits
|
|
1234
|
-
WHERE partition=? AND commit_seq>?
|
|
1238
|
+
WHERE partition=? AND commit_seq>? AND commit_seq<?
|
|
1235
1239
|
ORDER BY commit_seq DESC LIMIT ?`)
|
|
1236
|
-
.all(partition, query.afterSeq, query.limit);
|
|
1240
|
+
.all(partition, query.afterSeq, beforeSeq, query.limit);
|
|
1241
|
+
// `changeSample`: one extra statement for the whole page — the first
|
|
1242
|
+
// `sample` changes (change-index order) of every listed commit.
|
|
1243
|
+
const sampleCount = query.changeSample !== undefined &&
|
|
1244
|
+
Number.isInteger(query.changeSample) &&
|
|
1245
|
+
query.changeSample > 0
|
|
1246
|
+
? query.changeSample
|
|
1247
|
+
: 0;
|
|
1248
|
+
const samples = new Map();
|
|
1249
|
+
if (sampleCount > 0 && rows.length > 0) {
|
|
1250
|
+
const seqs = rows.map((row) => row.commit_seq);
|
|
1251
|
+
const records = this.db
|
|
1252
|
+
.query(`SELECT commit_seq, tbl, row_id, op FROM sync_changes
|
|
1253
|
+
WHERE partition=? AND commit_seq IN (${placeholders(seqs.length)}) AND idx<?
|
|
1254
|
+
ORDER BY commit_seq, idx`)
|
|
1255
|
+
.all(partition, ...seqs, sampleCount);
|
|
1256
|
+
for (const record of records) {
|
|
1257
|
+
const sample = samples.get(record.commit_seq) ?? [];
|
|
1258
|
+
sample.push({
|
|
1259
|
+
table: record.tbl,
|
|
1260
|
+
rowId: record.row_id,
|
|
1261
|
+
op: record.op === 1 ? 'upsert' : 'delete',
|
|
1262
|
+
});
|
|
1263
|
+
samples.set(record.commit_seq, sample);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1237
1266
|
return rows.map((row) => {
|
|
1238
1267
|
const changes = this.db
|
|
1239
1268
|
.query('SELECT tbl, count(*) AS n FROM sync_changes WHERE partition=? AND commit_seq=? GROUP BY tbl')
|
|
@@ -1246,9 +1275,88 @@ END`);
|
|
|
1246
1275
|
createdAtMs: row.created_at_ms,
|
|
1247
1276
|
changeCount: changes.reduce((sum, c) => sum + c.n, 0),
|
|
1248
1277
|
tables: changes.map((c) => c.tbl),
|
|
1278
|
+
...(sampleCount > 0
|
|
1279
|
+
? { sample: samples.get(row.commit_seq) ?? [] }
|
|
1280
|
+
: {}),
|
|
1249
1281
|
};
|
|
1250
1282
|
});
|
|
1251
1283
|
}
|
|
1284
|
+
async getCommit(partition, commitSeq) {
|
|
1285
|
+
const meta = this.db
|
|
1286
|
+
.query('SELECT client_id, client_commit_id, actor_id, created_at_ms FROM sync_commits WHERE partition=? AND commit_seq=?')
|
|
1287
|
+
.get(partition, commitSeq);
|
|
1288
|
+
if (meta === null)
|
|
1289
|
+
return undefined;
|
|
1290
|
+
const changes = this.db
|
|
1291
|
+
.query('SELECT tbl, row_id, op, row_version, scopes, payload FROM sync_changes WHERE partition=? AND commit_seq=? ORDER BY idx')
|
|
1292
|
+
.all(partition, commitSeq)
|
|
1293
|
+
.map(toStoredChange);
|
|
1294
|
+
return {
|
|
1295
|
+
commitSeq,
|
|
1296
|
+
clientId: meta.client_id,
|
|
1297
|
+
clientCommitId: meta.client_commit_id,
|
|
1298
|
+
actorId: meta.actor_id,
|
|
1299
|
+
createdAtMs: meta.created_at_ms,
|
|
1300
|
+
changeCount: changes.length,
|
|
1301
|
+
tables: [...new Set(changes.map((change) => change.table))].sort(),
|
|
1302
|
+
changes,
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
async listRowChanges(partition, query) {
|
|
1306
|
+
// Retained-log read for one row: no dedicated index (the write path pays
|
|
1307
|
+
// nothing for it), bounded by retention (§4.6).
|
|
1308
|
+
return this.db
|
|
1309
|
+
.query(`SELECT ch.commit_seq, ch.op, ch.row_version, c.actor_id, c.client_id, c.created_at_ms
|
|
1310
|
+
FROM sync_changes ch
|
|
1311
|
+
JOIN sync_commits c
|
|
1312
|
+
ON c.partition=ch.partition AND c.commit_seq=ch.commit_seq
|
|
1313
|
+
WHERE ch.partition=? AND ch.tbl=? AND ch.row_id=?
|
|
1314
|
+
ORDER BY ch.commit_seq DESC, ch.idx DESC LIMIT ?`)
|
|
1315
|
+
.all(partition, query.table, query.rowId, query.limit)
|
|
1316
|
+
.map((row) => ({
|
|
1317
|
+
commitSeq: row.commit_seq,
|
|
1318
|
+
op: row.op === 1 ? 'upsert' : 'delete',
|
|
1319
|
+
...(row.row_version !== null ? { rowVersion: row.row_version } : {}),
|
|
1320
|
+
actorId: row.actor_id,
|
|
1321
|
+
clientId: row.client_id,
|
|
1322
|
+
createdAtMs: row.created_at_ms,
|
|
1323
|
+
}));
|
|
1324
|
+
}
|
|
1325
|
+
async listRows(partition, query) {
|
|
1326
|
+
// Keyset page over the row table's (partition, rowId) primary key: an
|
|
1327
|
+
// index range read, no scope filter, no per-row lookup.
|
|
1328
|
+
return this.db
|
|
1329
|
+
.query(`SELECT ${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, ${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, ${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, ${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, ${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions
|
|
1330
|
+
FROM ${quoteIdent(this.table(query.table).name)}
|
|
1331
|
+
WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>?
|
|
1332
|
+
ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT ?`)
|
|
1333
|
+
.all(partition, query.afterRowId ?? '', query.limit)
|
|
1334
|
+
.map(toStoredRow);
|
|
1335
|
+
}
|
|
1336
|
+
async countRows(partition, table) {
|
|
1337
|
+
const record = this.db
|
|
1338
|
+
.query(`SELECT count(*) AS n FROM ${quoteIdent(this.table(table).name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=?`)
|
|
1339
|
+
.get(partition);
|
|
1340
|
+
return record?.n ?? 0;
|
|
1341
|
+
}
|
|
1342
|
+
async listPushResults(partition, clientCommitId) {
|
|
1343
|
+
// Retained outcomes for one clientCommitId: no dedicated index, ordered
|
|
1344
|
+
// by client so the read is deterministic.
|
|
1345
|
+
return this.db
|
|
1346
|
+
.query('SELECT client_id, result FROM sync_push_results WHERE partition=? AND client_commit_id=? ORDER BY client_id')
|
|
1347
|
+
.all(partition, clientCommitId)
|
|
1348
|
+
.map((record) => {
|
|
1349
|
+
try {
|
|
1350
|
+
return {
|
|
1351
|
+
clientId: record.client_id,
|
|
1352
|
+
result: deserializePushResult(record.result),
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
catch {
|
|
1356
|
+
throw syncError('sync.idempotency_cache_miss', 'persisted push result unreadable (§6.3)');
|
|
1357
|
+
}
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1252
1360
|
async scopeActivity(partition, query) {
|
|
1253
1361
|
// Candidate commits via the change-scope index (§3.1) — never a scan.
|
|
1254
1362
|
const rows = this.db
|
|
@@ -1276,17 +1384,6 @@ END`);
|
|
|
1276
1384
|
}
|
|
1277
1385
|
return out;
|
|
1278
1386
|
}
|
|
1279
|
-
async getRowScopes(partition, table, rowId) {
|
|
1280
|
-
const record = this.db
|
|
1281
|
-
.query(selectRowScopesSql(this.table(table), 'sqlite'))
|
|
1282
|
-
.get(partition, rowId);
|
|
1283
|
-
if (record === null)
|
|
1284
|
-
return undefined;
|
|
1285
|
-
return {
|
|
1286
|
-
serverVersion: record.server_version,
|
|
1287
|
-
scopes: JSON.parse(record.scopes),
|
|
1288
|
-
};
|
|
1289
|
-
}
|
|
1290
1387
|
async listPartitions() {
|
|
1291
1388
|
return (await this.listPartitionRegistry()).map((entry) => entry.partition);
|
|
1292
1389
|
}
|