@syncular/server 0.39.2 → 0.41.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 +4 -1
- package/dist/admin.d.ts +6 -0
- package/dist/admin.js +9 -0
- package/dist/d1-storage.d.ts +3 -2
- package/dist/d1-storage.js +60 -15
- package/dist/handler.js +79 -8
- package/dist/postgres-storage.d.ts +4 -3
- package/dist/postgres-storage.js +96 -46
- package/dist/relational-rows.d.ts +9 -0
- package/dist/relational-rows.js +30 -0
- package/dist/sqlite-dialect.d.ts +7 -1
- package/dist/sqlite-dialect.js +8 -0
- package/dist/sqlite-storage.d.ts +3 -2
- package/dist/sqlite-storage.js +57 -8
- package/dist/storage.d.ts +31 -1
- package/dist/storage.js +8 -0
- package/package.json +2 -2
- package/src/admin.ts +15 -0
- package/src/d1-storage.ts +109 -14
- package/src/handler.ts +126 -19
- package/src/postgres-storage.ts +154 -58
- package/src/relational-rows.ts +34 -0
- package/src/sqlite-dialect.ts +9 -0
- package/src/sqlite-storage.ts +102 -4
- package/src/storage.ts +56 -1
package/README.md
CHANGED
|
@@ -135,7 +135,10 @@ the complete [backup and restore runbook](https://syncular.dev/server-operations
|
|
|
135
135
|
A `SyncError` answers with its catalog code and status. By default, any other exception an
|
|
136
136
|
adapter catches answers `sync.internal_error` and is handed to
|
|
137
137
|
`SyncServerConfig.onError` exactly once; a throwing observer does not change
|
|
138
|
-
the response.
|
|
138
|
+
the response. A `SyncError` never reaches `onError`: observe a catalog failure
|
|
139
|
+
through the `request.handled` event (`outcome`, `errorCode`, `clientId`) or the
|
|
140
|
+
client's stored `lastErrorCode` / `consecutiveFailures` / `lastRoundAtMs`, which
|
|
141
|
+
`ServerStorage.recordClientRoundFailure` updates on every failed round. The same config accepts an optional synchronous `mapError` hook:
|
|
139
142
|
after `onError` observes the original, `mapError` may return a catalog
|
|
140
143
|
`SyncError` (with structured `details`, for example `retryAfterMs`) that the
|
|
141
144
|
adapter answers instead. Return `undefined` to keep `sync.internal_error`. A
|
package/dist/admin.d.ts
CHANGED
|
@@ -51,6 +51,12 @@ export interface AdminClient {
|
|
|
51
51
|
readonly connected?: boolean;
|
|
52
52
|
/** When the client's oldest open socket opened. */
|
|
53
53
|
readonly connectedSinceMs?: number;
|
|
54
|
+
/** When its last sync round finished, successfully or not. */
|
|
55
|
+
readonly lastRoundAtMs?: number;
|
|
56
|
+
/** Catalog code of its last round; present only while that round failed. */
|
|
57
|
+
readonly lastErrorCode?: string;
|
|
58
|
+
/** Rounds failed in a row since its last success; absent at zero. */
|
|
59
|
+
readonly consecutiveFailures?: number;
|
|
54
60
|
}
|
|
55
61
|
export interface AdminListClientsOptions {
|
|
56
62
|
/** Default 200. */
|
package/dist/admin.js
CHANGED
|
@@ -926,6 +926,15 @@ export class SyncularAdmin {
|
|
|
926
926
|
? { connected: connectedSinceMs !== undefined }
|
|
927
927
|
: {}),
|
|
928
928
|
...(connectedSinceMs !== undefined ? { connectedSinceMs } : {}),
|
|
929
|
+
...(record.lastRoundAtMs !== undefined
|
|
930
|
+
? { lastRoundAtMs: record.lastRoundAtMs }
|
|
931
|
+
: {}),
|
|
932
|
+
...(record.lastErrorCode !== undefined
|
|
933
|
+
? { lastErrorCode: record.lastErrorCode }
|
|
934
|
+
: {}),
|
|
935
|
+
...(record.consecutiveFailures !== undefined
|
|
936
|
+
? { consecutiveFailures: record.consecutiveFailures }
|
|
937
|
+
: {}),
|
|
929
938
|
};
|
|
930
939
|
};
|
|
931
940
|
}
|
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, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
45
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, ClientRecordWriteOptions, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, 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>;
|
|
@@ -123,7 +123,8 @@ export declare class D1ServerStorage implements ServerStorage {
|
|
|
123
123
|
scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
|
|
124
124
|
scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
125
125
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
126
|
-
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
126
|
+
putClientRecord(partition: string, record: ClientRecord, options?: ClientRecordWriteOptions): Promise<void>;
|
|
127
|
+
recordClientRoundFailure(partition: string, clientId: string, actorId: string, errorCode: string, atMs: number): Promise<void>;
|
|
127
128
|
advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
|
|
128
129
|
updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
|
|
129
130
|
getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
|
package/dist/d1-storage.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
var _a;
|
|
2
2
|
import { validateCommitPruneQuery } from './prune.js';
|
|
3
3
|
import { StorageQueryError } from './storage-errors.js';
|
|
4
|
-
import { PARTITION_TOUCH_INTERVAL_MS } from './storage.js';
|
|
4
|
+
import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS } from './storage.js';
|
|
5
5
|
/**
|
|
6
6
|
* Cloudflare D1 server storage for Workers deployments.
|
|
7
7
|
*
|
|
@@ -46,9 +46,9 @@ import { PARTITION_TOUCH_INTERVAL_MS } from './storage.js';
|
|
|
46
46
|
import { decodeRow } from '@syncular/core';
|
|
47
47
|
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
48
48
|
import { syncError } from './errors.js';
|
|
49
|
-
import { assertAppendOnlyMigration, assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL, 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, SQLITE_TABLE_INDEXES_SQL, sqliteIndexSignatures, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
|
|
49
|
+
import { assertAppendOnlyMigration, assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL, 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, serverIndexDdl, SQLITE_TABLE_INDEXES_SQL, sqliteIndexSignatures, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
|
|
50
50
|
import { matchesEffective } from './scopes.js';
|
|
51
|
-
import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL, eraseScopeValueHistoryParams, LIST_SCOPE_VALUE_ROWS_SQLITE_SQL, serializePushResult, sqliteDdlStatements, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
|
|
51
|
+
import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL, eraseScopeValueHistoryParams, LIST_SCOPE_VALUE_ROWS_SQLITE_SQL, serializePushResult, sqliteDdlStatements, SYNC_CLIENT_ROUND_COLUMNS, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
|
|
52
52
|
import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
|
|
53
53
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
54
54
|
function toStoredReaction(record) {
|
|
@@ -617,6 +617,11 @@ export class D1ServerStorage {
|
|
|
617
617
|
if (!results.some((column) => column.name === 'wire_version')) {
|
|
618
618
|
await this.#db.exec('ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1;');
|
|
619
619
|
}
|
|
620
|
+
for (const [name, definition] of Object.entries(SYNC_CLIENT_ROUND_COLUMNS)) {
|
|
621
|
+
if (!results.some((column) => column.name === name)) {
|
|
622
|
+
await this.#db.exec(`ALTER TABLE sync_clients ADD COLUMN ${name} ${definition};`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
620
625
|
}
|
|
621
626
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
622
627
|
table(name) {
|
|
@@ -713,6 +718,30 @@ export class D1ServerStorage {
|
|
|
713
718
|
primaryKeyPosition: column.pk,
|
|
714
719
|
})), 'sqlite');
|
|
715
720
|
}
|
|
721
|
+
// A database from before the round-state columns gains them in place.
|
|
722
|
+
statementsExecuted++;
|
|
723
|
+
const clientColumns = await this.#db
|
|
724
|
+
.prepare('PRAGMA table_info("sync_clients")')
|
|
725
|
+
.all();
|
|
726
|
+
const missingRoundColumns = Object.entries(SYNC_CLIENT_ROUND_COLUMNS).filter(([name]) => !clientColumns.results.some((column) => column.name === name));
|
|
727
|
+
if (missingRoundColumns.length > 0) {
|
|
728
|
+
await batch(missingRoundColumns.map(([name, definition]) => this.#db.prepare(`ALTER TABLE sync_clients ADD COLUMN ${name} ${definition}`)));
|
|
729
|
+
}
|
|
730
|
+
// Server-only indexes reconcile at every open of a database already at
|
|
731
|
+
// this version: they never reach a client, so no version bump is needed.
|
|
732
|
+
const indexes = new Map();
|
|
733
|
+
for (const table of tables) {
|
|
734
|
+
statementsExecuted++;
|
|
735
|
+
const { results } = await this.#db
|
|
736
|
+
.prepare(SQLITE_TABLE_INDEXES_SQL)
|
|
737
|
+
.bind(table.name)
|
|
738
|
+
.all();
|
|
739
|
+
indexes.set(table.name, new Map(Object.entries(sqliteIndexSignatures(results))));
|
|
740
|
+
}
|
|
741
|
+
const reconcile = serverIndexDdl(schema, indexes);
|
|
742
|
+
if (reconcile.length > 0) {
|
|
743
|
+
await batch(reconcile.map((sql) => this.#db.prepare(sql)));
|
|
744
|
+
}
|
|
716
745
|
this.#tables = schema.tables;
|
|
717
746
|
this.#schemaVersion = schema.version;
|
|
718
747
|
return { complete: true, statementsExecuted };
|
|
@@ -816,12 +845,11 @@ export class D1ServerStorage {
|
|
|
816
845
|
const columns = await this.#db
|
|
817
846
|
.prepare('PRAGMA table_info("sync_clients")')
|
|
818
847
|
.all();
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
: [])))
|
|
848
|
+
const missingColumns = Object.entries({
|
|
849
|
+
wire_version: 'INTEGER NOT NULL DEFAULT 1',
|
|
850
|
+
...SYNC_CLIENT_ROUND_COLUMNS,
|
|
851
|
+
}).filter(([name]) => !columns.results.some((column) => column.name === name));
|
|
852
|
+
if (!(await save({ ...state, phase: 'prepare', offset: 0 }, missingColumns.map(([name, definition]) => this.#db.prepare(`ALTER TABLE sync_clients ADD COLUMN ${name} ${definition}`)))))
|
|
825
853
|
break;
|
|
826
854
|
}
|
|
827
855
|
else if (state.phase === 'prepare') {
|
|
@@ -1562,7 +1590,7 @@ export class D1ServerStorage {
|
|
|
1562
1590
|
}
|
|
1563
1591
|
async getClientRecord(partition, clientId) {
|
|
1564
1592
|
const record = await this.#db
|
|
1565
|
-
.prepare('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
|
|
1593
|
+
.prepare('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms, last_error_code, consecutive_failures FROM sync_clients WHERE partition=? AND client_id=?')
|
|
1566
1594
|
.bind(partition, clientId)
|
|
1567
1595
|
.first();
|
|
1568
1596
|
if (record === null)
|
|
@@ -1574,21 +1602,37 @@ export class D1ServerStorage {
|
|
|
1574
1602
|
cursor: record.cursor,
|
|
1575
1603
|
updatedAtMs: record.updated_at_ms,
|
|
1576
1604
|
subscriptions: JSON.parse(record.subscriptions),
|
|
1605
|
+
...clientRoundState(record.last_round_at_ms, record.last_error_code, record.consecutive_failures),
|
|
1577
1606
|
};
|
|
1578
1607
|
}
|
|
1579
|
-
async putClientRecord(partition, record) {
|
|
1608
|
+
async putClientRecord(partition, record, options) {
|
|
1580
1609
|
// Update the existing record in place, so the table keeps one row per
|
|
1581
1610
|
// (partition, client_id). Every non-key field is written from the new
|
|
1582
1611
|
// record; a delete-and-insert would drop and recreate the row instead.
|
|
1612
|
+
// `keepRoundOutcome` leaves the round-state columns alone (§1.5, §4.5).
|
|
1583
1613
|
await this.#db
|
|
1584
|
-
.prepare(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (
|
|
1614
|
+
.prepare(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)
|
|
1585
1615
|
ON CONFLICT(partition, client_id) DO UPDATE SET
|
|
1586
1616
|
actor_id=excluded.actor_id,
|
|
1587
1617
|
wire_version=excluded.wire_version,
|
|
1588
1618
|
cursor=excluded.cursor,
|
|
1589
1619
|
subscriptions=excluded.subscriptions,
|
|
1590
|
-
updated_at_ms=excluded.updated_at_ms
|
|
1591
|
-
|
|
1620
|
+
updated_at_ms=excluded.updated_at_ms,
|
|
1621
|
+
last_round_at_ms=excluded.last_round_at_ms${options?.keepRoundOutcome === true
|
|
1622
|
+
? ''
|
|
1623
|
+
: `,
|
|
1624
|
+
last_error_code=NULL,
|
|
1625
|
+
consecutive_failures=0`}`)
|
|
1626
|
+
.bind(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs, record.updatedAtMs)
|
|
1627
|
+
.run();
|
|
1628
|
+
}
|
|
1629
|
+
async recordClientRoundFailure(partition, clientId, actorId, errorCode, atMs) {
|
|
1630
|
+
await this.#db
|
|
1631
|
+
.prepare(`UPDATE sync_clients
|
|
1632
|
+
SET last_round_at_ms=?, last_error_code=?,
|
|
1633
|
+
consecutive_failures=consecutive_failures+1
|
|
1634
|
+
WHERE partition=? AND client_id=? AND actor_id=?`)
|
|
1635
|
+
.bind(atMs, errorCode, partition, clientId, actorId)
|
|
1592
1636
|
.run();
|
|
1593
1637
|
}
|
|
1594
1638
|
async advanceClientCursor(partition, clientId, actorId, logEpoch, cursor, updatedAtMs) {
|
|
@@ -1686,7 +1730,7 @@ export class D1ServerStorage {
|
|
|
1686
1730
|
// -- admin/console read surface --------------------------------------------
|
|
1687
1731
|
async listClientRecords(partition) {
|
|
1688
1732
|
const { results } = await this.#db
|
|
1689
|
-
.prepare('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
|
|
1733
|
+
.prepare('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms, last_error_code, consecutive_failures FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
|
|
1690
1734
|
.bind(partition)
|
|
1691
1735
|
.all();
|
|
1692
1736
|
return results.map((record) => ({
|
|
@@ -1696,6 +1740,7 @@ export class D1ServerStorage {
|
|
|
1696
1740
|
cursor: record.cursor,
|
|
1697
1741
|
updatedAtMs: record.updated_at_ms,
|
|
1698
1742
|
subscriptions: JSON.parse(record.subscriptions),
|
|
1743
|
+
...clientRoundState(record.last_round_at_ms, record.last_error_code, record.consecutive_failures),
|
|
1699
1744
|
}));
|
|
1700
1745
|
}
|
|
1701
1746
|
async listCommitMetadata(partition, query) {
|
package/dist/handler.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { DecodeError, decodeMessage, } from '@syncular/core';
|
|
13
13
|
import { clockOf, limitsOf, REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
|
|
14
|
-
import { SyncError, syncError } from './errors.js';
|
|
14
|
+
import { reportError, SyncError, syncError } from './errors.js';
|
|
15
15
|
import { emitEvent, } from './events.js';
|
|
16
16
|
import { END_FRAME_BYTES, encodeResponseFrame, responseEnvelopeHeader, } from './frame-bytes.js';
|
|
17
17
|
import { ACCEPT_EXTERNAL_ROWS, ACCEPT_INLINE_ROWS, clampPullLimits, prepareSections, preparedSection, } from './pull.js';
|
|
@@ -106,10 +106,13 @@ async function planRequest(request, ctx, schema, registry) {
|
|
|
106
106
|
throw syncError('sync.invalid_request', 'missing REQ_HEADER');
|
|
107
107
|
}
|
|
108
108
|
const pushes = [];
|
|
109
|
+
let roundFailure;
|
|
109
110
|
let pull;
|
|
110
111
|
const subFrames = [];
|
|
111
112
|
for (const frame of request.frames) {
|
|
112
|
-
if (frame.type === '
|
|
113
|
+
if (frame.type === 'ROUND_FAILURE')
|
|
114
|
+
roundFailure = frame;
|
|
115
|
+
else if (frame.type === 'PUSH_COMMIT')
|
|
113
116
|
pushes.push(frame);
|
|
114
117
|
else if (frame.type === 'PULL_HEADER')
|
|
115
118
|
pull = frame;
|
|
@@ -130,6 +133,7 @@ async function planRequest(request, ctx, schema, registry) {
|
|
|
130
133
|
logEpoch: registry.logEpoch,
|
|
131
134
|
epochReset,
|
|
132
135
|
header,
|
|
136
|
+
roundFailure,
|
|
133
137
|
pushes,
|
|
134
138
|
pull,
|
|
135
139
|
subscriptions: [],
|
|
@@ -144,6 +148,7 @@ async function planRequest(request, ctx, schema, registry) {
|
|
|
144
148
|
logEpoch: registry.logEpoch,
|
|
145
149
|
epochReset: true,
|
|
146
150
|
header,
|
|
151
|
+
roundFailure,
|
|
147
152
|
pushes: [],
|
|
148
153
|
pull: undefined,
|
|
149
154
|
subscriptions: [],
|
|
@@ -212,6 +217,7 @@ async function planRequest(request, ctx, schema, registry) {
|
|
|
212
217
|
logEpoch: registry.logEpoch,
|
|
213
218
|
epochReset: false,
|
|
214
219
|
header,
|
|
220
|
+
roundFailure,
|
|
215
221
|
pushes,
|
|
216
222
|
pull,
|
|
217
223
|
subscriptions,
|
|
@@ -483,7 +489,11 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
483
489
|
cursor,
|
|
484
490
|
updatedAtMs: clockOf(ctx)(),
|
|
485
491
|
subscriptions,
|
|
486
|
-
}
|
|
492
|
+
},
|
|
493
|
+
// §4.5: a round that carried a `ROUND_FAILURE` report completes without
|
|
494
|
+
// clearing the reported code and streak; the next request without the
|
|
495
|
+
// frame clears them as a plain completed round does.
|
|
496
|
+
plan.roundFailure !== undefined ? { keepRoundOutcome: true } : undefined);
|
|
487
497
|
if (ctx.realtime?.notifySubscriptions !== undefined &&
|
|
488
498
|
JSON.stringify((previous?.subscriptions ?? []).map((s) => [s.id, s.table, s.scopes])) !== JSON.stringify(subscriptions.map((s) => [s.id, s.table, s.scopes]))) {
|
|
489
499
|
await ctx.realtime.notifySubscriptions(ctx.partition, plan.header.clientId);
|
|
@@ -495,6 +505,7 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
495
505
|
report.outcome = 'error';
|
|
496
506
|
report.errorCode = error.code;
|
|
497
507
|
}
|
|
508
|
+
await recordRoundFailure(ctx, plan.header.clientId, error);
|
|
498
509
|
// §1.6: in-band ERROR, then END, nothing else.
|
|
499
510
|
yield encodeResponseFrame({
|
|
500
511
|
type: 'ERROR',
|
|
@@ -519,8 +530,15 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
519
530
|
*/
|
|
520
531
|
export async function createSyncResponseStream(bytes, ctx) {
|
|
521
532
|
const sink = ctx.events;
|
|
522
|
-
if (sink === undefined)
|
|
523
|
-
|
|
533
|
+
if (sink === undefined) {
|
|
534
|
+
try {
|
|
535
|
+
return await createStreamCore(bytes, ctx, undefined);
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
await recordRoundFailure(ctx, requestClientId(bytes), error);
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
524
542
|
// Instrumented path: measure with the ctx clock, count bytes at the
|
|
525
543
|
// seam, emit exactly one `request.handled` per request. Every event of
|
|
526
544
|
// this request carries one `requestId`.
|
|
@@ -535,6 +553,7 @@ export async function createSyncResponseStream(bytes, ctx) {
|
|
|
535
553
|
return await createStreamCore(bytes, requestCtx, events, startedAtMs);
|
|
536
554
|
}
|
|
537
555
|
catch (error) {
|
|
556
|
+
const clientId = requestClientId(bytes);
|
|
538
557
|
emitEvent(events, {
|
|
539
558
|
type: 'request.handled',
|
|
540
559
|
kind: 'sync',
|
|
@@ -545,11 +564,56 @@ export async function createSyncResponseStream(bytes, ctx) {
|
|
|
545
564
|
bytesIn: bytes.length,
|
|
546
565
|
bytesOut: 0,
|
|
547
566
|
outcome: 'rejected',
|
|
567
|
+
...(clientId !== undefined ? { clientId } : {}),
|
|
548
568
|
errorCode: error instanceof SyncError ? error.code : 'internal',
|
|
549
569
|
pushCommits: 0,
|
|
550
570
|
pulled: false,
|
|
551
571
|
subscriptions: 0,
|
|
552
572
|
});
|
|
573
|
+
await recordRoundFailure(ctx, clientId, error);
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/** The `REQ_HEADER` clientId of a request that failed before planning. */
|
|
578
|
+
function requestClientId(bytes) {
|
|
579
|
+
try {
|
|
580
|
+
const message = decodeMessage(bytes);
|
|
581
|
+
const header = message.frames[0];
|
|
582
|
+
return message.msgKind === 'request' && header?.type === 'REQ_HEADER'
|
|
583
|
+
? header.clientId
|
|
584
|
+
: undefined;
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
return undefined;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Record a failed round on the client's stored record so an operator can see
|
|
592
|
+
* a stuck client and why. A storage failure here is reported through
|
|
593
|
+
* `onError` and never replaces the round's own outcome.
|
|
594
|
+
*/
|
|
595
|
+
async function recordRoundFailure(ctx, clientId, error) {
|
|
596
|
+
await recordRoundFailureCode(ctx, clientId, error instanceof SyncError ? error.code : 'sync.internal_error');
|
|
597
|
+
}
|
|
598
|
+
/** As `recordRoundFailure`, for a code that is not a thrown catalog error
|
|
599
|
+
* (a `ROUND_FAILURE` report, §1.5). */
|
|
600
|
+
async function recordRoundFailureCode(ctx, clientId, code) {
|
|
601
|
+
if (clientId === undefined)
|
|
602
|
+
return;
|
|
603
|
+
try {
|
|
604
|
+
await ctx.storage.recordClientRoundFailure(ctx.partition, clientId, ctx.actorId, code, clockOf(ctx)());
|
|
605
|
+
}
|
|
606
|
+
catch (failure) {
|
|
607
|
+
reportError(failure, ctx.onError, 'sync');
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/** Record a failure thrown while the response streams (after planning). */
|
|
611
|
+
async function* recordedStream(stream, ctx, clientId) {
|
|
612
|
+
try {
|
|
613
|
+
yield* stream;
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
await recordRoundFailure(ctx, clientId, error);
|
|
553
617
|
throw error;
|
|
554
618
|
}
|
|
555
619
|
}
|
|
@@ -599,6 +663,12 @@ async function createStreamCore(bytes, ctx, events, startedAtMs = 0) {
|
|
|
599
663
|
candidate.version === header.schemaVersion);
|
|
600
664
|
const served = selected ?? schema;
|
|
601
665
|
const plan = await planRequest(request, ctx, served, registry);
|
|
666
|
+
// §1.5/§4.5: record the reported round failure on the existing record
|
|
667
|
+
// before the round runs (it never creates one, never touches another
|
|
668
|
+
// actor's record). The completion of this round leaves it in place.
|
|
669
|
+
if (plan.roundFailure !== undefined) {
|
|
670
|
+
await recordRoundFailureCode(ctx, plan.header.clientId, plan.roundFailure.code);
|
|
671
|
+
}
|
|
602
672
|
const report = { outcome: 'ok' };
|
|
603
673
|
// RFC 0007 read-verify-refuse for the streamed read path: the generator
|
|
604
674
|
// builds the whole response (its data reads) before yielding the first
|
|
@@ -623,9 +693,10 @@ async function createStreamCore(bytes, ctx, events, startedAtMs = 0) {
|
|
|
623
693
|
for (const chunk of buffered)
|
|
624
694
|
yield chunk;
|
|
625
695
|
};
|
|
626
|
-
if (events === undefined)
|
|
627
|
-
return verified();
|
|
628
|
-
|
|
696
|
+
if (events === undefined) {
|
|
697
|
+
return recordedStream(verified(), ctx, plan.header.clientId);
|
|
698
|
+
}
|
|
699
|
+
return recordedStream(instrumentedStream(verified(), ctx, events, plan, report, bytes.length, startedAtMs), ctx, plan.header.clientId);
|
|
629
700
|
}
|
|
630
701
|
/** Counts response bytes and emits `request.handled` when the stream
|
|
631
702
|
* finishes — including a thrown host failure mid-stream (fail loud). */
|
|
@@ -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, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCheckpoint, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
42
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, ClientRecordWriteOptions, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCheckpoint, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
43
43
|
/**
|
|
44
44
|
* Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
|
|
45
45
|
*
|
|
@@ -69,7 +69,7 @@ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDec
|
|
|
69
69
|
* set, §5.9.5) as an index range, never a scan. `postgres-explain
|
|
70
70
|
* .test.ts` asserts an `Index` node here too.
|
|
71
71
|
*/
|
|
72
|
-
export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required BOOLEAN NOT NULL DEFAULT FALSE,\n last_authenticated_at_ms BIGINT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE INDEX IF NOT EXISTS sync_row_scopes_by_row\n ON sync_row_scopes(partition, tbl, row_id);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n writer_version BIGINT,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result JSONB NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(\n partition TEXT NOT NULL, name TEXT NOT NULL,\n schema_version INTEGER NOT NULL,\n state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),\n watermark BIGINT NOT NULL DEFAULT 0,\n owner_epoch BIGINT NOT NULL DEFAULT 0,\n observed_rows BIGINT NOT NULL DEFAULT 0,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, name)\n);\nCREATE TABLE IF NOT EXISTS sync_writer_fence(\n partition TEXT PRIMARY KEY,\n required_writer_version INTEGER NOT NULL\n);\n";
|
|
72
|
+
export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required BOOLEAN NOT NULL DEFAULT FALSE,\n last_authenticated_at_ms BIGINT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE INDEX IF NOT EXISTS sync_row_scopes_by_row\n ON sync_row_scopes(partition, tbl, row_id);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n writer_version BIGINT,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result JSONB NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n last_round_at_ms BIGINT, last_error_code TEXT,\n consecutive_failures INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY(partition, client_id)\n);\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;\nALTER TABLE sync_clients ADD COLUMN IF NOT EXISTS last_round_at_ms BIGINT;\nALTER TABLE sync_clients ADD COLUMN IF NOT EXISTS last_error_code TEXT;\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS consecutive_failures INTEGER NOT NULL DEFAULT 0;\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(\n partition TEXT NOT NULL, name TEXT NOT NULL,\n schema_version INTEGER NOT NULL,\n state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),\n watermark BIGINT NOT NULL DEFAULT 0,\n owner_epoch BIGINT NOT NULL DEFAULT 0,\n observed_rows BIGINT NOT NULL DEFAULT 0,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, name)\n);\nCREATE TABLE IF NOT EXISTS sync_writer_fence(\n partition TEXT PRIMARY KEY,\n required_writer_version INTEGER NOT NULL\n);\n";
|
|
73
73
|
export declare class PostgresServerStorage implements ServerStorage {
|
|
74
74
|
#private;
|
|
75
75
|
constructor(exec: PgExecutor);
|
|
@@ -129,7 +129,8 @@ export declare class PostgresServerStorage implements ServerStorage {
|
|
|
129
129
|
scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
|
|
130
130
|
scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
131
131
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
132
|
-
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
132
|
+
putClientRecord(partition: string, record: ClientRecord, options?: ClientRecordWriteOptions): Promise<void>;
|
|
133
|
+
recordClientRoundFailure(partition: string, clientId: string, actorId: string, errorCode: string, atMs: number): Promise<void>;
|
|
133
134
|
advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
|
|
134
135
|
updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
|
|
135
136
|
getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
|