@syncular/server 0.15.21 → 0.15.23
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 +39 -0
- package/dist/d1-storage.d.ts +2 -1
- package/dist/d1-storage.js +51 -1
- package/dist/events.d.ts +10 -0
- package/dist/handler.js +21 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/postgres-storage.d.ts +2 -1
- package/dist/postgres-storage.js +29 -1
- package/dist/push.d.ts +13 -0
- package/dist/push.js +54 -14
- package/dist/relational-rows.d.ts +11 -1
- package/dist/relational-rows.js +29 -0
- package/dist/schema.js +3 -0
- package/dist/seed.d.ts +27 -2
- package/dist/seed.js +71 -7
- package/dist/sqlite-dialect.js +12 -0
- package/dist/sqlite-storage.d.ts +2 -1
- package/dist/sqlite-storage.js +17 -1
- package/dist/storage-errors.d.ts +11 -0
- package/dist/storage-errors.js +19 -0
- package/dist/storage-query.d.ts +6 -0
- package/dist/storage-query.js +30 -0
- package/dist/storage.d.ts +36 -1
- package/package.json +2 -2
- package/src/d1-storage.ts +81 -0
- package/src/events.ts +10 -0
- package/src/handler.ts +21 -5
- package/src/index.ts +4 -0
- package/src/postgres-storage.ts +61 -0
- package/src/push.ts +91 -17
- package/src/relational-rows.ts +45 -1
- package/src/schema.ts +5 -0
- package/src/seed.ts +95 -10
- package/src/sqlite-dialect.ts +14 -0
- package/src/sqlite-storage.ts +38 -0
- package/src/storage-errors.ts +36 -0
- package/src/storage-query.ts +48 -0
- package/src/storage.ts +41 -1
package/dist/seed.js
CHANGED
|
@@ -11,7 +11,36 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { decodeMessage, encodeMessage, encodeRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
|
|
13
13
|
import { SyncError } from './errors.js';
|
|
14
|
+
import { composeEvents } from './events-ring.js';
|
|
14
15
|
import { handleSyncRequest } from './handler.js';
|
|
16
|
+
/** Structured terminal failure from the real push path used by a seed. */
|
|
17
|
+
export class SeedMutationError extends Error {
|
|
18
|
+
name = 'SeedMutationError';
|
|
19
|
+
clientId;
|
|
20
|
+
clientCommitId;
|
|
21
|
+
opIndex;
|
|
22
|
+
/** Exact protocol or host-validator rejection code. */
|
|
23
|
+
code;
|
|
24
|
+
replayed;
|
|
25
|
+
retryable;
|
|
26
|
+
recordedAtMs;
|
|
27
|
+
cacheIdentity;
|
|
28
|
+
constructor(options) {
|
|
29
|
+
super(options.message);
|
|
30
|
+
this.clientId = options.clientId;
|
|
31
|
+
this.clientCommitId = options.clientCommitId;
|
|
32
|
+
this.opIndex = options.opIndex;
|
|
33
|
+
this.code = options.code;
|
|
34
|
+
this.replayed = options.replayed;
|
|
35
|
+
this.retryable = options.retryable;
|
|
36
|
+
if (options.recordedAtMs !== undefined) {
|
|
37
|
+
this.recordedAtMs = options.recordedAtMs;
|
|
38
|
+
}
|
|
39
|
+
if (options.cacheIdentity !== undefined) {
|
|
40
|
+
this.cacheIdentity = options.cacheIdentity;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
15
44
|
const MAPPABLE_RE = /^_*[A-Za-z][A-Za-z0-9_]*$/;
|
|
16
45
|
/** Pinned §12 schema alias used by generated row types and every client host. */
|
|
17
46
|
function snakeToCamel(name) {
|
|
@@ -35,8 +64,8 @@ function snakeToCamel(name) {
|
|
|
35
64
|
}
|
|
36
65
|
/**
|
|
37
66
|
* Seed `mutations` into a partition through the real push path. Throws a
|
|
38
|
-
* `
|
|
39
|
-
* seed fails loud
|
|
67
|
+
* `SeedMutationError` when the push is rejected and `SyncError` for malformed
|
|
68
|
+
* helper input, so a broken seed fails loud instead of serving an empty store.
|
|
40
69
|
*/
|
|
41
70
|
export async function seedMutations(config, target, mutations) {
|
|
42
71
|
const clientId = target.clientId ?? 'seed';
|
|
@@ -88,11 +117,28 @@ export async function seedMutations(config, target, mutations) {
|
|
|
88
117
|
accept: 0b0011,
|
|
89
118
|
},
|
|
90
119
|
];
|
|
120
|
+
let terminalEvent;
|
|
121
|
+
const capture = {
|
|
122
|
+
emit(event) {
|
|
123
|
+
if ((event.type === 'push.rejected' || event.type === 'push.conflicted') &&
|
|
124
|
+
event.clientId === clientId &&
|
|
125
|
+
event.clientCommitId === clientCommitId) {
|
|
126
|
+
terminalEvent = event;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
91
130
|
const response = await handleSyncRequest(encodeMessage({
|
|
92
131
|
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
93
132
|
msgKind: 'request',
|
|
94
133
|
frames,
|
|
95
|
-
}), {
|
|
134
|
+
}), {
|
|
135
|
+
...config,
|
|
136
|
+
partition: target.partition,
|
|
137
|
+
actorId: target.actorId,
|
|
138
|
+
events: config.events === undefined
|
|
139
|
+
? capture
|
|
140
|
+
: composeEvents(config.events, capture),
|
|
141
|
+
});
|
|
96
142
|
// Fail loud: surface the first rejected/failed operation.
|
|
97
143
|
const message = decodeMessage(response);
|
|
98
144
|
const result = message.frames.find((frame) => frame.type === 'PUSH_RESULT' && frame.clientCommitId === clientCommitId);
|
|
@@ -101,9 +147,27 @@ export async function seedMutations(config, target, mutations) {
|
|
|
101
147
|
}
|
|
102
148
|
if (result.status === 'rejected') {
|
|
103
149
|
const failed = result.results.find((r) => r.status !== 'applied');
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
150
|
+
const code = failed?.code ?? 'sync.invalid_request';
|
|
151
|
+
const opIndex = failed?.opIndex ?? 0;
|
|
152
|
+
const retryable = failed?.status === 'error' ? failed.retryable : false;
|
|
153
|
+
const detail = failed === undefined
|
|
154
|
+
? ''
|
|
155
|
+
: ` (op ${opIndex}: ${code} — ${failed.message})`;
|
|
156
|
+
const replayed = terminalEvent?.replay ?? false;
|
|
157
|
+
throw new SeedMutationError({
|
|
158
|
+
clientId,
|
|
159
|
+
clientCommitId,
|
|
160
|
+
opIndex,
|
|
161
|
+
code,
|
|
162
|
+
replayed,
|
|
163
|
+
retryable,
|
|
164
|
+
message: `seedMutations: the seed commit was rejected${replayed ? ' (cached replay)' : ''}${detail}`,
|
|
165
|
+
...(terminalEvent?.recordedAtMs !== undefined
|
|
166
|
+
? { recordedAtMs: terminalEvent.recordedAtMs }
|
|
167
|
+
: {}),
|
|
168
|
+
...(terminalEvent?.cacheIdentity !== undefined
|
|
169
|
+
? { cacheIdentity: terminalEvent.cacheIdentity }
|
|
170
|
+
: {}),
|
|
171
|
+
});
|
|
108
172
|
}
|
|
109
173
|
}
|
package/dist/sqlite-dialect.js
CHANGED
|
@@ -93,6 +93,12 @@ export function serializePushResult(result) {
|
|
|
93
93
|
return JSON.stringify({
|
|
94
94
|
status: result.status,
|
|
95
95
|
...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
|
|
96
|
+
...(result.recordedAtMs !== undefined
|
|
97
|
+
? { recordedAtMs: result.recordedAtMs }
|
|
98
|
+
: {}),
|
|
99
|
+
...(result.cacheIdentity !== undefined
|
|
100
|
+
? { cacheIdentity: result.cacheIdentity }
|
|
101
|
+
: {}),
|
|
96
102
|
results: result.results.map((record) => {
|
|
97
103
|
if (record.status === 'conflict') {
|
|
98
104
|
return {
|
|
@@ -146,6 +152,12 @@ export function deserializePushResult(text) {
|
|
|
146
152
|
return {
|
|
147
153
|
status: parsed.status,
|
|
148
154
|
...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
|
|
155
|
+
...(parsed.recordedAtMs !== undefined
|
|
156
|
+
? { recordedAtMs: parsed.recordedAtMs }
|
|
157
|
+
: {}),
|
|
158
|
+
...(parsed.cacheIdentity !== undefined
|
|
159
|
+
? { cacheIdentity: parsed.cacheIdentity }
|
|
160
|
+
: {}),
|
|
149
161
|
results,
|
|
150
162
|
};
|
|
151
163
|
}
|
package/dist/sqlite-storage.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { Database } from 'bun:sqlite';
|
|
10
10
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
11
|
-
import type { ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
|
|
11
|
+
import type { ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
|
|
12
12
|
export declare class SqliteServerStorage implements ServerStorage {
|
|
13
13
|
#private;
|
|
14
14
|
readonly db: Database;
|
|
@@ -28,6 +28,7 @@ export declare class SqliteServerStorage implements ServerStorage {
|
|
|
28
28
|
getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
|
|
29
29
|
readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
|
|
30
30
|
scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
|
|
31
|
+
scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
31
32
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
32
33
|
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
33
34
|
listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { Database } from 'bun:sqlite';
|
|
10
10
|
import { syncError } from './errors.js';
|
|
11
|
-
import { commitWindowPageSql, deleteRowSql, dropTableDdl, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
11
|
+
import { commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
12
12
|
import { matchesEffective } from './scopes.js';
|
|
13
13
|
import { collectCommitWindowPage, deserializePushResult, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
|
|
14
14
|
import { isSqliteConstraintError, StorageConstraintError, } from './storage-errors.js';
|
|
15
|
+
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
15
16
|
class SqliteTransaction {
|
|
16
17
|
#storage;
|
|
17
18
|
#partition;
|
|
@@ -34,6 +35,10 @@ class SqliteTransaction {
|
|
|
34
35
|
this.#assertOpen();
|
|
35
36
|
return this.#storage.scanRows(this.#partition, query);
|
|
36
37
|
}
|
|
38
|
+
scanRowsByIndex(query) {
|
|
39
|
+
this.#assertOpen();
|
|
40
|
+
return this.#storage.scanRowsByIndex(this.#partition, query);
|
|
41
|
+
}
|
|
37
42
|
async lockPartitionForCommitValidation() {
|
|
38
43
|
this.#assertOpen();
|
|
39
44
|
// BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
|
|
@@ -339,6 +344,7 @@ export class SqliteServerStorage {
|
|
|
339
344
|
return commits;
|
|
340
345
|
}
|
|
341
346
|
async scanRows(partition, query) {
|
|
347
|
+
assertScopeIndexedScan(query);
|
|
342
348
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
343
349
|
const firstVariable = variables[0];
|
|
344
350
|
if (firstVariable === undefined)
|
|
@@ -377,6 +383,16 @@ export class SqliteServerStorage {
|
|
|
377
383
|
}
|
|
378
384
|
return rows;
|
|
379
385
|
}
|
|
386
|
+
async scanRowsByIndex(partition, query) {
|
|
387
|
+
const table = this.table(query.table);
|
|
388
|
+
const index = resolveIndexRowScan(table, query);
|
|
389
|
+
const statement = indexRowPageStatement(table, index, query.values, partition, query.afterRowId, query.limit, 'sqlite');
|
|
390
|
+
const params = statement.params;
|
|
391
|
+
const records = this.db
|
|
392
|
+
.query(statement.sql)
|
|
393
|
+
.all(...params);
|
|
394
|
+
return records.map(toStoredRow);
|
|
395
|
+
}
|
|
380
396
|
async getClientRecord(partition, clientId) {
|
|
381
397
|
const record = this.db
|
|
382
398
|
.query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
|
package/dist/storage-errors.d.ts
CHANGED
|
@@ -8,6 +8,17 @@ export declare class StorageConstraintError extends Error {
|
|
|
8
8
|
readonly opIndex: number | undefined;
|
|
9
9
|
constructor(cause: unknown, opIndex?: number);
|
|
10
10
|
}
|
|
11
|
+
/** Stable, privacy-safe failures for trusted server storage queries. */
|
|
12
|
+
export type StorageQueryErrorCode = 'sync.storage.scan_requires_scope' | 'sync.storage.index_not_found' | 'sync.storage.index_not_materialized' | 'sync.storage.index_value_count_mismatch' | 'sync.storage.invalid_limit';
|
|
13
|
+
/**
|
|
14
|
+
* Host-only query error. Messages never include identifiers, values, SQL,
|
|
15
|
+
* paths, or row data; callers branch on `code`, never message text.
|
|
16
|
+
*/
|
|
17
|
+
export declare class StorageQueryError extends Error {
|
|
18
|
+
readonly name = "StorageQueryError";
|
|
19
|
+
readonly code: StorageQueryErrorCode;
|
|
20
|
+
constructor(code: StorageQueryErrorCode);
|
|
21
|
+
}
|
|
11
22
|
/** SQLite primary/extended constraint result codes (`SQLITE_CONSTRAINT*`). */
|
|
12
23
|
export declare function isSqliteConstraintError(error: unknown): boolean;
|
|
13
24
|
/** PostgreSQL SQLSTATE class 23: integrity constraint violation. */
|
package/dist/storage-errors.js
CHANGED
|
@@ -11,6 +11,25 @@ export class StorageConstraintError extends Error {
|
|
|
11
11
|
this.opIndex = opIndex;
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
+
const STORAGE_QUERY_MESSAGES = {
|
|
15
|
+
'sync.storage.scan_requires_scope': 'scope-indexed row scans require at least one scope variable',
|
|
16
|
+
'sync.storage.index_not_found': 'trusted row lookup requires a declared relational index',
|
|
17
|
+
'sync.storage.index_not_materialized': 'trusted row lookup requires a materialized relational table',
|
|
18
|
+
'sync.storage.index_value_count_mismatch': 'trusted row lookup requires one exact value per index column',
|
|
19
|
+
'sync.storage.invalid_limit': 'trusted row lookup limit must be an integer from 1 through 1,000',
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Host-only query error. Messages never include identifiers, values, SQL,
|
|
23
|
+
* paths, or row data; callers branch on `code`, never message text.
|
|
24
|
+
*/
|
|
25
|
+
export class StorageQueryError extends Error {
|
|
26
|
+
name = 'StorageQueryError';
|
|
27
|
+
code;
|
|
28
|
+
constructor(code) {
|
|
29
|
+
super(STORAGE_QUERY_MESSAGES[code]);
|
|
30
|
+
this.code = code;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
14
33
|
function driverError(error) {
|
|
15
34
|
return typeof error === 'object' && error !== null
|
|
16
35
|
? error
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CompiledTable, IndexSchema } from './schema.js';
|
|
2
|
+
import type { IndexRowScanQuery, RowScanQuery } from './storage.js';
|
|
3
|
+
/** Fail loudly instead of making an unsupported unscoped scan look empty. */
|
|
4
|
+
export declare function assertScopeIndexedScan(query: RowScanQuery): void;
|
|
5
|
+
/** Validate and resolve one exact trusted-host relational index lookup. */
|
|
6
|
+
export declare function resolveIndexRowScan(table: CompiledTable, query: IndexRowScanQuery): IndexSchema;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { StorageQueryError } from './storage-errors.js';
|
|
2
|
+
/** Fail loudly instead of making an unsupported unscoped scan look empty. */
|
|
3
|
+
export function assertScopeIndexedScan(query) {
|
|
4
|
+
const scopeFilter = query.scopeFilter;
|
|
5
|
+
if (scopeFilter === undefined ||
|
|
6
|
+
scopeFilter === null ||
|
|
7
|
+
Object.keys(scopeFilter).length === 0) {
|
|
8
|
+
throw new StorageQueryError('sync.storage.scan_requires_scope');
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Validate and resolve one exact trusted-host relational index lookup. */
|
|
12
|
+
export function resolveIndexRowScan(table, query) {
|
|
13
|
+
if (!Number.isInteger(query.limit) ||
|
|
14
|
+
query.limit < 1 ||
|
|
15
|
+
query.limit > 1_000) {
|
|
16
|
+
throw new StorageQueryError('sync.storage.invalid_limit');
|
|
17
|
+
}
|
|
18
|
+
if (!table.materialize) {
|
|
19
|
+
throw new StorageQueryError('sync.storage.index_not_materialized');
|
|
20
|
+
}
|
|
21
|
+
const index = table.indexes.find((candidate) => candidate.name === query.index);
|
|
22
|
+
if (index === undefined) {
|
|
23
|
+
throw new StorageQueryError('sync.storage.index_not_found');
|
|
24
|
+
}
|
|
25
|
+
if (!Array.isArray(query.values) ||
|
|
26
|
+
query.values.length !== index.columns.length) {
|
|
27
|
+
throw new StorageQueryError('sync.storage.index_value_count_mismatch');
|
|
28
|
+
}
|
|
29
|
+
return index;
|
|
30
|
+
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* The interface is async throughout so a Postgres implementation slots in
|
|
17
17
|
* without touching the core. All methods are partition-local (§2.1).
|
|
18
18
|
*/
|
|
19
|
-
import type { PushOperationResult, ScopeMap } from '@syncular/core';
|
|
19
|
+
import type { PushOperationResult, RowValue, ScopeMap } from '@syncular/core';
|
|
20
20
|
import type { CompiledSchema } from './schema.js';
|
|
21
21
|
/** The current stored state of a synced row. */
|
|
22
22
|
export interface StoredRow {
|
|
@@ -64,6 +64,10 @@ export interface StoredPushResult {
|
|
|
64
64
|
readonly status: 'applied' | 'rejected';
|
|
65
65
|
/** Present iff `status` is `applied`. */
|
|
66
66
|
readonly commitSeq?: number;
|
|
67
|
+
/** Host clock when this terminal idempotency outcome was first recorded. */
|
|
68
|
+
readonly recordedAtMs?: number;
|
|
69
|
+
/** Privacy-safe identity used to distinguish this stored outcome from a race. */
|
|
70
|
+
readonly cacheIdentity?: string;
|
|
67
71
|
readonly results: readonly PushOperationResult[];
|
|
68
72
|
}
|
|
69
73
|
export interface ClientSubscription {
|
|
@@ -102,6 +106,25 @@ export interface RowScanQuery {
|
|
|
102
106
|
readonly afterRowId: string | null;
|
|
103
107
|
readonly limit: number;
|
|
104
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Exact server-host lookup through one declared relational index.
|
|
111
|
+
*
|
|
112
|
+
* This is deliberately NOT a Syncular scope or client query. It is available
|
|
113
|
+
* only to trusted server code that already owns a `ServerStorage` or
|
|
114
|
+
* `StorageTransaction` capability. Every declared index column must have one
|
|
115
|
+
* exact value, so adapters can keep the lookup bounded and deterministic.
|
|
116
|
+
*/
|
|
117
|
+
export interface IndexRowScanQuery {
|
|
118
|
+
readonly table: string;
|
|
119
|
+
/** `TableSchema.indexes[].name`; never exposed as a client subscription. */
|
|
120
|
+
readonly index: string;
|
|
121
|
+
/** Exact values in the index declaration's column order. */
|
|
122
|
+
readonly values: readonly RowValue[];
|
|
123
|
+
/** Resume after this rowId (exclusive); `null` = start of the match set. */
|
|
124
|
+
readonly afterRowId?: string | null;
|
|
125
|
+
/** Integer from 1 through 1,000. */
|
|
126
|
+
readonly limit: number;
|
|
127
|
+
}
|
|
105
128
|
export interface ClientCursorInfo {
|
|
106
129
|
readonly clientId: string;
|
|
107
130
|
readonly cursor: number;
|
|
@@ -156,6 +179,12 @@ export interface StorageTransaction {
|
|
|
156
179
|
* semantics. A custom backend may omit it until `commitValidator` is used.
|
|
157
180
|
*/
|
|
158
181
|
scanRows?(query: RowScanQuery): Promise<StoredRow[]>;
|
|
182
|
+
/**
|
|
183
|
+
* Optional additive capability for trusted authoritative commands. In-tree
|
|
184
|
+
* SQLite/PostgreSQL/D1 adapters implement it with transaction-local
|
|
185
|
+
* read-your-own-writes semantics. It is not reachable from SSP2 requests.
|
|
186
|
+
*/
|
|
187
|
+
scanRowsByIndex?(query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
159
188
|
/**
|
|
160
189
|
* Serialize candidate-state validation for this partition before any row
|
|
161
190
|
* read/write. Required at runtime when `commitValidator` is configured.
|
|
@@ -227,6 +256,12 @@ export interface ServerStorage {
|
|
|
227
256
|
readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
|
|
228
257
|
/** Scope-filtered snapshot scan, ordered by rowId (bootstrap paging). */
|
|
229
258
|
scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
|
|
259
|
+
/**
|
|
260
|
+
* Optional trusted-host exact lookup through a declared relational index.
|
|
261
|
+
* This capability is outside client scope/subscription authorization and
|
|
262
|
+
* MUST NOT be re-exported as a client-controlled endpoint.
|
|
263
|
+
*/
|
|
264
|
+
scanRowsByIndex?(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
230
265
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
231
266
|
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
232
267
|
/** Cursor records feeding the §4.6 retention watermark. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.23",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"!dist/**/*.test.d.ts"
|
|
54
54
|
],
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@syncular/core": "0.15.
|
|
56
|
+
"@syncular/core": "0.15.23"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/d1-storage.ts
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
commitWindowPageSql,
|
|
46
46
|
deleteRowSql,
|
|
47
47
|
dropTableDdl,
|
|
48
|
+
indexRowPageStatement,
|
|
48
49
|
layoutsOf,
|
|
49
50
|
migratePayload,
|
|
50
51
|
parseLayouts,
|
|
@@ -84,6 +85,7 @@ import type {
|
|
|
84
85
|
CommitMetadata,
|
|
85
86
|
CommitMetadataQuery,
|
|
86
87
|
CommitWindowQuery,
|
|
88
|
+
IndexRowScanQuery,
|
|
87
89
|
NewCommit,
|
|
88
90
|
RowScanQuery,
|
|
89
91
|
ScopeActivityQuery,
|
|
@@ -95,6 +97,7 @@ import type {
|
|
|
95
97
|
StoredRow,
|
|
96
98
|
} from './storage';
|
|
97
99
|
import { isD1ConstraintError, StorageConstraintError } from './storage-errors';
|
|
100
|
+
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query';
|
|
98
101
|
|
|
99
102
|
// -- The subset of the D1 API this storage uses (structural typing) ---------
|
|
100
103
|
// Declared locally so the package takes no `@cloudflare/workers-types`
|
|
@@ -193,6 +196,7 @@ class D1Transaction implements StorageTransaction {
|
|
|
193
196
|
|
|
194
197
|
async scanRows(query: RowScanQuery): Promise<StoredRow[]> {
|
|
195
198
|
this.#assertOpen();
|
|
199
|
+
assertScopeIndexedScan(query);
|
|
196
200
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
197
201
|
const firstVariable = variables[0];
|
|
198
202
|
if (firstVariable === undefined) return [];
|
|
@@ -254,6 +258,60 @@ class D1Transaction implements StorageTransaction {
|
|
|
254
258
|
.slice(0, query.limit);
|
|
255
259
|
}
|
|
256
260
|
|
|
261
|
+
async scanRowsByIndex(query: IndexRowScanQuery): Promise<StoredRow[]> {
|
|
262
|
+
this.#assertOpen();
|
|
263
|
+
const table = this.#resolveTable(query.table);
|
|
264
|
+
const index = resolveIndexRowScan(table, query);
|
|
265
|
+
const pendingForTable = [...this.#pending.entries()].filter(([key]) =>
|
|
266
|
+
key.startsWith(`${query.table}\u0000`),
|
|
267
|
+
);
|
|
268
|
+
const persistedLimit = query.limit + pendingForTable.length;
|
|
269
|
+
const statement = indexRowPageStatement(
|
|
270
|
+
table,
|
|
271
|
+
index,
|
|
272
|
+
query.values,
|
|
273
|
+
this.#partition,
|
|
274
|
+
query.afterRowId,
|
|
275
|
+
persistedLimit,
|
|
276
|
+
'sqlite',
|
|
277
|
+
);
|
|
278
|
+
const { results: records } = await this.#db
|
|
279
|
+
.prepare(statement.sql)
|
|
280
|
+
.bind(...statement.params)
|
|
281
|
+
.all<SqliteRowRecord>();
|
|
282
|
+
|
|
283
|
+
const rows = new Map(
|
|
284
|
+
records.map((record) => {
|
|
285
|
+
const row = toStoredRow(record);
|
|
286
|
+
return [row.rowId, row] as const;
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
const columnPositions = index.columns.map((column) => {
|
|
290
|
+
const position = table.columnIndex.get(column);
|
|
291
|
+
if (position === undefined) {
|
|
292
|
+
throw new Error('compiled relational index references unknown column');
|
|
293
|
+
}
|
|
294
|
+
return position;
|
|
295
|
+
});
|
|
296
|
+
const lowerBound = query.afterRowId ?? '';
|
|
297
|
+
for (const [key, pending] of pendingForTable) {
|
|
298
|
+
const rowId = key.slice(query.table.length + 1);
|
|
299
|
+
rows.delete(rowId);
|
|
300
|
+
if (pending.kind !== 'row' || rowId <= lowerBound) continue;
|
|
301
|
+
const values = decodeRow(table.columns, pending.row.payload);
|
|
302
|
+
const matches = columnPositions.every((position, valueIndex) =>
|
|
303
|
+
relationalValuesEqual(
|
|
304
|
+
values[position] ?? null,
|
|
305
|
+
query.values[valueIndex] ?? null,
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
if (matches) rows.set(rowId, pending.row);
|
|
309
|
+
}
|
|
310
|
+
return [...rows.values()]
|
|
311
|
+
.sort((left, right) => left.rowId.localeCompare(right.rowId))
|
|
312
|
+
.slice(0, query.limit);
|
|
313
|
+
}
|
|
314
|
+
|
|
257
315
|
async lockPartitionForCommitValidation(): Promise<void> {
|
|
258
316
|
this.#assertOpen();
|
|
259
317
|
if (!this.#commitValidationSerialized) {
|
|
@@ -859,6 +917,7 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
859
917
|
}
|
|
860
918
|
|
|
861
919
|
async scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]> {
|
|
920
|
+
assertScopeIndexedScan(query);
|
|
862
921
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
863
922
|
const firstVariable = variables[0];
|
|
864
923
|
if (firstVariable === undefined) return [];
|
|
@@ -904,6 +963,28 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
904
963
|
return rows;
|
|
905
964
|
}
|
|
906
965
|
|
|
966
|
+
async scanRowsByIndex(
|
|
967
|
+
partition: string,
|
|
968
|
+
query: IndexRowScanQuery,
|
|
969
|
+
): Promise<StoredRow[]> {
|
|
970
|
+
const table = this.table(query.table);
|
|
971
|
+
const index = resolveIndexRowScan(table, query);
|
|
972
|
+
const statement = indexRowPageStatement(
|
|
973
|
+
table,
|
|
974
|
+
index,
|
|
975
|
+
query.values,
|
|
976
|
+
partition,
|
|
977
|
+
query.afterRowId,
|
|
978
|
+
query.limit,
|
|
979
|
+
'sqlite',
|
|
980
|
+
);
|
|
981
|
+
const { results } = await this.#db
|
|
982
|
+
.prepare(statement.sql)
|
|
983
|
+
.bind(...statement.params)
|
|
984
|
+
.all<SqliteRowRecord>();
|
|
985
|
+
return results.map(toStoredRow);
|
|
986
|
+
}
|
|
987
|
+
|
|
907
988
|
async getClientRecord(
|
|
908
989
|
partition: string,
|
|
909
990
|
clientId: string,
|
package/src/events.ts
CHANGED
|
@@ -63,12 +63,22 @@ export interface PushRejectedEvent extends PushEventBase {
|
|
|
63
63
|
readonly type: 'push.rejected';
|
|
64
64
|
readonly code: string;
|
|
65
65
|
readonly opIndex: number;
|
|
66
|
+
/** True when the rejection was replayed from the idempotency cache. */
|
|
67
|
+
readonly replay: boolean;
|
|
68
|
+
/** Original host time for outcomes recorded by a metadata-aware server. */
|
|
69
|
+
readonly recordedAtMs?: number;
|
|
70
|
+
/** Privacy-safe identity of the stored outcome, when available. */
|
|
71
|
+
readonly cacheIdentity?: string;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
/** A push commit terminated by a version conflict (§6.2). */
|
|
69
75
|
export interface PushConflictedEvent extends PushEventBase {
|
|
70
76
|
readonly type: 'push.conflicted';
|
|
71
77
|
readonly opIndex: number;
|
|
78
|
+
/** True when the conflict was replayed from the idempotency cache. */
|
|
79
|
+
readonly replay: boolean;
|
|
80
|
+
readonly recordedAtMs?: number;
|
|
81
|
+
readonly cacheIdentity?: string;
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
/** One emitted segment within a pull subscription section. */
|
package/src/handler.ts
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
type SubscriptionPlan,
|
|
45
45
|
subscriptionSection,
|
|
46
46
|
} from './pull';
|
|
47
|
-
import {
|
|
47
|
+
import { type ProcessedPushCommit, processPushCommitWithTrace } from './push';
|
|
48
48
|
import type { CompiledSchema } from './schema';
|
|
49
49
|
import { compileSchema } from './schema';
|
|
50
50
|
import { computeEffective, type ResolvedScopes } from './scopes';
|
|
@@ -339,8 +339,9 @@ function emitPushEvent(
|
|
|
339
339
|
ctx: SyncRequestContext,
|
|
340
340
|
clientId: string,
|
|
341
341
|
push: PushCommitFrame,
|
|
342
|
-
|
|
342
|
+
processed: ProcessedPushCommit,
|
|
343
343
|
): void {
|
|
344
|
+
const { frame } = processed;
|
|
344
345
|
const base = {
|
|
345
346
|
atMs: clockOf(ctx)(),
|
|
346
347
|
partition: ctx.partition,
|
|
@@ -354,7 +355,7 @@ function emitPushEvent(
|
|
|
354
355
|
type: 'push.applied',
|
|
355
356
|
...base,
|
|
356
357
|
...(frame.commitSeq !== undefined ? { commitSeq: frame.commitSeq } : {}),
|
|
357
|
-
replay:
|
|
358
|
+
replay: processed.replayed,
|
|
358
359
|
});
|
|
359
360
|
return;
|
|
360
361
|
}
|
|
@@ -365,6 +366,13 @@ function emitPushEvent(
|
|
|
365
366
|
type: 'push.conflicted',
|
|
366
367
|
...base,
|
|
367
368
|
opIndex: record.opIndex,
|
|
369
|
+
replay: processed.replayed,
|
|
370
|
+
...(processed.recordedAtMs !== undefined
|
|
371
|
+
? { recordedAtMs: processed.recordedAtMs }
|
|
372
|
+
: {}),
|
|
373
|
+
...(processed.cacheIdentity !== undefined
|
|
374
|
+
? { cacheIdentity: processed.cacheIdentity }
|
|
375
|
+
: {}),
|
|
368
376
|
});
|
|
369
377
|
return;
|
|
370
378
|
}
|
|
@@ -376,6 +384,13 @@ function emitPushEvent(
|
|
|
376
384
|
? record.code
|
|
377
385
|
: 'sync.invalid_request',
|
|
378
386
|
opIndex: record?.opIndex ?? 0,
|
|
387
|
+
replay: processed.replayed,
|
|
388
|
+
...(processed.recordedAtMs !== undefined
|
|
389
|
+
? { recordedAtMs: processed.recordedAtMs }
|
|
390
|
+
: {}),
|
|
391
|
+
...(processed.cacheIdentity !== undefined
|
|
392
|
+
? { cacheIdentity: processed.cacheIdentity }
|
|
393
|
+
: {}),
|
|
379
394
|
});
|
|
380
395
|
}
|
|
381
396
|
|
|
@@ -423,15 +438,16 @@ async function* streamResponse(
|
|
|
423
438
|
try {
|
|
424
439
|
// Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
|
|
425
440
|
for (const push of plan.pushes) {
|
|
426
|
-
const
|
|
441
|
+
const processed = await processPushCommitWithTrace(
|
|
427
442
|
ctx,
|
|
428
443
|
schema,
|
|
429
444
|
plan.resolved,
|
|
430
445
|
plan.header.clientId,
|
|
431
446
|
push,
|
|
432
447
|
);
|
|
448
|
+
const { frame } = processed;
|
|
433
449
|
if (events !== undefined) {
|
|
434
|
-
emitPushEvent(events, ctx, plan.header.clientId, push,
|
|
450
|
+
emitPushEvent(events, ctx, plan.header.clientId, push, processed);
|
|
435
451
|
}
|
|
436
452
|
yield encodeResponseFrame(frame);
|
|
437
453
|
const details = pushResultDetailsFrame(frame);
|
package/src/index.ts
CHANGED
|
@@ -51,4 +51,8 @@ export * from './sqlite-lease-store';
|
|
|
51
51
|
export * from './sqlite-segment-store';
|
|
52
52
|
export * from './sqlite-storage';
|
|
53
53
|
export * from './storage';
|
|
54
|
+
export {
|
|
55
|
+
StorageQueryError,
|
|
56
|
+
type StorageQueryErrorCode,
|
|
57
|
+
} from './storage-errors';
|
|
54
58
|
export * from './validate';
|