@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/src/postgres-storage.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
commitWindowPageSql,
|
|
48
48
|
deleteRowSql,
|
|
49
49
|
dropTableDdl,
|
|
50
|
+
indexRowPageStatement,
|
|
50
51
|
layoutsOf,
|
|
51
52
|
migratePayload,
|
|
52
53
|
parseLayouts,
|
|
@@ -73,6 +74,7 @@ import type {
|
|
|
73
74
|
CommitMetadata,
|
|
74
75
|
CommitMetadataQuery,
|
|
75
76
|
CommitWindowQuery,
|
|
77
|
+
IndexRowScanQuery,
|
|
76
78
|
NewCommit,
|
|
77
79
|
RowScanQuery,
|
|
78
80
|
ScopeActivityQuery,
|
|
@@ -88,6 +90,7 @@ import {
|
|
|
88
90
|
isPostgresConstraintError,
|
|
89
91
|
StorageConstraintError,
|
|
90
92
|
} from './storage-errors';
|
|
93
|
+
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query';
|
|
91
94
|
|
|
92
95
|
/**
|
|
93
96
|
* Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
|
|
@@ -190,6 +193,12 @@ function serializePushResult(result: StoredPushResult): unknown {
|
|
|
190
193
|
return {
|
|
191
194
|
status: result.status,
|
|
192
195
|
...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
|
|
196
|
+
...(result.recordedAtMs !== undefined
|
|
197
|
+
? { recordedAtMs: result.recordedAtMs }
|
|
198
|
+
: {}),
|
|
199
|
+
...(result.cacheIdentity !== undefined
|
|
200
|
+
? { cacheIdentity: result.cacheIdentity }
|
|
201
|
+
: {}),
|
|
193
202
|
results: result.results.map((record) => {
|
|
194
203
|
if (record.status === 'conflict') {
|
|
195
204
|
return {
|
|
@@ -220,6 +229,8 @@ function deserializePushResult(value: unknown): StoredPushResult {
|
|
|
220
229
|
const parsed = value as {
|
|
221
230
|
status: 'applied' | 'rejected';
|
|
222
231
|
commitSeq?: number;
|
|
232
|
+
recordedAtMs?: number;
|
|
233
|
+
cacheIdentity?: string;
|
|
223
234
|
results: SerializedResult[];
|
|
224
235
|
};
|
|
225
236
|
const results: PushOperationResult[] = parsed.results.map((record) => {
|
|
@@ -248,6 +259,12 @@ function deserializePushResult(value: unknown): StoredPushResult {
|
|
|
248
259
|
return {
|
|
249
260
|
status: parsed.status,
|
|
250
261
|
...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
|
|
262
|
+
...(parsed.recordedAtMs !== undefined
|
|
263
|
+
? { recordedAtMs: parsed.recordedAtMs }
|
|
264
|
+
: {}),
|
|
265
|
+
...(parsed.cacheIdentity !== undefined
|
|
266
|
+
? { cacheIdentity: parsed.cacheIdentity }
|
|
267
|
+
: {}),
|
|
251
268
|
results,
|
|
252
269
|
};
|
|
253
270
|
}
|
|
@@ -374,6 +391,26 @@ async function getRowOn(
|
|
|
374
391
|
return record === undefined ? undefined : toStoredRow(record);
|
|
375
392
|
}
|
|
376
393
|
|
|
394
|
+
async function scanRowsByIndexOn(
|
|
395
|
+
q: PgQueryable,
|
|
396
|
+
compiled: CompiledTable,
|
|
397
|
+
partition: string,
|
|
398
|
+
query: IndexRowScanQuery,
|
|
399
|
+
): Promise<StoredRow[]> {
|
|
400
|
+
const index = resolveIndexRowScan(compiled, query);
|
|
401
|
+
const statement = indexRowPageStatement(
|
|
402
|
+
compiled,
|
|
403
|
+
index,
|
|
404
|
+
query.values,
|
|
405
|
+
partition,
|
|
406
|
+
query.afterRowId,
|
|
407
|
+
query.limit,
|
|
408
|
+
'postgres',
|
|
409
|
+
);
|
|
410
|
+
const { rows } = await q.query<RowRecord>(statement.sql, statement.params);
|
|
411
|
+
return rows.map(toStoredRow);
|
|
412
|
+
}
|
|
413
|
+
|
|
377
414
|
async function writeRowOn(
|
|
378
415
|
q: PgQueryable,
|
|
379
416
|
compiled: CompiledTable,
|
|
@@ -437,6 +474,7 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
437
474
|
|
|
438
475
|
async scanRows(query: RowScanQuery): Promise<StoredRow[]> {
|
|
439
476
|
this.#assertOpen();
|
|
477
|
+
assertScopeIndexedScan(query);
|
|
440
478
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
441
479
|
const firstVariable = variables[0];
|
|
442
480
|
if (firstVariable === undefined) return [];
|
|
@@ -473,6 +511,16 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
473
511
|
return rows;
|
|
474
512
|
}
|
|
475
513
|
|
|
514
|
+
scanRowsByIndex(query: IndexRowScanQuery): Promise<StoredRow[]> {
|
|
515
|
+
this.#assertOpen();
|
|
516
|
+
return scanRowsByIndexOn(
|
|
517
|
+
this.#client,
|
|
518
|
+
this.#resolveTable(query.table),
|
|
519
|
+
this.#partition,
|
|
520
|
+
query,
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
476
524
|
async lockPartitionForCommitValidation(): Promise<void> {
|
|
477
525
|
this.#assertOpen();
|
|
478
526
|
await this.#client.query(
|
|
@@ -1012,6 +1060,7 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1012
1060
|
}
|
|
1013
1061
|
|
|
1014
1062
|
async scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]> {
|
|
1063
|
+
assertScopeIndexedScan(query);
|
|
1015
1064
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
1016
1065
|
const firstVariable = variables[0];
|
|
1017
1066
|
if (firstVariable === undefined) return [];
|
|
@@ -1054,6 +1103,18 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1054
1103
|
return rows;
|
|
1055
1104
|
}
|
|
1056
1105
|
|
|
1106
|
+
scanRowsByIndex(
|
|
1107
|
+
partition: string,
|
|
1108
|
+
query: IndexRowScanQuery,
|
|
1109
|
+
): Promise<StoredRow[]> {
|
|
1110
|
+
return scanRowsByIndexOn(
|
|
1111
|
+
this.#exec,
|
|
1112
|
+
this.table(query.table),
|
|
1113
|
+
partition,
|
|
1114
|
+
query,
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1057
1118
|
async getClientRecord(
|
|
1058
1119
|
partition: string,
|
|
1059
1120
|
clientId: string,
|
package/src/push.ts
CHANGED
|
@@ -750,6 +750,42 @@ function resultFrame(
|
|
|
750
750
|
};
|
|
751
751
|
}
|
|
752
752
|
|
|
753
|
+
export interface ProcessedPushCommit {
|
|
754
|
+
readonly frame: PushResultFrame;
|
|
755
|
+
/** True when this request observed an already-recorded idempotency outcome. */
|
|
756
|
+
readonly replayed: boolean;
|
|
757
|
+
readonly recordedAtMs?: number;
|
|
758
|
+
readonly cacheIdentity?: string;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function processedPushCommit(
|
|
762
|
+
clientCommitId: string,
|
|
763
|
+
stored: StoredPushResult,
|
|
764
|
+
replayed: boolean,
|
|
765
|
+
): ProcessedPushCommit {
|
|
766
|
+
return {
|
|
767
|
+
frame: resultFrame(clientCommitId, stored, replayed),
|
|
768
|
+
replayed,
|
|
769
|
+
...(stored.recordedAtMs !== undefined
|
|
770
|
+
? { recordedAtMs: stored.recordedAtMs }
|
|
771
|
+
: {}),
|
|
772
|
+
...(stored.cacheIdentity !== undefined
|
|
773
|
+
? { cacheIdentity: stored.cacheIdentity }
|
|
774
|
+
: {}),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function newStoredPushResult(
|
|
779
|
+
recordedAtMs: number,
|
|
780
|
+
result: Omit<StoredPushResult, 'recordedAtMs' | 'cacheIdentity'>,
|
|
781
|
+
): StoredPushResult {
|
|
782
|
+
return {
|
|
783
|
+
...result,
|
|
784
|
+
recordedAtMs,
|
|
785
|
+
cacheIdentity: crypto.randomUUID(),
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
753
789
|
function idempotencyCacheMissFrame(
|
|
754
790
|
clientCommitId: string,
|
|
755
791
|
error: SyncError,
|
|
@@ -776,7 +812,7 @@ async function persistRejectedPushResult(
|
|
|
776
812
|
clientId: string,
|
|
777
813
|
clientCommitId: string,
|
|
778
814
|
stored: StoredPushResult,
|
|
779
|
-
): Promise<StoredPushResult> {
|
|
815
|
+
): Promise<{ readonly stored: StoredPushResult; readonly replayed: boolean }> {
|
|
780
816
|
const rejectionTx = await storage.begin(partition);
|
|
781
817
|
try {
|
|
782
818
|
await rejectionTx.putPushResult(clientId, clientCommitId, stored);
|
|
@@ -793,7 +829,10 @@ async function persistRejectedPushResult(
|
|
|
793
829
|
if (canonical === undefined) {
|
|
794
830
|
throw new Error('push rejection finalization did not persist an outcome');
|
|
795
831
|
}
|
|
796
|
-
return
|
|
832
|
+
return {
|
|
833
|
+
stored: canonical,
|
|
834
|
+
replayed: canonical.cacheIdentity !== stored.cacheIdentity,
|
|
835
|
+
};
|
|
797
836
|
}
|
|
798
837
|
|
|
799
838
|
export interface AppliedCommitEvent {
|
|
@@ -811,6 +850,23 @@ export async function processPushCommit(
|
|
|
811
850
|
clientId: string,
|
|
812
851
|
frame: PushCommitFrame,
|
|
813
852
|
): Promise<PushResultFrame> {
|
|
853
|
+
return (
|
|
854
|
+
await processPushCommitWithTrace(ctx, schema, resolved, clientId, frame)
|
|
855
|
+
).frame;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Host-observable variant of `processPushCommit`. The SSP2 wire frame keeps
|
|
860
|
+
* rejected replays as `status: rejected`; this companion result preserves the
|
|
861
|
+
* cache provenance needed by structured events and server helpers.
|
|
862
|
+
*/
|
|
863
|
+
export async function processPushCommitWithTrace(
|
|
864
|
+
ctx: SyncRequestContext,
|
|
865
|
+
schema: CompiledSchema,
|
|
866
|
+
resolved: ResolvedScopes,
|
|
867
|
+
clientId: string,
|
|
868
|
+
frame: PushCommitFrame,
|
|
869
|
+
): Promise<ProcessedPushCommit> {
|
|
814
870
|
const { storage, partition } = ctx;
|
|
815
871
|
let persisted: StoredPushResult | undefined;
|
|
816
872
|
try {
|
|
@@ -826,12 +882,15 @@ export async function processPushCommit(
|
|
|
826
882
|
) {
|
|
827
883
|
// §6.3: answer the retryable cache-miss for this commit rather than
|
|
828
884
|
// re-applying. Not persisted — a retry may find a readable record.
|
|
829
|
-
return
|
|
885
|
+
return {
|
|
886
|
+
frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
|
|
887
|
+
replayed: false,
|
|
888
|
+
};
|
|
830
889
|
}
|
|
831
890
|
throw error;
|
|
832
891
|
}
|
|
833
892
|
if (persisted !== undefined) {
|
|
834
|
-
return
|
|
893
|
+
return processedPushCommit(frame.clientCommitId, persisted, true);
|
|
835
894
|
}
|
|
836
895
|
|
|
837
896
|
const createdAtMs = clockOf(ctx)();
|
|
@@ -863,7 +922,11 @@ export async function processPushCommit(
|
|
|
863
922
|
);
|
|
864
923
|
if (serializedPersisted !== undefined) {
|
|
865
924
|
await tx.rollback();
|
|
866
|
-
return
|
|
925
|
+
return processedPushCommit(
|
|
926
|
+
frame.clientCommitId,
|
|
927
|
+
serializedPersisted,
|
|
928
|
+
true,
|
|
929
|
+
);
|
|
867
930
|
}
|
|
868
931
|
} catch (error) {
|
|
869
932
|
if (
|
|
@@ -871,7 +934,10 @@ export async function processPushCommit(
|
|
|
871
934
|
error.code === 'sync.idempotency_cache_miss'
|
|
872
935
|
) {
|
|
873
936
|
await tx.rollback();
|
|
874
|
-
return
|
|
937
|
+
return {
|
|
938
|
+
frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
|
|
939
|
+
replayed: false,
|
|
940
|
+
};
|
|
875
941
|
}
|
|
876
942
|
throw error;
|
|
877
943
|
}
|
|
@@ -923,10 +989,10 @@ export async function processPushCommit(
|
|
|
923
989
|
if (terminated !== undefined) {
|
|
924
990
|
// §6.3 rejected: only the terminating operation's record; §6.4:
|
|
925
991
|
// every write of the commit rolls back.
|
|
926
|
-
const stored
|
|
992
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
927
993
|
status: 'rejected',
|
|
928
994
|
results: [terminated],
|
|
929
|
-
};
|
|
995
|
+
});
|
|
930
996
|
if (commitValidator !== undefined) {
|
|
931
997
|
// Discard candidate rows and persist the rejection while retaining the
|
|
932
998
|
// same partition lock. This closes the duplicate-request race between
|
|
@@ -946,13 +1012,13 @@ export async function processPushCommit(
|
|
|
946
1012
|
frame.clientCommitId,
|
|
947
1013
|
stored,
|
|
948
1014
|
);
|
|
949
|
-
return
|
|
1015
|
+
return processedPushCommit(
|
|
950
1016
|
frame.clientCommitId,
|
|
951
|
-
canonical,
|
|
952
|
-
canonical
|
|
1017
|
+
canonical.stored,
|
|
1018
|
+
canonical.replayed,
|
|
953
1019
|
);
|
|
954
1020
|
}
|
|
955
|
-
return
|
|
1021
|
+
return processedPushCommit(frame.clientCommitId, stored, false);
|
|
956
1022
|
}
|
|
957
1023
|
|
|
958
1024
|
const commitSeq = await tx.appendCommit({
|
|
@@ -962,7 +1028,11 @@ export async function processPushCommit(
|
|
|
962
1028
|
createdAtMs,
|
|
963
1029
|
changes,
|
|
964
1030
|
});
|
|
965
|
-
const stored
|
|
1031
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
1032
|
+
status: 'applied',
|
|
1033
|
+
commitSeq,
|
|
1034
|
+
results,
|
|
1035
|
+
});
|
|
966
1036
|
await tx.putPushResult(clientId, frame.clientCommitId, stored);
|
|
967
1037
|
await tx.commit();
|
|
968
1038
|
if (ctx.realtime !== undefined && changes.length > 0) {
|
|
@@ -973,11 +1043,11 @@ export async function processPushCommit(
|
|
|
973
1043
|
changes,
|
|
974
1044
|
});
|
|
975
1045
|
}
|
|
976
|
-
return
|
|
1046
|
+
return processedPushCommit(frame.clientCommitId, stored, false);
|
|
977
1047
|
} catch (error) {
|
|
978
1048
|
await tx.rollback();
|
|
979
1049
|
if (error instanceof StorageConstraintError) {
|
|
980
|
-
const stored
|
|
1050
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
981
1051
|
status: 'rejected',
|
|
982
1052
|
results: [
|
|
983
1053
|
{
|
|
@@ -988,7 +1058,7 @@ export async function processPushCommit(
|
|
|
988
1058
|
retryable: false,
|
|
989
1059
|
},
|
|
990
1060
|
],
|
|
991
|
-
};
|
|
1061
|
+
});
|
|
992
1062
|
const canonical = await persistRejectedPushResult(
|
|
993
1063
|
storage,
|
|
994
1064
|
partition,
|
|
@@ -996,7 +1066,11 @@ export async function processPushCommit(
|
|
|
996
1066
|
frame.clientCommitId,
|
|
997
1067
|
stored,
|
|
998
1068
|
);
|
|
999
|
-
return
|
|
1069
|
+
return processedPushCommit(
|
|
1070
|
+
frame.clientCommitId,
|
|
1071
|
+
canonical.stored,
|
|
1072
|
+
canonical.replayed,
|
|
1073
|
+
);
|
|
1000
1074
|
}
|
|
1001
1075
|
throw error;
|
|
1002
1076
|
}
|
package/src/relational-rows.ts
CHANGED
|
@@ -49,7 +49,7 @@ import {
|
|
|
49
49
|
type RowColumn,
|
|
50
50
|
type RowValue,
|
|
51
51
|
} from '@syncular/core';
|
|
52
|
-
import type { CompiledSchema, CompiledTable } from './schema';
|
|
52
|
+
import type { CompiledSchema, CompiledTable, IndexSchema } from './schema';
|
|
53
53
|
import type { StoredRow } from './storage';
|
|
54
54
|
|
|
55
55
|
export type RelationalDialect = 'sqlite' | 'postgres';
|
|
@@ -287,6 +287,50 @@ export function selectRowSql(
|
|
|
287
287
|
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]}`;
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
export interface IndexRowPageStatement {
|
|
291
|
+
readonly sql: string;
|
|
292
|
+
readonly params: readonly unknown[];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Bounded exact lookup through one declared relational index. Unlike
|
|
297
|
+
* `scanRowPageSql`, this is a trusted server-host query: it never reads or
|
|
298
|
+
* creates Syncular scope-index entries and is not reachable from SSP2.
|
|
299
|
+
*/
|
|
300
|
+
export function indexRowPageStatement(
|
|
301
|
+
table: CompiledTable,
|
|
302
|
+
index: IndexSchema,
|
|
303
|
+
values: readonly RowValue[],
|
|
304
|
+
partition: string,
|
|
305
|
+
afterRowId: string | null | undefined,
|
|
306
|
+
limit: number,
|
|
307
|
+
dialect: RelationalDialect,
|
|
308
|
+
): IndexRowPageStatement {
|
|
309
|
+
const params: unknown[] = [partition];
|
|
310
|
+
const placeholder = (): string =>
|
|
311
|
+
dialect === 'sqlite' ? '?' : `$${params.length}`;
|
|
312
|
+
const predicates = index.columns.map((columnName, valueIndex) => {
|
|
313
|
+
const columnPosition = table.columnIndex.get(columnName);
|
|
314
|
+
const column =
|
|
315
|
+
columnPosition === undefined ? undefined : table.columns[columnPosition];
|
|
316
|
+
if (column === undefined) {
|
|
317
|
+
throw new Error('compiled relational index references unknown column');
|
|
318
|
+
}
|
|
319
|
+
const value = values[valueIndex] ?? null;
|
|
320
|
+
if (value === null) return `${quoteIdent(columnName)} IS NULL`;
|
|
321
|
+
params.push(toSqlValue(column, value, dialect));
|
|
322
|
+
return `${quoteIdent(columnName)}=${placeholder()}`;
|
|
323
|
+
});
|
|
324
|
+
params.push(afterRowId ?? '');
|
|
325
|
+
const after = placeholder();
|
|
326
|
+
params.push(limit);
|
|
327
|
+
const pageLimit = placeholder();
|
|
328
|
+
return {
|
|
329
|
+
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}`,
|
|
330
|
+
params,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
290
334
|
/**
|
|
291
335
|
* One-round-trip page scan for `scanRows`: candidates from the inverted
|
|
292
336
|
* scope index (ordered + LIMITed at the covering `sync_row_scopes` PK —
|
package/src/schema.ts
CHANGED
|
@@ -180,6 +180,11 @@ export function compileSchema(schema: ServerSchema): CompiledSchema {
|
|
|
180
180
|
);
|
|
181
181
|
}
|
|
182
182
|
indexNames.add(index.name);
|
|
183
|
+
if (index.columns.length === 0) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`table ${table.name}: index ${JSON.stringify(index.name)} must name at least one column`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
183
188
|
for (const column of index.columns) {
|
|
184
189
|
if (!columnIndex.has(column)) {
|
|
185
190
|
throw new Error(
|
package/src/seed.ts
CHANGED
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
} from '@syncular/core';
|
|
22
22
|
import type { SyncServerConfig } from './context';
|
|
23
23
|
import { SyncError } from './errors';
|
|
24
|
+
import type { SyncularServerEvent, SyncularServerEvents } from './events';
|
|
25
|
+
import { composeEvents } from './events-ring';
|
|
24
26
|
import { handleSyncRequest } from './handler';
|
|
25
27
|
|
|
26
28
|
/** One app-shaped seed mutation — the same vocabulary as client mutations. */
|
|
@@ -54,6 +56,48 @@ export interface SeedTarget {
|
|
|
54
56
|
readonly commitId?: string;
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
export interface SeedMutationErrorOptions {
|
|
60
|
+
readonly clientId: string;
|
|
61
|
+
readonly clientCommitId: string;
|
|
62
|
+
readonly opIndex: number;
|
|
63
|
+
readonly code: string;
|
|
64
|
+
readonly replayed: boolean;
|
|
65
|
+
readonly retryable: boolean;
|
|
66
|
+
readonly message: string;
|
|
67
|
+
readonly recordedAtMs?: number;
|
|
68
|
+
readonly cacheIdentity?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Structured terminal failure from the real push path used by a seed. */
|
|
72
|
+
export class SeedMutationError extends Error {
|
|
73
|
+
override readonly name = 'SeedMutationError';
|
|
74
|
+
readonly clientId: string;
|
|
75
|
+
readonly clientCommitId: string;
|
|
76
|
+
readonly opIndex: number;
|
|
77
|
+
/** Exact protocol or host-validator rejection code. */
|
|
78
|
+
readonly code: string;
|
|
79
|
+
readonly replayed: boolean;
|
|
80
|
+
readonly retryable: boolean;
|
|
81
|
+
readonly recordedAtMs?: number;
|
|
82
|
+
readonly cacheIdentity?: string;
|
|
83
|
+
|
|
84
|
+
constructor(options: SeedMutationErrorOptions) {
|
|
85
|
+
super(options.message);
|
|
86
|
+
this.clientId = options.clientId;
|
|
87
|
+
this.clientCommitId = options.clientCommitId;
|
|
88
|
+
this.opIndex = options.opIndex;
|
|
89
|
+
this.code = options.code;
|
|
90
|
+
this.replayed = options.replayed;
|
|
91
|
+
this.retryable = options.retryable;
|
|
92
|
+
if (options.recordedAtMs !== undefined) {
|
|
93
|
+
this.recordedAtMs = options.recordedAtMs;
|
|
94
|
+
}
|
|
95
|
+
if (options.cacheIdentity !== undefined) {
|
|
96
|
+
this.cacheIdentity = options.cacheIdentity;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
57
101
|
const MAPPABLE_RE = /^_*[A-Za-z][A-Za-z0-9_]*$/;
|
|
58
102
|
|
|
59
103
|
/** Pinned §12 schema alias used by generated row types and every client host. */
|
|
@@ -79,8 +123,8 @@ function snakeToCamel(name: string): string {
|
|
|
79
123
|
|
|
80
124
|
/**
|
|
81
125
|
* Seed `mutations` into a partition through the real push path. Throws a
|
|
82
|
-
* `
|
|
83
|
-
* seed fails loud
|
|
126
|
+
* `SeedMutationError` when the push is rejected and `SyncError` for malformed
|
|
127
|
+
* helper input, so a broken seed fails loud instead of serving an empty store.
|
|
84
128
|
*/
|
|
85
129
|
export async function seedMutations(
|
|
86
130
|
config: SyncServerConfig,
|
|
@@ -154,13 +198,37 @@ export async function seedMutations(
|
|
|
154
198
|
accept: 0b0011,
|
|
155
199
|
},
|
|
156
200
|
];
|
|
201
|
+
type SeedTerminalEvent = Extract<
|
|
202
|
+
SyncularServerEvent,
|
|
203
|
+
{ type: 'push.rejected' | 'push.conflicted' }
|
|
204
|
+
>;
|
|
205
|
+
let terminalEvent: SeedTerminalEvent | undefined;
|
|
206
|
+
const capture: SyncularServerEvents = {
|
|
207
|
+
emit(event) {
|
|
208
|
+
if (
|
|
209
|
+
(event.type === 'push.rejected' || event.type === 'push.conflicted') &&
|
|
210
|
+
event.clientId === clientId &&
|
|
211
|
+
event.clientCommitId === clientCommitId
|
|
212
|
+
) {
|
|
213
|
+
terminalEvent = event;
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
};
|
|
157
217
|
const response = await handleSyncRequest(
|
|
158
218
|
encodeMessage({
|
|
159
219
|
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
160
220
|
msgKind: 'request',
|
|
161
221
|
frames,
|
|
162
222
|
}),
|
|
163
|
-
{
|
|
223
|
+
{
|
|
224
|
+
...config,
|
|
225
|
+
partition: target.partition,
|
|
226
|
+
actorId: target.actorId,
|
|
227
|
+
events:
|
|
228
|
+
config.events === undefined
|
|
229
|
+
? capture
|
|
230
|
+
: composeEvents(config.events, capture),
|
|
231
|
+
},
|
|
164
232
|
);
|
|
165
233
|
|
|
166
234
|
// Fail loud: surface the first rejected/failed operation.
|
|
@@ -177,13 +245,30 @@ export async function seedMutations(
|
|
|
177
245
|
}
|
|
178
246
|
if (result.status === 'rejected') {
|
|
179
247
|
const failed = result.results.find((r) => r.status !== 'applied');
|
|
248
|
+
const code = failed?.code ?? 'sync.invalid_request';
|
|
249
|
+
const opIndex = failed?.opIndex ?? 0;
|
|
250
|
+
const retryable = failed?.status === 'error' ? failed.retryable : false;
|
|
180
251
|
const detail =
|
|
181
|
-
failed
|
|
182
|
-
?
|
|
183
|
-
:
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
252
|
+
failed === undefined
|
|
253
|
+
? ''
|
|
254
|
+
: ` (op ${opIndex}: ${code} — ${failed.message})`;
|
|
255
|
+
const replayed = terminalEvent?.replay ?? false;
|
|
256
|
+
throw new SeedMutationError({
|
|
257
|
+
clientId,
|
|
258
|
+
clientCommitId,
|
|
259
|
+
opIndex,
|
|
260
|
+
code,
|
|
261
|
+
replayed,
|
|
262
|
+
retryable,
|
|
263
|
+
message: `seedMutations: the seed commit was rejected${
|
|
264
|
+
replayed ? ' (cached replay)' : ''
|
|
265
|
+
}${detail}`,
|
|
266
|
+
...(terminalEvent?.recordedAtMs !== undefined
|
|
267
|
+
? { recordedAtMs: terminalEvent.recordedAtMs }
|
|
268
|
+
: {}),
|
|
269
|
+
...(terminalEvent?.cacheIdentity !== undefined
|
|
270
|
+
? { cacheIdentity: terminalEvent.cacheIdentity }
|
|
271
|
+
: {}),
|
|
272
|
+
});
|
|
188
273
|
}
|
|
189
274
|
}
|
package/src/sqlite-dialect.ts
CHANGED
|
@@ -134,6 +134,12 @@ export function serializePushResult(result: StoredPushResult): string {
|
|
|
134
134
|
return JSON.stringify({
|
|
135
135
|
status: result.status,
|
|
136
136
|
...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
|
|
137
|
+
...(result.recordedAtMs !== undefined
|
|
138
|
+
? { recordedAtMs: result.recordedAtMs }
|
|
139
|
+
: {}),
|
|
140
|
+
...(result.cacheIdentity !== undefined
|
|
141
|
+
? { cacheIdentity: result.cacheIdentity }
|
|
142
|
+
: {}),
|
|
137
143
|
results: result.results.map((record) => {
|
|
138
144
|
if (record.status === 'conflict') {
|
|
139
145
|
return {
|
|
@@ -164,6 +170,8 @@ export function deserializePushResult(text: string): StoredPushResult {
|
|
|
164
170
|
const parsed = JSON.parse(text) as {
|
|
165
171
|
status: 'applied' | 'rejected';
|
|
166
172
|
commitSeq?: number;
|
|
173
|
+
recordedAtMs?: number;
|
|
174
|
+
cacheIdentity?: string;
|
|
167
175
|
results: SerializedResult[];
|
|
168
176
|
};
|
|
169
177
|
const results: PushOperationResult[] = parsed.results.map((record) => {
|
|
@@ -192,6 +200,12 @@ export function deserializePushResult(text: string): StoredPushResult {
|
|
|
192
200
|
return {
|
|
193
201
|
status: parsed.status,
|
|
194
202
|
...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
|
|
203
|
+
...(parsed.recordedAtMs !== undefined
|
|
204
|
+
? { recordedAtMs: parsed.recordedAtMs }
|
|
205
|
+
: {}),
|
|
206
|
+
...(parsed.cacheIdentity !== undefined
|
|
207
|
+
? { cacheIdentity: parsed.cacheIdentity }
|
|
208
|
+
: {}),
|
|
195
209
|
results,
|
|
196
210
|
};
|
|
197
211
|
}
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
commitWindowPageSql,
|
|
13
13
|
deleteRowSql,
|
|
14
14
|
dropTableDdl,
|
|
15
|
+
indexRowPageStatement,
|
|
15
16
|
layoutsOf,
|
|
16
17
|
migratePayload,
|
|
17
18
|
parseLayouts,
|
|
@@ -47,6 +48,7 @@ import type {
|
|
|
47
48
|
CommitMetadata,
|
|
48
49
|
CommitMetadataQuery,
|
|
49
50
|
CommitWindowQuery,
|
|
51
|
+
IndexRowScanQuery,
|
|
50
52
|
NewCommit,
|
|
51
53
|
RowScanQuery,
|
|
52
54
|
ScopeActivityQuery,
|
|
@@ -61,6 +63,7 @@ import {
|
|
|
61
63
|
isSqliteConstraintError,
|
|
62
64
|
StorageConstraintError,
|
|
63
65
|
} from './storage-errors';
|
|
66
|
+
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query';
|
|
64
67
|
|
|
65
68
|
class SqliteTransaction implements StorageTransaction {
|
|
66
69
|
#storage: SqliteServerStorage;
|
|
@@ -88,6 +91,11 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
88
91
|
return this.#storage.scanRows(this.#partition, query);
|
|
89
92
|
}
|
|
90
93
|
|
|
94
|
+
scanRowsByIndex(query: IndexRowScanQuery): Promise<StoredRow[]> {
|
|
95
|
+
this.#assertOpen();
|
|
96
|
+
return this.#storage.scanRowsByIndex(this.#partition, query);
|
|
97
|
+
}
|
|
98
|
+
|
|
91
99
|
async lockPartitionForCommitValidation(): Promise<void> {
|
|
92
100
|
this.#assertOpen();
|
|
93
101
|
// BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
|
|
@@ -564,6 +572,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
564
572
|
}
|
|
565
573
|
|
|
566
574
|
async scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]> {
|
|
575
|
+
assertScopeIndexedScan(query);
|
|
567
576
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
568
577
|
const firstVariable = variables[0];
|
|
569
578
|
if (firstVariable === undefined) return [];
|
|
@@ -611,6 +620,35 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
611
620
|
return rows;
|
|
612
621
|
}
|
|
613
622
|
|
|
623
|
+
async scanRowsByIndex(
|
|
624
|
+
partition: string,
|
|
625
|
+
query: IndexRowScanQuery,
|
|
626
|
+
): Promise<StoredRow[]> {
|
|
627
|
+
const table = this.table(query.table);
|
|
628
|
+
const index = resolveIndexRowScan(table, query);
|
|
629
|
+
const statement = indexRowPageStatement(
|
|
630
|
+
table,
|
|
631
|
+
index,
|
|
632
|
+
query.values,
|
|
633
|
+
partition,
|
|
634
|
+
query.afterRowId,
|
|
635
|
+
query.limit,
|
|
636
|
+
'sqlite',
|
|
637
|
+
);
|
|
638
|
+
const params = statement.params as readonly (
|
|
639
|
+
| string
|
|
640
|
+
| number
|
|
641
|
+
| Uint8Array
|
|
642
|
+
| null
|
|
643
|
+
)[];
|
|
644
|
+
const records = this.db
|
|
645
|
+
.query<SqliteRowRecord, (string | number | Uint8Array | null)[]>(
|
|
646
|
+
statement.sql,
|
|
647
|
+
)
|
|
648
|
+
.all(...params);
|
|
649
|
+
return records.map(toStoredRow);
|
|
650
|
+
}
|
|
651
|
+
|
|
614
652
|
async getClientRecord(
|
|
615
653
|
partition: string,
|
|
616
654
|
clientId: string,
|