@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/README.md
CHANGED
|
@@ -173,10 +173,49 @@ Whole-commit validation checks a client-proposed commit; it does not grant
|
|
|
173
173
|
authority. Privileged operations such as connecting facilities still belong in
|
|
174
174
|
explicit server-authoritative commands.
|
|
175
175
|
|
|
176
|
+
## Seed idempotency and safe revisioning
|
|
177
|
+
|
|
178
|
+
`seedMutations` uses the real push path and a stable `clientId`/`commitId`.
|
|
179
|
+
Both applied and rejected outcomes are terminal for that key. A rejected call
|
|
180
|
+
throws `SeedMutationError`, whose structured `code`, `opIndex`, `replayed`,
|
|
181
|
+
`recordedAtMs`, and `cacheIdentity` fields distinguish a fresh policy failure
|
|
182
|
+
from replay of an older cached rejection.
|
|
183
|
+
|
|
184
|
+
After correcting a development seed definition, advance an explicit seed
|
|
185
|
+
revision (`catalog-v1` to `catalog-v2`) and rerun it; do not delete the database
|
|
186
|
+
or unrelated rows. This does not apply to application commands. After an
|
|
187
|
+
unknown command outcome, reuse the original idempotency key because changing it
|
|
188
|
+
can execute the operation twice. The full inspection and recovery recipe is in
|
|
189
|
+
the public [server guide](https://syncular.dev/guide-server/#seeding-data).
|
|
190
|
+
|
|
176
191
|
The task-oriented [concurrency and conflict-correction guide](https://syncular.dev/guide-concurrency-correction/)
|
|
177
192
|
shows version projection, aggregate rollback, corrected replacement commits,
|
|
178
193
|
explicit acknowledgement, and restart-safe recovery UI together.
|
|
179
194
|
|
|
195
|
+
## Trusted relational-index lookups for authoritative commands
|
|
196
|
+
|
|
197
|
+
`scanRows` is a Syncular scope-index scan, never an unscoped administrative
|
|
198
|
+
query. Passing an empty or omitted `scopeFilter` throws the exported
|
|
199
|
+
`StorageQueryError` with `code: 'sync.storage.scan_requires_scope'` on
|
|
200
|
+
SQLite, PostgreSQL, and D1.
|
|
201
|
+
|
|
202
|
+
An authoritative command that needs an exact alternate lookup can instead use
|
|
203
|
+
the optional `storage.scanRowsByIndex(partition, query)` or transactional
|
|
204
|
+
`tx.scanRowsByIndex(query)` capability. The query names one declared
|
|
205
|
+
`TableSchema.indexes` entry, supplies one exact value per index column, uses an
|
|
206
|
+
exclusive `afterRowId`, and has a required limit from 1 through 1,000. All
|
|
207
|
+
shipped adapters implement it; transaction reads see staged writes and deletes.
|
|
208
|
+
It requires a materialized table.
|
|
209
|
+
|
|
210
|
+
This is a trusted `@syncular/server` storage capability, not SSP2: it creates no
|
|
211
|
+
scope variable, named-query obligation, subscription descriptor, or client
|
|
212
|
+
authority. Never expose table/index/value selection through a client-controlled
|
|
213
|
+
route. Custom adapters may omit the additive method; authoritative commands
|
|
214
|
+
must check for it and fail closed. See the public
|
|
215
|
+
[storage lookup guide](https://syncular.dev/server-storage/#choosing-the-right-row-lookup)
|
|
216
|
+
for a user-scoped key-grant table revoked through a Workspace index and for the
|
|
217
|
+
atomic reverse-index/queue fallback required by ordered or derived lookups.
|
|
218
|
+
|
|
180
219
|
## Structured events (the ops seam)
|
|
181
220
|
|
|
182
221
|
One optional interface, `SyncularServerEvents`, carries every
|
package/dist/d1-storage.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
2
|
-
import type { ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
|
|
2
|
+
import type { ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
|
|
3
3
|
export interface D1PreparedStatement {
|
|
4
4
|
bind(...values: unknown[]): D1PreparedStatement;
|
|
5
5
|
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
@@ -40,6 +40,7 @@ export declare class D1ServerStorage implements ServerStorage {
|
|
|
40
40
|
getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
|
|
41
41
|
readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
|
|
42
42
|
scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
|
|
43
|
+
scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
43
44
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
44
45
|
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
45
46
|
listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
|
package/dist/d1-storage.js
CHANGED
|
@@ -42,10 +42,11 @@ var _a;
|
|
|
42
42
|
*/
|
|
43
43
|
import { decodeRow } from '@syncular/core';
|
|
44
44
|
import { syncError } from './errors.js';
|
|
45
|
-
import { commitWindowPageSql, deleteRowSql, dropTableDdl, 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';
|
|
45
|
+
import { commitWindowPageSql, deleteRowSql, 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';
|
|
46
46
|
import { matchesEffective } from './scopes.js';
|
|
47
47
|
import { asUint8Array, collectCommitWindowPage, deserializePushResult, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
|
|
48
48
|
import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
|
|
49
|
+
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
49
50
|
function relationalValuesEqual(left, right) {
|
|
50
51
|
if (left instanceof Uint8Array && right instanceof Uint8Array) {
|
|
51
52
|
return (left.length === right.length &&
|
|
@@ -100,6 +101,7 @@ class D1Transaction {
|
|
|
100
101
|
}
|
|
101
102
|
async scanRows(query) {
|
|
102
103
|
this.#assertOpen();
|
|
104
|
+
assertScopeIndexedScan(query);
|
|
103
105
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
104
106
|
const firstVariable = variables[0];
|
|
105
107
|
if (firstVariable === undefined)
|
|
@@ -149,6 +151,43 @@ class D1Transaction {
|
|
|
149
151
|
.sort((left, right) => left.rowId.localeCompare(right.rowId))
|
|
150
152
|
.slice(0, query.limit);
|
|
151
153
|
}
|
|
154
|
+
async scanRowsByIndex(query) {
|
|
155
|
+
this.#assertOpen();
|
|
156
|
+
const table = this.#resolveTable(query.table);
|
|
157
|
+
const index = resolveIndexRowScan(table, query);
|
|
158
|
+
const pendingForTable = [...this.#pending.entries()].filter(([key]) => key.startsWith(`${query.table}\u0000`));
|
|
159
|
+
const persistedLimit = query.limit + pendingForTable.length;
|
|
160
|
+
const statement = indexRowPageStatement(table, index, query.values, this.#partition, query.afterRowId, persistedLimit, 'sqlite');
|
|
161
|
+
const { results: records } = await this.#db
|
|
162
|
+
.prepare(statement.sql)
|
|
163
|
+
.bind(...statement.params)
|
|
164
|
+
.all();
|
|
165
|
+
const rows = new Map(records.map((record) => {
|
|
166
|
+
const row = toStoredRow(record);
|
|
167
|
+
return [row.rowId, row];
|
|
168
|
+
}));
|
|
169
|
+
const columnPositions = index.columns.map((column) => {
|
|
170
|
+
const position = table.columnIndex.get(column);
|
|
171
|
+
if (position === undefined) {
|
|
172
|
+
throw new Error('compiled relational index references unknown column');
|
|
173
|
+
}
|
|
174
|
+
return position;
|
|
175
|
+
});
|
|
176
|
+
const lowerBound = query.afterRowId ?? '';
|
|
177
|
+
for (const [key, pending] of pendingForTable) {
|
|
178
|
+
const rowId = key.slice(query.table.length + 1);
|
|
179
|
+
rows.delete(rowId);
|
|
180
|
+
if (pending.kind !== 'row' || rowId <= lowerBound)
|
|
181
|
+
continue;
|
|
182
|
+
const values = decodeRow(table.columns, pending.row.payload);
|
|
183
|
+
const matches = columnPositions.every((position, valueIndex) => relationalValuesEqual(values[position] ?? null, query.values[valueIndex] ?? null));
|
|
184
|
+
if (matches)
|
|
185
|
+
rows.set(rowId, pending.row);
|
|
186
|
+
}
|
|
187
|
+
return [...rows.values()]
|
|
188
|
+
.sort((left, right) => left.rowId.localeCompare(right.rowId))
|
|
189
|
+
.slice(0, query.limit);
|
|
190
|
+
}
|
|
152
191
|
async lockPartitionForCommitValidation() {
|
|
153
192
|
this.#assertOpen();
|
|
154
193
|
if (!this.#commitValidationSerialized) {
|
|
@@ -575,6 +614,7 @@ export class D1ServerStorage {
|
|
|
575
614
|
return commits;
|
|
576
615
|
}
|
|
577
616
|
async scanRows(partition, query) {
|
|
617
|
+
assertScopeIndexedScan(query);
|
|
578
618
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
579
619
|
const firstVariable = variables[0];
|
|
580
620
|
if (firstVariable === undefined)
|
|
@@ -614,6 +654,16 @@ export class D1ServerStorage {
|
|
|
614
654
|
}
|
|
615
655
|
return rows;
|
|
616
656
|
}
|
|
657
|
+
async scanRowsByIndex(partition, query) {
|
|
658
|
+
const table = this.table(query.table);
|
|
659
|
+
const index = resolveIndexRowScan(table, query);
|
|
660
|
+
const statement = indexRowPageStatement(table, index, query.values, partition, query.afterRowId, query.limit, 'sqlite');
|
|
661
|
+
const { results } = await this.#db
|
|
662
|
+
.prepare(statement.sql)
|
|
663
|
+
.bind(...statement.params)
|
|
664
|
+
.all();
|
|
665
|
+
return results.map(toStoredRow);
|
|
666
|
+
}
|
|
617
667
|
async getClientRecord(partition, clientId) {
|
|
618
668
|
const record = await this.#db
|
|
619
669
|
.prepare('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
|
package/dist/events.d.ts
CHANGED
|
@@ -59,11 +59,21 @@ export interface PushRejectedEvent extends PushEventBase {
|
|
|
59
59
|
readonly type: 'push.rejected';
|
|
60
60
|
readonly code: string;
|
|
61
61
|
readonly opIndex: number;
|
|
62
|
+
/** True when the rejection was replayed from the idempotency cache. */
|
|
63
|
+
readonly replay: boolean;
|
|
64
|
+
/** Original host time for outcomes recorded by a metadata-aware server. */
|
|
65
|
+
readonly recordedAtMs?: number;
|
|
66
|
+
/** Privacy-safe identity of the stored outcome, when available. */
|
|
67
|
+
readonly cacheIdentity?: string;
|
|
62
68
|
}
|
|
63
69
|
/** A push commit terminated by a version conflict (§6.2). */
|
|
64
70
|
export interface PushConflictedEvent extends PushEventBase {
|
|
65
71
|
readonly type: 'push.conflicted';
|
|
66
72
|
readonly opIndex: number;
|
|
73
|
+
/** True when the conflict was replayed from the idempotency cache. */
|
|
74
|
+
readonly replay: boolean;
|
|
75
|
+
readonly recordedAtMs?: number;
|
|
76
|
+
readonly cacheIdentity?: string;
|
|
67
77
|
}
|
|
68
78
|
/** One emitted segment within a pull subscription section. */
|
|
69
79
|
export interface PullSegmentSummary {
|
package/dist/handler.js
CHANGED
|
@@ -15,7 +15,7 @@ import { SyncError, syncError } from './errors.js';
|
|
|
15
15
|
import { emitEvent, } from './events.js';
|
|
16
16
|
import { END_FRAME_BYTES, encodeResponseFrame, RESPONSE_ENVELOPE_HEADER, } from './frame-bytes.js';
|
|
17
17
|
import { ACCEPT_EXTERNAL_ROWS, ACCEPT_INLINE_ROWS, clampPullLimits, subscriptionSection, } from './pull.js';
|
|
18
|
-
import {
|
|
18
|
+
import { processPushCommitWithTrace } from './push.js';
|
|
19
19
|
import { compileSchema } from './schema.js';
|
|
20
20
|
import { computeEffective } from './scopes.js';
|
|
21
21
|
function validateResolvedKeys(allowed, schema) {
|
|
@@ -199,7 +199,8 @@ function pushResultDetailsFrame(frame) {
|
|
|
199
199
|
entries,
|
|
200
200
|
};
|
|
201
201
|
}
|
|
202
|
-
function emitPushEvent(events, ctx, clientId, push,
|
|
202
|
+
function emitPushEvent(events, ctx, clientId, push, processed) {
|
|
203
|
+
const { frame } = processed;
|
|
203
204
|
const base = {
|
|
204
205
|
atMs: clockOf(ctx)(),
|
|
205
206
|
partition: ctx.partition,
|
|
@@ -213,7 +214,7 @@ function emitPushEvent(events, ctx, clientId, push, frame) {
|
|
|
213
214
|
type: 'push.applied',
|
|
214
215
|
...base,
|
|
215
216
|
...(frame.commitSeq !== undefined ? { commitSeq: frame.commitSeq } : {}),
|
|
216
|
-
replay:
|
|
217
|
+
replay: processed.replayed,
|
|
217
218
|
});
|
|
218
219
|
return;
|
|
219
220
|
}
|
|
@@ -224,6 +225,13 @@ function emitPushEvent(events, ctx, clientId, push, frame) {
|
|
|
224
225
|
type: 'push.conflicted',
|
|
225
226
|
...base,
|
|
226
227
|
opIndex: record.opIndex,
|
|
228
|
+
replay: processed.replayed,
|
|
229
|
+
...(processed.recordedAtMs !== undefined
|
|
230
|
+
? { recordedAtMs: processed.recordedAtMs }
|
|
231
|
+
: {}),
|
|
232
|
+
...(processed.cacheIdentity !== undefined
|
|
233
|
+
? { cacheIdentity: processed.cacheIdentity }
|
|
234
|
+
: {}),
|
|
227
235
|
});
|
|
228
236
|
return;
|
|
229
237
|
}
|
|
@@ -234,6 +242,13 @@ function emitPushEvent(events, ctx, clientId, push, frame) {
|
|
|
234
242
|
? record.code
|
|
235
243
|
: 'sync.invalid_request',
|
|
236
244
|
opIndex: record?.opIndex ?? 0,
|
|
245
|
+
replay: processed.replayed,
|
|
246
|
+
...(processed.recordedAtMs !== undefined
|
|
247
|
+
? { recordedAtMs: processed.recordedAtMs }
|
|
248
|
+
: {}),
|
|
249
|
+
...(processed.cacheIdentity !== undefined
|
|
250
|
+
? { cacheIdentity: processed.cacheIdentity }
|
|
251
|
+
: {}),
|
|
237
252
|
});
|
|
238
253
|
}
|
|
239
254
|
async function* streamResponse(plan, ctx, schema, report) {
|
|
@@ -276,9 +291,10 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
276
291
|
try {
|
|
277
292
|
// Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
|
|
278
293
|
for (const push of plan.pushes) {
|
|
279
|
-
const
|
|
294
|
+
const processed = await processPushCommitWithTrace(ctx, schema, plan.resolved, plan.header.clientId, push);
|
|
295
|
+
const { frame } = processed;
|
|
280
296
|
if (events !== undefined) {
|
|
281
|
-
emitPushEvent(events, ctx, plan.header.clientId, push,
|
|
297
|
+
emitPushEvent(events, ctx, plan.header.clientId, push, processed);
|
|
282
298
|
}
|
|
283
299
|
yield encodeResponseFrame(frame);
|
|
284
300
|
const details = pushResultDetailsFrame(frame);
|
package/dist/index.d.ts
CHANGED
|
@@ -43,4 +43,5 @@ export * from './sqlite-lease-store.js';
|
|
|
43
43
|
export * from './sqlite-segment-store.js';
|
|
44
44
|
export * from './sqlite-storage.js';
|
|
45
45
|
export * from './storage.js';
|
|
46
|
+
export { StorageQueryError, type StorageQueryErrorCode, } from './storage-errors.js';
|
|
46
47
|
export * from './validate.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type PgExecutor } from './pg-executor.js';
|
|
2
2
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
3
|
-
import type { ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
|
|
3
|
+
import type { ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
|
|
4
4
|
/**
|
|
5
5
|
* Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
|
|
6
6
|
*
|
|
@@ -50,6 +50,7 @@ export declare class PostgresServerStorage implements ServerStorage {
|
|
|
50
50
|
getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
|
|
51
51
|
readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
|
|
52
52
|
scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
|
|
53
|
+
scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
53
54
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
54
55
|
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
55
56
|
listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
|
package/dist/postgres-storage.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { syncError } from './errors.js';
|
|
2
2
|
import { asBytes, asNumber, } from './pg-executor.js';
|
|
3
|
-
import { commitWindowPageSql, deleteRowSql, dropTableDdl, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
3
|
+
import { commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
4
4
|
import { matchesEffective } from './scopes.js';
|
|
5
5
|
import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
|
|
6
|
+
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
6
7
|
/**
|
|
7
8
|
* Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
|
|
8
9
|
*
|
|
@@ -90,6 +91,12 @@ function serializePushResult(result) {
|
|
|
90
91
|
return {
|
|
91
92
|
status: result.status,
|
|
92
93
|
...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
|
|
94
|
+
...(result.recordedAtMs !== undefined
|
|
95
|
+
? { recordedAtMs: result.recordedAtMs }
|
|
96
|
+
: {}),
|
|
97
|
+
...(result.cacheIdentity !== undefined
|
|
98
|
+
? { cacheIdentity: result.cacheIdentity }
|
|
99
|
+
: {}),
|
|
93
100
|
results: result.results.map((record) => {
|
|
94
101
|
if (record.status === 'conflict') {
|
|
95
102
|
return {
|
|
@@ -143,6 +150,12 @@ function deserializePushResult(value) {
|
|
|
143
150
|
return {
|
|
144
151
|
status: parsed.status,
|
|
145
152
|
...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
|
|
153
|
+
...(parsed.recordedAtMs !== undefined
|
|
154
|
+
? { recordedAtMs: parsed.recordedAtMs }
|
|
155
|
+
: {}),
|
|
156
|
+
...(parsed.cacheIdentity !== undefined
|
|
157
|
+
? { cacheIdentity: parsed.cacheIdentity }
|
|
158
|
+
: {}),
|
|
146
159
|
results,
|
|
147
160
|
};
|
|
148
161
|
}
|
|
@@ -213,6 +226,12 @@ async function getRowOn(q, compiled, partition, rowId) {
|
|
|
213
226
|
const record = rows[0];
|
|
214
227
|
return record === undefined ? undefined : toStoredRow(record);
|
|
215
228
|
}
|
|
229
|
+
async function scanRowsByIndexOn(q, compiled, partition, query) {
|
|
230
|
+
const index = resolveIndexRowScan(compiled, query);
|
|
231
|
+
const statement = indexRowPageStatement(compiled, index, query.values, partition, query.afterRowId, query.limit, 'postgres');
|
|
232
|
+
const { rows } = await q.query(statement.sql, statement.params);
|
|
233
|
+
return rows.map(toStoredRow);
|
|
234
|
+
}
|
|
216
235
|
async function writeRowOn(q, compiled, partition, row) {
|
|
217
236
|
await q.query(upsertSql(compiled, 'postgres'), upsertValues(compiled, partition, row, 'postgres'));
|
|
218
237
|
await q.query('DELETE FROM sync_row_scopes WHERE partition=$1 AND tbl=$2 AND row_id=$3', [partition, compiled.name, row.rowId]);
|
|
@@ -247,6 +266,7 @@ class PostgresTransaction {
|
|
|
247
266
|
}
|
|
248
267
|
async scanRows(query) {
|
|
249
268
|
this.#assertOpen();
|
|
269
|
+
assertScopeIndexedScan(query);
|
|
250
270
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
251
271
|
const firstVariable = variables[0];
|
|
252
272
|
if (firstVariable === undefined)
|
|
@@ -285,6 +305,10 @@ class PostgresTransaction {
|
|
|
285
305
|
}
|
|
286
306
|
return rows;
|
|
287
307
|
}
|
|
308
|
+
scanRowsByIndex(query) {
|
|
309
|
+
this.#assertOpen();
|
|
310
|
+
return scanRowsByIndexOn(this.#client, this.#resolveTable(query.table), this.#partition, query);
|
|
311
|
+
}
|
|
288
312
|
async lockPartitionForCommitValidation() {
|
|
289
313
|
this.#assertOpen();
|
|
290
314
|
await this.#client.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
|
|
@@ -673,6 +697,7 @@ export class PostgresServerStorage {
|
|
|
673
697
|
return commits;
|
|
674
698
|
}
|
|
675
699
|
async scanRows(partition, query) {
|
|
700
|
+
assertScopeIndexedScan(query);
|
|
676
701
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
677
702
|
const firstVariable = variables[0];
|
|
678
703
|
if (firstVariable === undefined)
|
|
@@ -717,6 +742,9 @@ export class PostgresServerStorage {
|
|
|
717
742
|
}
|
|
718
743
|
return rows;
|
|
719
744
|
}
|
|
745
|
+
scanRowsByIndex(partition, query) {
|
|
746
|
+
return scanRowsByIndexOn(this.#exec, this.table(query.table), partition, query);
|
|
747
|
+
}
|
|
720
748
|
async getClientRecord(partition, clientId) {
|
|
721
749
|
const { rows } = await this.#exec.query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 AND client_id=$2', [partition, clientId]);
|
|
722
750
|
const record = rows[0];
|
package/dist/push.d.ts
CHANGED
|
@@ -22,6 +22,13 @@ import type { SyncRequestContext } from './context.js';
|
|
|
22
22
|
import type { CompiledSchema } from './schema.js';
|
|
23
23
|
import type { ResolvedScopes } from './scopes.js';
|
|
24
24
|
import type { StoredCommit } from './storage.js';
|
|
25
|
+
export interface ProcessedPushCommit {
|
|
26
|
+
readonly frame: PushResultFrame;
|
|
27
|
+
/** True when this request observed an already-recorded idempotency outcome. */
|
|
28
|
+
readonly replayed: boolean;
|
|
29
|
+
readonly recordedAtMs?: number;
|
|
30
|
+
readonly cacheIdentity?: string;
|
|
31
|
+
}
|
|
25
32
|
export interface AppliedCommitEvent {
|
|
26
33
|
readonly commit: StoredCommit;
|
|
27
34
|
}
|
|
@@ -30,3 +37,9 @@ export interface AppliedCommitEvent {
|
|
|
30
37
|
* atomic apply (§6.4), realtime notification for applied commits.
|
|
31
38
|
*/
|
|
32
39
|
export declare function processPushCommit(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, frame: PushCommitFrame): Promise<PushResultFrame>;
|
|
40
|
+
/**
|
|
41
|
+
* Host-observable variant of `processPushCommit`. The SSP2 wire frame keeps
|
|
42
|
+
* rejected replays as `status: rejected`; this companion result preserves the
|
|
43
|
+
* cache provenance needed by structured events and server helpers.
|
|
44
|
+
*/
|
|
45
|
+
export declare function processPushCommitWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, frame: PushCommitFrame): Promise<ProcessedPushCommit>;
|
package/dist/push.js
CHANGED
|
@@ -466,6 +466,25 @@ function resultFrame(clientCommitId, stored, replay) {
|
|
|
466
466
|
results: [...stored.results],
|
|
467
467
|
};
|
|
468
468
|
}
|
|
469
|
+
function processedPushCommit(clientCommitId, stored, replayed) {
|
|
470
|
+
return {
|
|
471
|
+
frame: resultFrame(clientCommitId, stored, replayed),
|
|
472
|
+
replayed,
|
|
473
|
+
...(stored.recordedAtMs !== undefined
|
|
474
|
+
? { recordedAtMs: stored.recordedAtMs }
|
|
475
|
+
: {}),
|
|
476
|
+
...(stored.cacheIdentity !== undefined
|
|
477
|
+
? { cacheIdentity: stored.cacheIdentity }
|
|
478
|
+
: {}),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function newStoredPushResult(recordedAtMs, result) {
|
|
482
|
+
return {
|
|
483
|
+
...result,
|
|
484
|
+
recordedAtMs,
|
|
485
|
+
cacheIdentity: crypto.randomUUID(),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
469
488
|
function idempotencyCacheMissFrame(clientCommitId, error) {
|
|
470
489
|
return {
|
|
471
490
|
type: 'PUSH_RESULT',
|
|
@@ -496,13 +515,24 @@ async function persistRejectedPushResult(storage, partition, clientId, clientCom
|
|
|
496
515
|
if (canonical === undefined) {
|
|
497
516
|
throw new Error('push rejection finalization did not persist an outcome');
|
|
498
517
|
}
|
|
499
|
-
return
|
|
518
|
+
return {
|
|
519
|
+
stored: canonical,
|
|
520
|
+
replayed: canonical.cacheIdentity !== stored.cacheIdentity,
|
|
521
|
+
};
|
|
500
522
|
}
|
|
501
523
|
/**
|
|
502
524
|
* Process one `PUSH_COMMIT` frame: idempotency replay (§2.3), sequential
|
|
503
525
|
* atomic apply (§6.4), realtime notification for applied commits.
|
|
504
526
|
*/
|
|
505
527
|
export async function processPushCommit(ctx, schema, resolved, clientId, frame) {
|
|
528
|
+
return (await processPushCommitWithTrace(ctx, schema, resolved, clientId, frame)).frame;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Host-observable variant of `processPushCommit`. The SSP2 wire frame keeps
|
|
532
|
+
* rejected replays as `status: rejected`; this companion result preserves the
|
|
533
|
+
* cache provenance needed by structured events and server helpers.
|
|
534
|
+
*/
|
|
535
|
+
export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame) {
|
|
506
536
|
const { storage, partition } = ctx;
|
|
507
537
|
let persisted;
|
|
508
538
|
try {
|
|
@@ -513,12 +543,15 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
513
543
|
error.code === 'sync.idempotency_cache_miss') {
|
|
514
544
|
// §6.3: answer the retryable cache-miss for this commit rather than
|
|
515
545
|
// re-applying. Not persisted — a retry may find a readable record.
|
|
516
|
-
return
|
|
546
|
+
return {
|
|
547
|
+
frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
|
|
548
|
+
replayed: false,
|
|
549
|
+
};
|
|
517
550
|
}
|
|
518
551
|
throw error;
|
|
519
552
|
}
|
|
520
553
|
if (persisted !== undefined) {
|
|
521
|
-
return
|
|
554
|
+
return processedPushCommit(frame.clientCommitId, persisted, true);
|
|
522
555
|
}
|
|
523
556
|
const createdAtMs = clockOf(ctx)();
|
|
524
557
|
const blobCtx = { store: ctx.blobs, partition };
|
|
@@ -541,14 +574,17 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
541
574
|
const serializedPersisted = await storage.getPushResult(partition, clientId, frame.clientCommitId);
|
|
542
575
|
if (serializedPersisted !== undefined) {
|
|
543
576
|
await tx.rollback();
|
|
544
|
-
return
|
|
577
|
+
return processedPushCommit(frame.clientCommitId, serializedPersisted, true);
|
|
545
578
|
}
|
|
546
579
|
}
|
|
547
580
|
catch (error) {
|
|
548
581
|
if (error instanceof SyncError &&
|
|
549
582
|
error.code === 'sync.idempotency_cache_miss') {
|
|
550
583
|
await tx.rollback();
|
|
551
|
-
return
|
|
584
|
+
return {
|
|
585
|
+
frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
|
|
586
|
+
replayed: false,
|
|
587
|
+
};
|
|
552
588
|
}
|
|
553
589
|
throw error;
|
|
554
590
|
}
|
|
@@ -580,10 +616,10 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
580
616
|
if (terminated !== undefined) {
|
|
581
617
|
// §6.3 rejected: only the terminating operation's record; §6.4:
|
|
582
618
|
// every write of the commit rolls back.
|
|
583
|
-
const stored = {
|
|
619
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
584
620
|
status: 'rejected',
|
|
585
621
|
results: [terminated],
|
|
586
|
-
};
|
|
622
|
+
});
|
|
587
623
|
if (commitValidator !== undefined) {
|
|
588
624
|
// Discard candidate rows and persist the rejection while retaining the
|
|
589
625
|
// same partition lock. This closes the duplicate-request race between
|
|
@@ -596,9 +632,9 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
596
632
|
else {
|
|
597
633
|
await tx.rollback();
|
|
598
634
|
const canonical = await persistRejectedPushResult(storage, partition, clientId, frame.clientCommitId, stored);
|
|
599
|
-
return
|
|
635
|
+
return processedPushCommit(frame.clientCommitId, canonical.stored, canonical.replayed);
|
|
600
636
|
}
|
|
601
|
-
return
|
|
637
|
+
return processedPushCommit(frame.clientCommitId, stored, false);
|
|
602
638
|
}
|
|
603
639
|
const commitSeq = await tx.appendCommit({
|
|
604
640
|
clientId,
|
|
@@ -607,7 +643,11 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
607
643
|
createdAtMs,
|
|
608
644
|
changes,
|
|
609
645
|
});
|
|
610
|
-
const stored =
|
|
646
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
647
|
+
status: 'applied',
|
|
648
|
+
commitSeq,
|
|
649
|
+
results,
|
|
650
|
+
});
|
|
611
651
|
await tx.putPushResult(clientId, frame.clientCommitId, stored);
|
|
612
652
|
await tx.commit();
|
|
613
653
|
if (ctx.realtime !== undefined && changes.length > 0) {
|
|
@@ -618,12 +658,12 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
618
658
|
changes,
|
|
619
659
|
});
|
|
620
660
|
}
|
|
621
|
-
return
|
|
661
|
+
return processedPushCommit(frame.clientCommitId, stored, false);
|
|
622
662
|
}
|
|
623
663
|
catch (error) {
|
|
624
664
|
await tx.rollback();
|
|
625
665
|
if (error instanceof StorageConstraintError) {
|
|
626
|
-
const stored = {
|
|
666
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
627
667
|
status: 'rejected',
|
|
628
668
|
results: [
|
|
629
669
|
{
|
|
@@ -634,9 +674,9 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
634
674
|
retryable: false,
|
|
635
675
|
},
|
|
636
676
|
],
|
|
637
|
-
};
|
|
677
|
+
});
|
|
638
678
|
const canonical = await persistRejectedPushResult(storage, partition, clientId, frame.clientCommitId, stored);
|
|
639
|
-
return
|
|
679
|
+
return processedPushCommit(frame.clientCommitId, canonical.stored, canonical.replayed);
|
|
640
680
|
}
|
|
641
681
|
throw error;
|
|
642
682
|
}
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
* NOT NULL per the schema.
|
|
45
45
|
*/
|
|
46
46
|
import { type RowColumn, type RowValue } from '@syncular/core';
|
|
47
|
-
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
47
|
+
import type { CompiledSchema, CompiledTable, IndexSchema } from './schema.js';
|
|
48
48
|
import type { StoredRow } from './storage.js';
|
|
49
49
|
export type RelationalDialect = 'sqlite' | 'postgres';
|
|
50
50
|
export declare const SYNC_PARTITION_COLUMN = "_sync_partition";
|
|
@@ -100,6 +100,16 @@ export declare function upsertSql(table: CompiledTable, dialect: RelationalDiale
|
|
|
100
100
|
* `toStoredRow` converters keep working. Params: [partition, rowId].
|
|
101
101
|
*/
|
|
102
102
|
export declare function selectRowSql(table: CompiledTable, dialect: RelationalDialect): string;
|
|
103
|
+
export interface IndexRowPageStatement {
|
|
104
|
+
readonly sql: string;
|
|
105
|
+
readonly params: readonly unknown[];
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Bounded exact lookup through one declared relational index. Unlike
|
|
109
|
+
* `scanRowPageSql`, this is a trusted server-host query: it never reads or
|
|
110
|
+
* creates Syncular scope-index entries and is not reachable from SSP2.
|
|
111
|
+
*/
|
|
112
|
+
export declare function indexRowPageStatement(table: CompiledTable, index: IndexSchema, values: readonly RowValue[], partition: string, afterRowId: string | null | undefined, limit: number, dialect: RelationalDialect): IndexRowPageStatement;
|
|
103
113
|
/**
|
|
104
114
|
* One-round-trip page scan for `scanRows`: candidates from the inverted
|
|
105
115
|
* scope index (ordered + LIMITed at the covering `sync_row_scopes` PK —
|
package/dist/relational-rows.js
CHANGED
|
@@ -243,6 +243,35 @@ export function selectRowSql(table, dialect) {
|
|
|
243
243
|
const p = dialect === 'sqlite' ? ['?', '?'] : ['$1', '$2'];
|
|
244
244
|
return `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 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${p[0]} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=${p[1]}`;
|
|
245
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Bounded exact lookup through one declared relational index. Unlike
|
|
248
|
+
* `scanRowPageSql`, this is a trusted server-host query: it never reads or
|
|
249
|
+
* creates Syncular scope-index entries and is not reachable from SSP2.
|
|
250
|
+
*/
|
|
251
|
+
export function indexRowPageStatement(table, index, values, partition, afterRowId, limit, dialect) {
|
|
252
|
+
const params = [partition];
|
|
253
|
+
const placeholder = () => dialect === 'sqlite' ? '?' : `$${params.length}`;
|
|
254
|
+
const predicates = index.columns.map((columnName, valueIndex) => {
|
|
255
|
+
const columnPosition = table.columnIndex.get(columnName);
|
|
256
|
+
const column = columnPosition === undefined ? undefined : table.columns[columnPosition];
|
|
257
|
+
if (column === undefined) {
|
|
258
|
+
throw new Error('compiled relational index references unknown column');
|
|
259
|
+
}
|
|
260
|
+
const value = values[valueIndex] ?? null;
|
|
261
|
+
if (value === null)
|
|
262
|
+
return `${quoteIdent(columnName)} IS NULL`;
|
|
263
|
+
params.push(toSqlValue(column, value, dialect));
|
|
264
|
+
return `${quoteIdent(columnName)}=${placeholder()}`;
|
|
265
|
+
});
|
|
266
|
+
params.push(afterRowId ?? '');
|
|
267
|
+
const after = placeholder();
|
|
268
|
+
params.push(limit);
|
|
269
|
+
const pageLimit = placeholder();
|
|
270
|
+
return {
|
|
271
|
+
sql: `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 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${dialect === 'sqlite' ? '?' : '$1'} AND ${predicates.join(' AND ')} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>${after} ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT ${pageLimit}`,
|
|
272
|
+
params,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
246
275
|
/**
|
|
247
276
|
* One-round-trip page scan for `scanRows`: candidates from the inverted
|
|
248
277
|
* scope index (ordered + LIMITed at the covering `sync_row_scopes` PK —
|
package/dist/schema.js
CHANGED
|
@@ -61,6 +61,9 @@ export function compileSchema(schema) {
|
|
|
61
61
|
throw new Error(`table ${table.name}: duplicate index ${JSON.stringify(index.name)}`);
|
|
62
62
|
}
|
|
63
63
|
indexNames.add(index.name);
|
|
64
|
+
if (index.columns.length === 0) {
|
|
65
|
+
throw new Error(`table ${table.name}: index ${JSON.stringify(index.name)} must name at least one column`);
|
|
66
|
+
}
|
|
64
67
|
for (const column of index.columns) {
|
|
65
68
|
if (!columnIndex.has(column)) {
|
|
66
69
|
throw new Error(`table ${table.name}: index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`);
|
package/dist/seed.d.ts
CHANGED
|
@@ -26,9 +26,34 @@ export interface SeedTarget {
|
|
|
26
26
|
/** The client commit id (default `'seed-commit-1'`). */
|
|
27
27
|
readonly commitId?: string;
|
|
28
28
|
}
|
|
29
|
+
export interface SeedMutationErrorOptions {
|
|
30
|
+
readonly clientId: string;
|
|
31
|
+
readonly clientCommitId: string;
|
|
32
|
+
readonly opIndex: number;
|
|
33
|
+
readonly code: string;
|
|
34
|
+
readonly replayed: boolean;
|
|
35
|
+
readonly retryable: boolean;
|
|
36
|
+
readonly message: string;
|
|
37
|
+
readonly recordedAtMs?: number;
|
|
38
|
+
readonly cacheIdentity?: string;
|
|
39
|
+
}
|
|
40
|
+
/** Structured terminal failure from the real push path used by a seed. */
|
|
41
|
+
export declare class SeedMutationError extends Error {
|
|
42
|
+
readonly name = "SeedMutationError";
|
|
43
|
+
readonly clientId: string;
|
|
44
|
+
readonly clientCommitId: string;
|
|
45
|
+
readonly opIndex: number;
|
|
46
|
+
/** Exact protocol or host-validator rejection code. */
|
|
47
|
+
readonly code: string;
|
|
48
|
+
readonly replayed: boolean;
|
|
49
|
+
readonly retryable: boolean;
|
|
50
|
+
readonly recordedAtMs?: number;
|
|
51
|
+
readonly cacheIdentity?: string;
|
|
52
|
+
constructor(options: SeedMutationErrorOptions);
|
|
53
|
+
}
|
|
29
54
|
/**
|
|
30
55
|
* Seed `mutations` into a partition through the real push path. Throws a
|
|
31
|
-
* `
|
|
32
|
-
* seed fails loud
|
|
56
|
+
* `SeedMutationError` when the push is rejected and `SyncError` for malformed
|
|
57
|
+
* helper input, so a broken seed fails loud instead of serving an empty store.
|
|
33
58
|
*/
|
|
34
59
|
export declare function seedMutations(config: SyncServerConfig, target: SeedTarget, mutations: readonly SeedMutation[]): Promise<void>;
|