@syncular/server 0.31.0 → 0.32.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 +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 +128 -19
- 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 +19 -6
- package/dist/postgres-storage.d.ts +6 -5
- package/dist/postgres-storage.js +102 -18
- package/dist/realtime.d.ts +15 -0
- package/dist/realtime.js +24 -0
- 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 +187 -23
- package/src/events-ring.ts +31 -2
- package/src/events.ts +27 -4
- package/src/handler.ts +20 -6
- package/src/postgres-storage.ts +173 -28
- package/src/realtime.ts +35 -1
- package/src/sqlite-storage.ts +181 -26
- package/src/storage.ts +72 -9
package/dist/d1-storage.d.ts
CHANGED
|
@@ -42,7 +42,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
|
42
42
|
*/
|
|
43
43
|
import { type ScopeMap } from '@syncular/core';
|
|
44
44
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
45
|
-
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';
|
|
45
|
+
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';
|
|
46
46
|
export interface D1PreparedStatement {
|
|
47
47
|
bind(...values: unknown[]): D1PreparedStatement;
|
|
48
48
|
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
@@ -136,10 +136,11 @@ export declare class D1ServerStorage implements ServerStorage {
|
|
|
136
136
|
listReferencedBlobIds(partition: string): Promise<string[]>;
|
|
137
137
|
listClientRecords(partition: string): Promise<ClientRecord[]>;
|
|
138
138
|
listCommitMetadata(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
|
|
139
|
+
getCommit(partition: string, commitSeq: number): Promise<CommitDetail | undefined>;
|
|
140
|
+
listRowChanges(partition: string, query: RowChangesQuery): Promise<RowChangeRecord[]>;
|
|
141
|
+
listRows(partition: string, query: RowListQuery): Promise<StoredRow[]>;
|
|
142
|
+
countRows(partition: string, table: string): Promise<number>;
|
|
143
|
+
listPushResults(partition: string, clientCommitId: string): Promise<ClientPushResult[]>;
|
|
139
144
|
scopeActivity(partition: string, query: ScopeActivityQuery): Promise<ScopeCommitActivity[]>;
|
|
140
|
-
getRowScopes(partition: string, table: string, rowId: string): Promise<{
|
|
141
|
-
serverVersion: number;
|
|
142
|
-
scopes: Record<string, string>;
|
|
143
|
-
} | undefined>;
|
|
144
145
|
listPartitions(): Promise<string[]>;
|
|
145
146
|
}
|
package/dist/d1-storage.js
CHANGED
|
@@ -45,9 +45,9 @@ import { StorageQueryError } from './storage-errors.js';
|
|
|
45
45
|
import { decodeRow } from '@syncular/core';
|
|
46
46
|
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
47
47
|
import { syncError } from './errors.js';
|
|
48
|
-
import { assertAppendOnlyMigration, assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, latestScopedChangeSeqSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
|
|
48
|
+
import { assertAppendOnlyMigration, 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, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
|
|
49
49
|
import { matchesEffective } from './scopes.js';
|
|
50
|
-
import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
|
|
50
|
+
import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, serializePushResult, sqliteDdlStatements, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
|
|
51
51
|
import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
|
|
52
52
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
53
53
|
function toStoredReaction(record) {
|
|
@@ -1664,24 +1664,61 @@ export class D1ServerStorage {
|
|
|
1664
1664
|
}));
|
|
1665
1665
|
}
|
|
1666
1666
|
async listCommitMetadata(partition, query) {
|
|
1667
|
+
// Newest-first window, resumed by `afterSeq` (exclusive lower bound) and
|
|
1668
|
+
// ended by `beforeSeq` (exclusive upper bound) when paging older. An
|
|
1669
|
+
// omitted bound is the largest safe commitSeq, so one statement serves
|
|
1670
|
+
// both the bounded and unbounded read.
|
|
1671
|
+
const beforeSeq = query.beforeSeq ?? Number.MAX_SAFE_INTEGER;
|
|
1667
1672
|
const { results: rows } = query.table
|
|
1668
1673
|
? await this.#db
|
|
1669
1674
|
.prepare(`SELECT c.commit_seq, c.client_id, c.client_commit_id, c.actor_id, c.created_at_ms
|
|
1670
1675
|
FROM sync_commits c
|
|
1671
|
-
WHERE c.partition=? AND c.commit_seq>?
|
|
1676
|
+
WHERE c.partition=? AND c.commit_seq>? AND c.commit_seq<?
|
|
1672
1677
|
AND EXISTS (SELECT 1 FROM sync_changes ch
|
|
1673
1678
|
WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=?)
|
|
1674
1679
|
ORDER BY c.commit_seq DESC LIMIT ?`)
|
|
1675
|
-
.bind(partition, query.afterSeq, query.table, query.limit)
|
|
1680
|
+
.bind(partition, query.afterSeq, beforeSeq, query.table, query.limit)
|
|
1676
1681
|
.all()
|
|
1677
1682
|
: await this.#db
|
|
1678
1683
|
.prepare(`SELECT commit_seq, client_id, client_commit_id, actor_id, created_at_ms
|
|
1679
1684
|
FROM sync_commits
|
|
1680
|
-
WHERE partition=? AND commit_seq>?
|
|
1685
|
+
WHERE partition=? AND commit_seq>? AND commit_seq<?
|
|
1681
1686
|
ORDER BY commit_seq DESC LIMIT ?`)
|
|
1682
|
-
.bind(partition, query.afterSeq, query.limit)
|
|
1687
|
+
.bind(partition, query.afterSeq, beforeSeq, query.limit)
|
|
1683
1688
|
.all();
|
|
1684
1689
|
const out = [];
|
|
1690
|
+
// `changeSample`: extra statement(s) for the whole page — the first
|
|
1691
|
+
// `sample` changes (change-index order) of every listed commit.
|
|
1692
|
+
const sampleCount = query.changeSample !== undefined &&
|
|
1693
|
+
Number.isInteger(query.changeSample) &&
|
|
1694
|
+
query.changeSample > 0
|
|
1695
|
+
? query.changeSample
|
|
1696
|
+
: 0;
|
|
1697
|
+
const samples = new Map();
|
|
1698
|
+
if (sampleCount > 0 && rows.length > 0) {
|
|
1699
|
+
const seqs = rows.map((row) => row.commit_seq);
|
|
1700
|
+
// D1 caps the bound parameters of one statement, so a page larger than
|
|
1701
|
+
// that cap splits into a few chunk statements, never one per commit.
|
|
1702
|
+
const chunkSize = D1_MAX_BIND_PARAMS - 2;
|
|
1703
|
+
for (let offset = 0; offset < seqs.length; offset += chunkSize) {
|
|
1704
|
+
const chunk = seqs.slice(offset, offset + chunkSize);
|
|
1705
|
+
const { results: changes } = await this.#db
|
|
1706
|
+
.prepare(`SELECT commit_seq, tbl, row_id, op FROM sync_changes
|
|
1707
|
+
WHERE partition=? AND commit_seq IN (${placeholders(chunk.length)}) AND idx<?
|
|
1708
|
+
ORDER BY commit_seq, idx`)
|
|
1709
|
+
.bind(partition, ...chunk, sampleCount)
|
|
1710
|
+
.all();
|
|
1711
|
+
for (const change of changes) {
|
|
1712
|
+
const sample = samples.get(change.commit_seq) ?? [];
|
|
1713
|
+
sample.push({
|
|
1714
|
+
table: change.tbl,
|
|
1715
|
+
rowId: change.row_id,
|
|
1716
|
+
op: change.op === 1 ? 'upsert' : 'delete',
|
|
1717
|
+
});
|
|
1718
|
+
samples.set(change.commit_seq, sample);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1685
1722
|
for (const row of rows) {
|
|
1686
1723
|
const { results: changes } = await this.#db
|
|
1687
1724
|
.prepare('SELECT tbl, count(*) AS n FROM sync_changes WHERE partition=? AND commit_seq=? GROUP BY tbl')
|
|
@@ -1695,10 +1732,95 @@ export class D1ServerStorage {
|
|
|
1695
1732
|
createdAtMs: row.created_at_ms,
|
|
1696
1733
|
changeCount: changes.reduce((sum, c) => sum + c.n, 0),
|
|
1697
1734
|
tables: changes.map((c) => c.tbl),
|
|
1735
|
+
...(sampleCount > 0
|
|
1736
|
+
? { sample: samples.get(row.commit_seq) ?? [] }
|
|
1737
|
+
: {}),
|
|
1698
1738
|
});
|
|
1699
1739
|
}
|
|
1700
1740
|
return out;
|
|
1701
1741
|
}
|
|
1742
|
+
async getCommit(partition, commitSeq) {
|
|
1743
|
+
const meta = await this.#db
|
|
1744
|
+
.prepare('SELECT client_id, client_commit_id, actor_id, created_at_ms FROM sync_commits WHERE partition=? AND commit_seq=?')
|
|
1745
|
+
.bind(partition, commitSeq)
|
|
1746
|
+
.first();
|
|
1747
|
+
if (meta === null)
|
|
1748
|
+
return undefined;
|
|
1749
|
+
const { results: changeRows } = await this.#db
|
|
1750
|
+
.prepare('SELECT tbl, row_id, op, row_version, scopes, payload FROM sync_changes WHERE partition=? AND commit_seq=? ORDER BY idx')
|
|
1751
|
+
.bind(partition, commitSeq)
|
|
1752
|
+
.all();
|
|
1753
|
+
const changes = changeRows.map(toStoredChange);
|
|
1754
|
+
return {
|
|
1755
|
+
commitSeq,
|
|
1756
|
+
clientId: meta.client_id,
|
|
1757
|
+
clientCommitId: meta.client_commit_id,
|
|
1758
|
+
actorId: meta.actor_id,
|
|
1759
|
+
createdAtMs: meta.created_at_ms,
|
|
1760
|
+
changeCount: changes.length,
|
|
1761
|
+
tables: [...new Set(changes.map((change) => change.table))].sort(),
|
|
1762
|
+
changes,
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
async listRowChanges(partition, query) {
|
|
1766
|
+
// Retained-log read for one row: no dedicated index (the write path pays
|
|
1767
|
+
// nothing for it), bounded by retention (§4.6).
|
|
1768
|
+
const { results: rows } = await this.#db
|
|
1769
|
+
.prepare(`SELECT ch.commit_seq, ch.op, ch.row_version, c.actor_id, c.client_id, c.created_at_ms
|
|
1770
|
+
FROM sync_changes ch
|
|
1771
|
+
JOIN sync_commits c
|
|
1772
|
+
ON c.partition=ch.partition AND c.commit_seq=ch.commit_seq
|
|
1773
|
+
WHERE ch.partition=? AND ch.tbl=? AND ch.row_id=?
|
|
1774
|
+
ORDER BY ch.commit_seq DESC, ch.idx DESC LIMIT ?`)
|
|
1775
|
+
.bind(partition, query.table, query.rowId, query.limit)
|
|
1776
|
+
.all();
|
|
1777
|
+
return rows.map((row) => ({
|
|
1778
|
+
commitSeq: row.commit_seq,
|
|
1779
|
+
op: row.op === 1 ? 'upsert' : 'delete',
|
|
1780
|
+
...(row.row_version !== null ? { rowVersion: row.row_version } : {}),
|
|
1781
|
+
actorId: row.actor_id,
|
|
1782
|
+
clientId: row.client_id,
|
|
1783
|
+
createdAtMs: row.created_at_ms,
|
|
1784
|
+
}));
|
|
1785
|
+
}
|
|
1786
|
+
async listRows(partition, query) {
|
|
1787
|
+
// Keyset page over the row table's (partition, rowId) primary key: an
|
|
1788
|
+
// index range read, no scope filter, no per-row lookup.
|
|
1789
|
+
const { results } = await this.#schemaDatabase()
|
|
1790
|
+
.prepare(`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
|
|
1791
|
+
FROM ${quoteIdent(this.table(query.table).name)}
|
|
1792
|
+
WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>?
|
|
1793
|
+
ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT ?`)
|
|
1794
|
+
.bind(partition, query.afterRowId ?? '', query.limit)
|
|
1795
|
+
.all();
|
|
1796
|
+
return results.map(toStoredRow);
|
|
1797
|
+
}
|
|
1798
|
+
async countRows(partition, table) {
|
|
1799
|
+
const record = await this.#schemaDatabase()
|
|
1800
|
+
.prepare(`SELECT count(*) AS n FROM ${quoteIdent(this.table(table).name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=?`)
|
|
1801
|
+
.bind(partition)
|
|
1802
|
+
.first();
|
|
1803
|
+
return record?.n ?? 0;
|
|
1804
|
+
}
|
|
1805
|
+
async listPushResults(partition, clientCommitId) {
|
|
1806
|
+
// Retained outcomes for one clientCommitId: no dedicated index, ordered
|
|
1807
|
+
// by client so the read is deterministic.
|
|
1808
|
+
const { results } = await this.#schemaDatabase()
|
|
1809
|
+
.prepare('SELECT client_id, result FROM sync_push_results WHERE partition=? AND client_commit_id=? ORDER BY client_id')
|
|
1810
|
+
.bind(partition, clientCommitId)
|
|
1811
|
+
.all();
|
|
1812
|
+
return results.map((record) => {
|
|
1813
|
+
try {
|
|
1814
|
+
return {
|
|
1815
|
+
clientId: record.client_id,
|
|
1816
|
+
result: deserializePushResult(record.result),
|
|
1817
|
+
};
|
|
1818
|
+
}
|
|
1819
|
+
catch {
|
|
1820
|
+
throw syncError('sync.idempotency_cache_miss', 'persisted push result unreadable (§6.3)');
|
|
1821
|
+
}
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1702
1824
|
async scopeActivity(partition, query) {
|
|
1703
1825
|
const { results: rows } = await this.#db
|
|
1704
1826
|
.prepare(`SELECT DISTINCT commit_seq, tbl FROM sync_change_scopes
|
|
@@ -1728,19 +1850,6 @@ export class D1ServerStorage {
|
|
|
1728
1850
|
}
|
|
1729
1851
|
return out;
|
|
1730
1852
|
}
|
|
1731
|
-
async getRowScopes(partition, table, rowId) {
|
|
1732
|
-
const db = this.#schemaDatabase();
|
|
1733
|
-
const record = await db
|
|
1734
|
-
.prepare(selectRowScopesSql(this.table(table), 'sqlite'))
|
|
1735
|
-
.bind(partition, rowId)
|
|
1736
|
-
.first();
|
|
1737
|
-
if (record === null)
|
|
1738
|
-
return undefined;
|
|
1739
|
-
return {
|
|
1740
|
-
serverVersion: record.server_version,
|
|
1741
|
-
scopes: JSON.parse(record.scopes),
|
|
1742
|
-
};
|
|
1743
|
-
}
|
|
1744
1853
|
async listPartitions() {
|
|
1745
1854
|
return (await this.listPartitionRegistry()).map((entry) => entry.partition);
|
|
1746
1855
|
}
|
package/dist/events-ring.d.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* without any infrastructure dependency.
|
|
4
4
|
*
|
|
5
5
|
* `RingBufferEvents` is a `SyncularServerEvents` sink that retains the last
|
|
6
|
-
* N events in memory and exposes a `query({type?,
|
|
7
|
-
* actorId?, limit})` plus a `subscribe(listener)` hook for live tails. It
|
|
6
|
+
* N events in memory and exposes a `query({type?, partition?, sinceMs?,
|
|
7
|
+
* clientId?, actorId?, limit})` plus a `subscribe(listener)` hook for live tails. It
|
|
8
8
|
* composes with any other sink through `composeEvents(...sinks)`, so a host
|
|
9
9
|
* can keep `consoleJsonEvents()` (or a Sentry adapter) AND feed the console
|
|
10
10
|
* event tail from the same emissions. Fire-and-forget discipline is
|
|
@@ -15,6 +15,10 @@ import { type SyncularServerEvent, type SyncularServerEvents } from './events.js
|
|
|
15
15
|
export interface RingEventQuery {
|
|
16
16
|
/** Restrict to one event `type` (e.g. `push.applied`). */
|
|
17
17
|
readonly type?: SyncularServerEvent['type'];
|
|
18
|
+
/** Restrict to one partition (every event carries one). */
|
|
19
|
+
readonly partition?: string;
|
|
20
|
+
/** Restrict to the events of one sync request. */
|
|
21
|
+
readonly requestId?: string;
|
|
18
22
|
/** Only events with `atMs >= sinceMs`. */
|
|
19
23
|
readonly sinceMs?: number;
|
|
20
24
|
/** Restrict to events carrying this `clientId` (not every type does). */
|
|
@@ -56,6 +60,12 @@ export declare class RingBufferEvents implements SyncularServerEvents {
|
|
|
56
60
|
get capacity(): number;
|
|
57
61
|
/** The number of events currently retained. */
|
|
58
62
|
get size(): number;
|
|
63
|
+
/**
|
|
64
|
+
* The newest `atMs` among events the ring dropped (evicted when full, or
|
|
65
|
+
* cleared); `undefined` while nothing was dropped. A window that starts
|
|
66
|
+
* at or before it is incomplete.
|
|
67
|
+
*/
|
|
68
|
+
get evictedThroughMs(): number | undefined;
|
|
59
69
|
/**
|
|
60
70
|
* Newest-first slice of the retained events. Filters by `type` and
|
|
61
71
|
* `sinceMs`, then caps to `limit`. Returns a fresh array — the caller may
|
package/dist/events-ring.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* without any infrastructure dependency.
|
|
4
4
|
*
|
|
5
5
|
* `RingBufferEvents` is a `SyncularServerEvents` sink that retains the last
|
|
6
|
-
* N events in memory and exposes a `query({type?,
|
|
7
|
-
* actorId?, limit})` plus a `subscribe(listener)` hook for live tails. It
|
|
6
|
+
* N events in memory and exposes a `query({type?, partition?, sinceMs?,
|
|
7
|
+
* clientId?, actorId?, limit})` plus a `subscribe(listener)` hook for live tails. It
|
|
8
8
|
* composes with any other sink through `composeEvents(...sinks)`, so a host
|
|
9
9
|
* can keep `consoleJsonEvents()` (or a Sentry adapter) AND feed the console
|
|
10
10
|
* event tail from the same emissions. Fire-and-forget discipline is
|
|
@@ -21,6 +21,12 @@ import { emitEvent, } from './events.js';
|
|
|
21
21
|
export function matchesRingQuery(event, query) {
|
|
22
22
|
if (query.type !== undefined && event.type !== query.type)
|
|
23
23
|
return false;
|
|
24
|
+
if (query.partition !== undefined && event.partition !== query.partition) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (query.requestId !== undefined && event.requestId !== query.requestId) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
24
30
|
if (query.sinceMs !== undefined && event.atMs < query.sinceMs)
|
|
25
31
|
return false;
|
|
26
32
|
const carrier = event;
|
|
@@ -46,6 +52,7 @@ export class RingBufferEvents {
|
|
|
46
52
|
/** Index of the oldest element in the circular buffer. */
|
|
47
53
|
#head = 0;
|
|
48
54
|
#size = 0;
|
|
55
|
+
#evictedThroughMs;
|
|
49
56
|
/** Live subscribers (the SSE tail). Each notify is guarded. */
|
|
50
57
|
#listeners = new Set();
|
|
51
58
|
constructor(options) {
|
|
@@ -62,6 +69,9 @@ export class RingBufferEvents {
|
|
|
62
69
|
}
|
|
63
70
|
else {
|
|
64
71
|
// Full: overwrite the oldest and advance the head.
|
|
72
|
+
const evicted = this.#buffer[this.#head];
|
|
73
|
+
if (evicted !== undefined)
|
|
74
|
+
this.#markEvicted(evicted.atMs);
|
|
65
75
|
this.#buffer[this.#head] = event;
|
|
66
76
|
this.#head = (this.#head + 1) % this.#capacity;
|
|
67
77
|
}
|
|
@@ -94,6 +104,19 @@ export class RingBufferEvents {
|
|
|
94
104
|
get size() {
|
|
95
105
|
return this.#size;
|
|
96
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* The newest `atMs` among events the ring dropped (evicted when full, or
|
|
109
|
+
* cleared); `undefined` while nothing was dropped. A window that starts
|
|
110
|
+
* at or before it is incomplete.
|
|
111
|
+
*/
|
|
112
|
+
get evictedThroughMs() {
|
|
113
|
+
return this.#evictedThroughMs;
|
|
114
|
+
}
|
|
115
|
+
#markEvicted(atMs) {
|
|
116
|
+
if (this.#evictedThroughMs === undefined || atMs > this.#evictedThroughMs) {
|
|
117
|
+
this.#evictedThroughMs = atMs;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
97
120
|
/**
|
|
98
121
|
* Newest-first slice of the retained events. Filters by `type` and
|
|
99
122
|
* `sinceMs`, then caps to `limit`. Returns a fresh array — the caller may
|
|
@@ -115,6 +138,8 @@ export class RingBufferEvents {
|
|
|
115
138
|
}
|
|
116
139
|
/** Drop all retained events. */
|
|
117
140
|
clear() {
|
|
141
|
+
for (const event of this.#buffer)
|
|
142
|
+
this.#markEvicted(event.atMs);
|
|
118
143
|
this.#buffer = [];
|
|
119
144
|
this.#head = 0;
|
|
120
145
|
this.#size = 0;
|
package/dist/events.d.ts
CHANGED
|
@@ -28,11 +28,15 @@ export interface RequestHandledEvent {
|
|
|
28
28
|
/**
|
|
29
29
|
* `ok` — response streamed to END;
|
|
30
30
|
* `schema_floor` — §2.4 required-schema answer;
|
|
31
|
-
* `
|
|
31
|
+
* `epoch_acquired` — §2.1 answer to a request without `logEpoch` (a new
|
|
32
|
+
* or wiped replica's first round);
|
|
33
|
+
* `reset` — §2.1 answer to a `logEpoch` that no longer matches the log;
|
|
32
34
|
* `rejected` — request validation failed before any bytes (§1.7);
|
|
33
35
|
* `error` — in-band ERROR frame (§1.6) or a thrown host failure.
|
|
34
36
|
*/
|
|
35
|
-
readonly outcome: 'ok' | 'schema_floor' | 'reset' | 'rejected' | 'error';
|
|
37
|
+
readonly outcome: 'ok' | 'schema_floor' | 'epoch_acquired' | 'reset' | 'rejected' | 'error';
|
|
38
|
+
/** The REQ_HEADER clientId; absent when the request failed to decode. */
|
|
39
|
+
readonly clientId?: string;
|
|
36
40
|
/** §10.2 code for `rejected`/`error`; `"internal"` for non-SyncErrors. */
|
|
37
41
|
readonly errorCode?: string;
|
|
38
42
|
readonly pushCommits: number;
|
|
@@ -60,6 +64,9 @@ export interface PushRejectedEvent extends PushEventBase {
|
|
|
60
64
|
readonly type: 'push.rejected';
|
|
61
65
|
readonly code: string;
|
|
62
66
|
readonly opIndex: number;
|
|
67
|
+
/** The terminating operation's table and rowId. */
|
|
68
|
+
readonly table?: string;
|
|
69
|
+
readonly rowId?: string;
|
|
63
70
|
/** True when the rejection was replayed from the idempotency cache. */
|
|
64
71
|
readonly replay: boolean;
|
|
65
72
|
/** Original host time for outcomes recorded by a metadata-aware server. */
|
|
@@ -71,6 +78,9 @@ export interface PushRejectedEvent extends PushEventBase {
|
|
|
71
78
|
export interface PushConflictedEvent extends PushEventBase {
|
|
72
79
|
readonly type: 'push.conflicted';
|
|
73
80
|
readonly opIndex: number;
|
|
81
|
+
/** The conflicting operation's table and rowId. */
|
|
82
|
+
readonly table?: string;
|
|
83
|
+
readonly rowId?: string;
|
|
74
84
|
/** True when the conflict was replayed from the idempotency cache. */
|
|
75
85
|
readonly replay: boolean;
|
|
76
86
|
readonly recordedAtMs?: number;
|
|
@@ -277,7 +287,15 @@ export interface LeaseRevokedEvent {
|
|
|
277
287
|
readonly partition: string;
|
|
278
288
|
readonly leaseId: string;
|
|
279
289
|
}
|
|
280
|
-
|
|
290
|
+
/**
|
|
291
|
+
* Every event. `requestId` is present on the events emitted while serving
|
|
292
|
+
* one sync request (`request.handled`, `push.*`, `pull.served`,
|
|
293
|
+
* `lease.issued`, `reaction.queued`, `scopes.resolve_failed`) and shared by
|
|
294
|
+
* all of them, so a sink can group one request's events.
|
|
295
|
+
*/
|
|
296
|
+
export type SyncularServerEvent = (RequestHandledEvent | PushAppliedEvent | PushRejectedEvent | PushConflictedEvent | ReactionQueuedEvent | ReactionStartedEvent | ReactionRetriedEvent | ReactionCompletedEvent | ReactionDeadLetteredEvent | ReactionPruneCompletedEvent | PullServedEvent | SegmentDownloadedEvent | BlobUploadedEvent | BlobDownloadedEvent | BlobSweptEvent | RealtimeOpenedEvent | RealtimeClosedEvent | RealtimeDeltaEvent | RealtimeWakeEvent | PruneCompletedEvent | ScopesResolveFailedEvent | LeaseIssuedEvent | LeaseRevokedEvent) & {
|
|
297
|
+
readonly requestId?: string;
|
|
298
|
+
};
|
|
281
299
|
/**
|
|
282
300
|
* The seam. Optional on the server config — when absent, no event object
|
|
283
301
|
* is ever built. Implementations receive every event synchronously and
|
package/dist/handler.js
CHANGED
|
@@ -253,11 +253,14 @@ function emitPushEvent(events, ctx, clientId, push, processed) {
|
|
|
253
253
|
}
|
|
254
254
|
// Rejected (§6.3): exactly one terminating operation record.
|
|
255
255
|
const record = frame.results[0];
|
|
256
|
+
const op = record !== undefined ? push.operations[record.opIndex] : undefined;
|
|
257
|
+
const target = op !== undefined ? { table: op.table, rowId: op.rowId } : {};
|
|
256
258
|
if (record?.status === 'conflict') {
|
|
257
259
|
emitEvent(events, {
|
|
258
260
|
type: 'push.conflicted',
|
|
259
261
|
...base,
|
|
260
262
|
opIndex: record.opIndex,
|
|
263
|
+
...target,
|
|
261
264
|
replay: processed.replayed,
|
|
262
265
|
...(processed.recordedAtMs !== undefined
|
|
263
266
|
? { recordedAtMs: processed.recordedAtMs }
|
|
@@ -275,6 +278,7 @@ function emitPushEvent(events, ctx, clientId, push, processed) {
|
|
|
275
278
|
? record.code
|
|
276
279
|
: 'sync.invalid_request',
|
|
277
280
|
opIndex: record?.opIndex ?? 0,
|
|
281
|
+
...target,
|
|
278
282
|
replay: processed.replayed,
|
|
279
283
|
...(processed.recordedAtMs !== undefined
|
|
280
284
|
? { recordedAtMs: processed.recordedAtMs }
|
|
@@ -309,8 +313,10 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
309
313
|
: {}),
|
|
310
314
|
}, plan.wireVersion);
|
|
311
315
|
if (plan.epochReset) {
|
|
312
|
-
if (report !== undefined)
|
|
313
|
-
report.outcome =
|
|
316
|
+
if (report !== undefined) {
|
|
317
|
+
report.outcome =
|
|
318
|
+
plan.header.logEpoch === undefined ? 'epoch_acquired' : 'reset';
|
|
319
|
+
}
|
|
314
320
|
yield END_FRAME_BYTES;
|
|
315
321
|
return;
|
|
316
322
|
}
|
|
@@ -498,15 +504,21 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
498
504
|
* bytes are produced; adapters map them to HTTP JSON errors (§1.1).
|
|
499
505
|
*/
|
|
500
506
|
export async function createSyncResponseStream(bytes, ctx) {
|
|
501
|
-
const
|
|
502
|
-
if (
|
|
507
|
+
const sink = ctx.events;
|
|
508
|
+
if (sink === undefined)
|
|
503
509
|
return createStreamCore(bytes, ctx, undefined);
|
|
504
510
|
// Instrumented path: measure with the ctx clock, count bytes at the
|
|
505
|
-
// seam, emit exactly one `request.handled` per request.
|
|
511
|
+
// seam, emit exactly one `request.handled` per request. Every event of
|
|
512
|
+
// this request carries one `requestId`.
|
|
513
|
+
const requestId = crypto.randomUUID();
|
|
514
|
+
const events = {
|
|
515
|
+
emit: (event) => sink.emit({ ...event, requestId }),
|
|
516
|
+
};
|
|
517
|
+
const requestCtx = { ...ctx, events };
|
|
506
518
|
const clock = clockOf(ctx);
|
|
507
519
|
const startedAtMs = clock();
|
|
508
520
|
try {
|
|
509
|
-
return await createStreamCore(bytes,
|
|
521
|
+
return await createStreamCore(bytes, requestCtx, events, startedAtMs);
|
|
510
522
|
}
|
|
511
523
|
catch (error) {
|
|
512
524
|
emitEvent(events, {
|
|
@@ -617,6 +629,7 @@ async function* instrumentedStream(stream, ctx, events, plan, report, bytesIn, s
|
|
|
617
629
|
bytesIn,
|
|
618
630
|
bytesOut,
|
|
619
631
|
outcome: report.outcome,
|
|
632
|
+
clientId: plan.header.clientId,
|
|
620
633
|
...(report.errorCode !== undefined
|
|
621
634
|
? { errorCode: report.errorCode }
|
|
622
635
|
: {}),
|
|
@@ -39,7 +39,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
|
39
39
|
import type { ScopeMap } from '@syncular/core';
|
|
40
40
|
import { type PgExecutor } from './pg-executor.js';
|
|
41
41
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
42
|
-
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCheckpoint, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
42
|
+
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, StoredCheckpoint, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
43
43
|
/**
|
|
44
44
|
* Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
|
|
45
45
|
*
|
|
@@ -136,10 +136,11 @@ export declare class PostgresServerStorage implements ServerStorage {
|
|
|
136
136
|
listReferencedBlobIds(partition: string): Promise<string[]>;
|
|
137
137
|
listClientRecords(partition: string): Promise<ClientRecord[]>;
|
|
138
138
|
listCommitMetadata(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
|
|
139
|
+
getCommit(partition: string, commitSeq: number): Promise<CommitDetail | undefined>;
|
|
140
|
+
listRowChanges(partition: string, query: RowChangesQuery): Promise<RowChangeRecord[]>;
|
|
141
|
+
listRows(partition: string, query: RowListQuery): Promise<StoredRow[]>;
|
|
142
|
+
countRows(partition: string, table: string): Promise<number>;
|
|
143
|
+
listPushResults(partition: string, clientCommitId: string): Promise<ClientPushResult[]>;
|
|
139
144
|
scopeActivity(partition: string, query: ScopeActivityQuery): Promise<ScopeCommitActivity[]>;
|
|
140
|
-
getRowScopes(partition: string, table: string, rowId: string): Promise<{
|
|
141
|
-
serverVersion: number;
|
|
142
|
-
scopes: Record<string, string>;
|
|
143
|
-
} | undefined>;
|
|
144
145
|
listPartitions(): Promise<string[]>;
|
|
145
146
|
}
|
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
|
}
|