@powersync/service-module-mongodb 0.21.0 → 0.21.2
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/CHANGELOG.md +41 -0
- package/ci/test-connection.yaml +12 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
- package/dist/api/MongoRouteAPIAdapter.js +73 -143
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/api/infer-collection-schema.d.ts +12 -0
- package/dist/api/infer-collection-schema.js +142 -0
- package/dist/api/infer-collection-schema.js.map +1 -0
- package/dist/module/MongoModule.d.ts +0 -1
- package/dist/module/MongoModule.js +0 -1
- package/dist/module/MongoModule.js.map +1 -1
- package/dist/replication/ChangeStream.js +1 -4
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/MongoRelation.js +5 -2
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoSnapshotter.js +0 -3
- package/dist/replication/MongoSnapshotter.js.map +1 -1
- package/dist/replication/RawChangeStream.d.ts +3 -11
- package/dist/replication/RawChangeStream.js +0 -17
- package/dist/replication/RawChangeStream.js.map +1 -1
- package/dist/replication/SourceRowConverter.d.ts +1 -0
- package/dist/replication/SourceRowConverter.js +4 -2
- package/dist/replication/SourceRowConverter.js.map +1 -1
- package/dist/replication/bufferToSqlite.d.ts +3 -1
- package/dist/replication/bufferToSqlite.js +35 -23
- package/dist/replication/bufferToSqlite.js.map +1 -1
- package/package.json +9 -9
- package/src/api/MongoRouteAPIAdapter.ts +79 -127
- package/src/api/infer-collection-schema.ts +153 -0
- package/src/module/MongoModule.ts +0 -2
- package/src/replication/ChangeStream.ts +1 -4
- package/src/replication/MongoRelation.ts +8 -1
- package/src/replication/MongoSnapshotter.ts +0 -3
- package/src/replication/RawChangeStream.ts +3 -30
- package/src/replication/SourceRowConverter.ts +4 -2
- package/src/replication/bufferToSqlite.ts +43 -23
- package/test/src/buffer_to_sqlite.test.ts +9 -0
- package/test/src/change_stream_utils.ts +4 -4
- package/test/src/documentdb_mode.test.ts +1 -2
- package/test/src/raw_change_stream.test.ts +0 -63
- package/test/src/schema.test.ts +223 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import * as lib_mongo from '@powersync/lib-service-mongodb';
|
|
2
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
3
|
+
import { ExpressionType } from '@powersync/service-sync-rules';
|
|
4
|
+
import { TableSchema } from '@powersync/service-types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Server errors that mean this one collection could not be inspected, rather than the
|
|
8
|
+
* connection being unusable.
|
|
9
|
+
*
|
|
10
|
+
* These come from the memory ceiling {@link inferCollectionSchema} imposes on itself by
|
|
11
|
+
* refusing to spill to disk, and are deterministic per collection. Timeouts are
|
|
12
|
+
* deliberately excluded: they usually indicate an overloaded source, where degrading
|
|
13
|
+
* would silently empty the schema of every collection at once.
|
|
14
|
+
*/
|
|
15
|
+
const SCHEMA_INFERENCE_FAILURE_CODES = new Set([
|
|
16
|
+
// ExceededMemoryLimit - a blocking stage ran out of memory with disk spill disabled.
|
|
17
|
+
146,
|
|
18
|
+
// QueryExceededMemoryLimitNoDiskUseAllowed - as above, reported by the sort $sample
|
|
19
|
+
// uses on collections too small for its random cursor.
|
|
20
|
+
292
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether the error means we could not determine this collection's fields, as opposed to
|
|
25
|
+
* something that makes the rest of the schema unreliable too.
|
|
26
|
+
*/
|
|
27
|
+
export function isSchemaInferenceFailure(e: unknown): boolean {
|
|
28
|
+
return lib_mongo.isMongoServerError(e) && SCHEMA_INFERENCE_FAILURE_CODES.has(e.code as number);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Match the types exposed after BSON deserialization and conversion to sync rules values.
|
|
32
|
+
const BSON_TYPES: Record<string, { name: string; sqliteType: ExpressionType }> = {
|
|
33
|
+
int: { name: 'Integer', sqliteType: ExpressionType.INTEGER },
|
|
34
|
+
long: { name: 'Long', sqliteType: ExpressionType.INTEGER },
|
|
35
|
+
double: { name: 'Double', sqliteType: ExpressionType.REAL },
|
|
36
|
+
decimal: { name: 'Decimal', sqliteType: ExpressionType.TEXT },
|
|
37
|
+
string: { name: 'String', sqliteType: ExpressionType.TEXT },
|
|
38
|
+
symbol: { name: 'String', sqliteType: ExpressionType.TEXT },
|
|
39
|
+
bool: { name: 'Boolean', sqliteType: ExpressionType.INTEGER },
|
|
40
|
+
objectId: { name: 'ObjectId', sqliteType: ExpressionType.TEXT },
|
|
41
|
+
uuid: { name: 'UUID', sqliteType: ExpressionType.TEXT },
|
|
42
|
+
binData: { name: 'Binary', sqliteType: ExpressionType.BLOB },
|
|
43
|
+
date: { name: 'Date', sqliteType: ExpressionType.TEXT },
|
|
44
|
+
timestamp: { name: 'Timestamp', sqliteType: ExpressionType.INTEGER },
|
|
45
|
+
regex: { name: 'RegExp', sqliteType: ExpressionType.TEXT },
|
|
46
|
+
object: { name: 'Object', sqliteType: ExpressionType.TEXT },
|
|
47
|
+
array: { name: 'Array', sqliteType: ExpressionType.TEXT },
|
|
48
|
+
javascript: { name: 'Object', sqliteType: ExpressionType.TEXT },
|
|
49
|
+
javascriptWithScope: { name: 'Object', sqliteType: ExpressionType.TEXT },
|
|
50
|
+
dbPointer: { name: 'Object', sqliteType: ExpressionType.TEXT },
|
|
51
|
+
null: { name: 'Null', sqliteType: ExpressionType.NONE },
|
|
52
|
+
undefined: { name: 'Null', sqliteType: ExpressionType.NONE },
|
|
53
|
+
minKey: { name: 'MinKey', sqliteType: ExpressionType.NONE },
|
|
54
|
+
maxKey: { name: 'MaxKey', sqliteType: ExpressionType.NONE }
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Infer top-level fields without transferring sampled document values to the service.
|
|
59
|
+
* Memory on the service scales with the inferred schema, not the size of those values.
|
|
60
|
+
*/
|
|
61
|
+
export async function inferCollectionSchema(
|
|
62
|
+
collection: mongo.Collection,
|
|
63
|
+
isDocumentDb: boolean
|
|
64
|
+
): Promise<TableSchema['columns']> {
|
|
65
|
+
const fields = await collection
|
|
66
|
+
.aggregate<{ _id: string; types: string[] }>(
|
|
67
|
+
[
|
|
68
|
+
// Keep this first so MongoDB can use its random cursor on large collections.
|
|
69
|
+
{ $sample: { size: 50 } },
|
|
70
|
+
{
|
|
71
|
+
$project: {
|
|
72
|
+
_id: 0,
|
|
73
|
+
fields: {
|
|
74
|
+
$map: {
|
|
75
|
+
input: { $objectToArray: '$$ROOT' },
|
|
76
|
+
as: 'field',
|
|
77
|
+
in: {
|
|
78
|
+
name: '$$field.k',
|
|
79
|
+
type: {
|
|
80
|
+
$let: {
|
|
81
|
+
vars: { bsonType: { $type: '$$field.v' } },
|
|
82
|
+
in: {
|
|
83
|
+
$switch: {
|
|
84
|
+
branches: [
|
|
85
|
+
{
|
|
86
|
+
case: { $eq: ['$$bsonType', 'double'] },
|
|
87
|
+
// Whole-number doubles are replicated as SQLite integers.
|
|
88
|
+
then: { $cond: [{ $eq: [{ $mod: ['$$field.v', 1] }, 0] }, 'int', 'double'] }
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
case: { $eq: ['$$bsonType', 'binData'] },
|
|
92
|
+
then: {
|
|
93
|
+
$cond: [
|
|
94
|
+
// BinData compares by length, then subtype, then bytes. Only
|
|
95
|
+
// 16-byte values of subtype 4 fall within this UUID range.
|
|
96
|
+
// This works without transferring binary values or requiring
|
|
97
|
+
// the newer MongoDB binary conversion operators.
|
|
98
|
+
// https://www.mongodb.com/docs/manual/reference/bson-type-comparison-order/#bindata
|
|
99
|
+
{
|
|
100
|
+
$and: [
|
|
101
|
+
{ $gte: ['$$field.v', new mongo.UUID('00000000-0000-0000-0000-000000000000')] },
|
|
102
|
+
{ $lte: ['$$field.v', new mongo.UUID('ffffffff-ffff-ffff-ffff-ffffffffffff')] }
|
|
103
|
+
]
|
|
104
|
+
},
|
|
105
|
+
'uuid',
|
|
106
|
+
'binData'
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
default: '$$bsonType'
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
// Discard values before unwinding so large documents aren't duplicated per field.
|
|
122
|
+
{ $unwind: '$fields' },
|
|
123
|
+
{ $group: { _id: '$fields.name', types: { $addToSet: '$fields.type' } } }
|
|
124
|
+
],
|
|
125
|
+
{
|
|
126
|
+
// Bound execution per collection below the default 60-second socket timeout
|
|
127
|
+
// so MongoDB can return a query timeout before the connection times out.
|
|
128
|
+
maxTimeMS: 30_000,
|
|
129
|
+
// Small collections can require $sample to sort full documents. Disable
|
|
130
|
+
// disk spill explicitly so concurrent schema queries fail at the memory
|
|
131
|
+
// limit without adding temporary-file I/O on the source database.
|
|
132
|
+
allowDiskUse: false,
|
|
133
|
+
// Field names are case-sensitive even when the collection's default collation isn't.
|
|
134
|
+
// DocumentDB rejects the collation option, including simple collation.
|
|
135
|
+
...(isDocumentDb ? {} : { collation: { locale: 'simple' } })
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
.toArray();
|
|
139
|
+
|
|
140
|
+
return fields
|
|
141
|
+
.map(({ _id: name, types }) => {
|
|
142
|
+
let sqliteType = ExpressionType.NONE;
|
|
143
|
+
const bsonTypes = new Set<string>();
|
|
144
|
+
for (const type of types) {
|
|
145
|
+
const inferred = BSON_TYPES[type];
|
|
146
|
+
sqliteType = sqliteType.or(inferred.sqliteType);
|
|
147
|
+
bsonTypes.add(inferred.name);
|
|
148
|
+
}
|
|
149
|
+
const internal_type = [...bsonTypes].sort().join(' | ');
|
|
150
|
+
return { name, type: internal_type, sqlite_type: sqliteType.typeFlags, internal_type, pg_type: internal_type };
|
|
151
|
+
})
|
|
152
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
153
|
+
}
|
|
@@ -213,7 +213,7 @@ export class ChangeStream {
|
|
|
213
213
|
this.isDocumentDb = await detectDocumentDb(this.defaultDb);
|
|
214
214
|
if (this.isDocumentDb) {
|
|
215
215
|
this.logger.warn(
|
|
216
|
-
'Azure DocumentDB support is
|
|
216
|
+
'Azure DocumentDB support is in alpha. APIs and behavior may change, and long-term stability is not yet guaranteed.'
|
|
217
217
|
);
|
|
218
218
|
}
|
|
219
219
|
this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
|
|
@@ -595,9 +595,6 @@ export class ChangeStream {
|
|
|
595
595
|
return rawChangeStream(watchDb, pipeline, {
|
|
596
596
|
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
597
597
|
maxAwaitTimeMS,
|
|
598
|
-
// maxAwaitTimeMS can be 0 for probe-style streams that do not want an idle wait.
|
|
599
|
-
// In that case there is no client-side wait to emulate for DocumentDB.
|
|
600
|
-
clientSideMaxAwaitTimeMS: this.isDocumentDb && maxAwaitTimeMS > 0,
|
|
601
598
|
maxTimeMS: this.changeStreamTimeout,
|
|
602
599
|
|
|
603
600
|
signal: options.signal,
|
|
@@ -3,11 +3,14 @@ import { storage } from '@powersync/service-core';
|
|
|
3
3
|
import { JsonContainer } from '@powersync/service-jsonbig';
|
|
4
4
|
import {
|
|
5
5
|
CompatibilityContext,
|
|
6
|
+
CompatibilityOption,
|
|
6
7
|
CustomArray,
|
|
7
8
|
CustomObject,
|
|
8
9
|
CustomSqliteValue,
|
|
9
10
|
DateTimeSourceOptions,
|
|
10
11
|
DateTimeValue,
|
|
12
|
+
SQLITE_FALSE,
|
|
13
|
+
SQLITE_TRUE,
|
|
11
14
|
SqliteInputRow,
|
|
12
15
|
SqliteInputValue,
|
|
13
16
|
TimeValuePrecision
|
|
@@ -127,7 +130,11 @@ function filterJsonData(data: any, context: CompatibilityContext, depth = 0): an
|
|
|
127
130
|
return data;
|
|
128
131
|
}
|
|
129
132
|
} else if (typeof data == 'boolean') {
|
|
130
|
-
|
|
133
|
+
if (context.isEnabled(CompatibilityOption.fixedBooleanInJson)) {
|
|
134
|
+
return data;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return data ? SQLITE_TRUE : SQLITE_FALSE;
|
|
131
138
|
} else if (typeof data == 'bigint') {
|
|
132
139
|
return data;
|
|
133
140
|
} else if (data instanceof Date) {
|
|
@@ -785,9 +785,6 @@ export class MongoSnapshotter {
|
|
|
785
785
|
return rawChangeStream(watchDb, pipeline, {
|
|
786
786
|
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
787
787
|
maxAwaitTimeMS,
|
|
788
|
-
// maxAwaitTimeMS can be 0 for probe-style streams that do not want an idle wait.
|
|
789
|
-
// In that case there is no client-side wait to emulate for DocumentDB.
|
|
790
|
-
clientSideMaxAwaitTimeMS: this.isDocumentDb && maxAwaitTimeMS > 0,
|
|
791
788
|
maxTimeMS: this.changeStreamTimeout,
|
|
792
789
|
signal: options.signal,
|
|
793
790
|
logger: this.logger,
|
|
@@ -6,14 +6,8 @@ import {
|
|
|
6
6
|
ReplicationAssertionError
|
|
7
7
|
} from '@powersync/lib-services-framework';
|
|
8
8
|
import { PerformanceTracer } from '@powersync/service-core';
|
|
9
|
-
import { performance } from 'node:perf_hooks';
|
|
10
|
-
import { setTimeout as delay } from 'node:timers/promises';
|
|
11
9
|
import { ChangeStreamInvalidatedError } from './ChangeStream.js';
|
|
12
10
|
|
|
13
|
-
// Keep the DocumentDB idle-poll workaround from adding the full maxAwaitTimeMS
|
|
14
|
-
// as local latency when an update arrives just after an empty batch.
|
|
15
|
-
const CLIENT_SIDE_MAX_AWAIT_TIME_MS_DELAY_CAP_MS = 1_000;
|
|
16
|
-
|
|
17
11
|
export interface RawChangeStreamOptions {
|
|
18
12
|
signal?: AbortSignal;
|
|
19
13
|
|
|
@@ -21,21 +15,12 @@ export interface RawChangeStreamOptions {
|
|
|
21
15
|
* How long to wait for new data per batch (max time for long-polling).
|
|
22
16
|
* This is sent as maxTimeMS for the getMore command.
|
|
23
17
|
*
|
|
24
|
-
* A value of 0
|
|
25
|
-
*
|
|
18
|
+
* A value of 0 removes the explicit await limit and leaves the server's
|
|
19
|
+
* default awaitData behavior in effect; it does not make getMore return
|
|
20
|
+
* immediately. Snapshot probes use 0 for this purpose.
|
|
26
21
|
*/
|
|
27
22
|
maxAwaitTimeMS: number;
|
|
28
23
|
|
|
29
|
-
/**
|
|
30
|
-
* Also enforce maxAwaitTimeMS on the client for empty getMore batches.
|
|
31
|
-
*
|
|
32
|
-
* Azure DocumentDB currently returns idle getMore calls before maxTimeMS. When
|
|
33
|
-
* this is enabled, empty batches are delayed locally (capped at 1s) to avoid
|
|
34
|
-
* tight polling. We still send maxTimeMS so this remains compatible with
|
|
35
|
-
* servers that handle maxAwaitTimeMS correctly.
|
|
36
|
-
*/
|
|
37
|
-
clientSideMaxAwaitTimeMS?: boolean;
|
|
38
|
-
|
|
39
24
|
/**
|
|
40
25
|
* Timeout for the initial aggregate command.
|
|
41
26
|
*/
|
|
@@ -230,17 +215,12 @@ async function* rawChangeStreamInner(
|
|
|
230
215
|
options.signal?.throwIfAborted();
|
|
231
216
|
|
|
232
217
|
using commandSpan = options.tracer?.span('changestream', 'getmore');
|
|
233
|
-
const getMoreStartedAt = performance.now();
|
|
234
218
|
const getMoreCommand: mongo.Document = {
|
|
235
219
|
getMore: cursorId,
|
|
236
220
|
collection: nsCollection,
|
|
237
221
|
batchSize: batchSizer.next(),
|
|
238
222
|
maxTimeMS: options.maxAwaitTimeMS
|
|
239
223
|
};
|
|
240
|
-
// Azure DocumentDB currently returns empty getMore batches before
|
|
241
|
-
// maxTimeMS expires. Keep maxTimeMS for forward compatibility with the
|
|
242
|
-
// server-side behavior, and when client-side mode is enabled, enforce the
|
|
243
|
-
// capped idle wait locally for empty batches below.
|
|
244
224
|
const getMoreResult: mongo.Document = await db.command(getMoreCommand, { session, raw: true }).catch((e) => {
|
|
245
225
|
if (isMongoServerError(e) && e.codeName == 'CursorKilled') {
|
|
246
226
|
// This may be due to the killCursors command issued when aborting.
|
|
@@ -268,13 +248,6 @@ async function* rawChangeStreamInner(
|
|
|
268
248
|
// postBatchResumeToken is returned in MongoDB 4.0.7 and later, and we support 6.0+
|
|
269
249
|
throw new ReplicationAssertionError(`postBatchResumeToken from aggregate response`);
|
|
270
250
|
}
|
|
271
|
-
if (options.clientSideMaxAwaitTimeMS && nextBatch.length == 0) {
|
|
272
|
-
const remainingMaxAwaitTimeMS = Math.ceil(options.maxAwaitTimeMS - (performance.now() - getMoreStartedAt));
|
|
273
|
-
if (remainingMaxAwaitTimeMS > 0) {
|
|
274
|
-
const clientSideDelayMs = Math.min(remainingMaxAwaitTimeMS, CLIENT_SIDE_MAX_AWAIT_TIME_MS_DELAY_CAP_MS);
|
|
275
|
-
await delay(clientSideDelayMs, undefined, { signal: options.signal });
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
251
|
yield {
|
|
279
252
|
events: nextBatch,
|
|
280
253
|
resumeToken: cursor.postBatchResumeToken,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
|
-
import { applyRowContext, CompatibilityContext, SqliteRow } from '@powersync/service-sync-rules';
|
|
2
|
+
import { applyRowContext, CompatibilityContext, CompatibilityOption, SqliteRow } from '@powersync/service-sync-rules';
|
|
3
3
|
import { bufferToSqlite, DateRenderMode, getDateRenderMode, parseDocumentId } from './bufferToSqlite.js';
|
|
4
4
|
import { constructAfterRecord } from './MongoRelation.js';
|
|
5
5
|
|
|
@@ -46,13 +46,15 @@ export class LegacySourceRowConverter implements SourceRowConverter {
|
|
|
46
46
|
|
|
47
47
|
export class DirectSourceRowConverter implements SourceRowConverter {
|
|
48
48
|
private readonly dateRenderMode: DateRenderMode;
|
|
49
|
+
private readonly fixedBooleansInJson: boolean;
|
|
49
50
|
|
|
50
51
|
constructor(compatibilityContext: CompatibilityContext) {
|
|
51
52
|
this.dateRenderMode = getDateRenderMode(compatibilityContext);
|
|
53
|
+
this.fixedBooleansInJson = compatibilityContext.isEnabled(CompatibilityOption.fixedBooleanInJson);
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
rawToSqliteRow(source: Buffer): { row: SqliteRow; replicaId: any } {
|
|
55
|
-
const row = bufferToSqlite(source, this.dateRenderMode);
|
|
57
|
+
const row = bufferToSqlite(source, this.dateRenderMode, this.fixedBooleansInJson);
|
|
56
58
|
const replicaId = parseDocumentId(source).id;
|
|
57
59
|
return { row, replicaId };
|
|
58
60
|
}
|
|
@@ -80,12 +80,16 @@ const SHARED_WRITER = new JsonBufferWriter();
|
|
|
80
80
|
*
|
|
81
81
|
* @param bytes the source BSON bytes
|
|
82
82
|
* @param dateRenderMode derive using getDateRenderMode(compatibilityContext)
|
|
83
|
+
* @param fixedBooleansInJson whether to serialize booleans in nested JSON as booleans, derived from compatibility
|
|
84
|
+
* context.
|
|
83
85
|
*
|
|
84
86
|
* @returns a SqliteRow
|
|
85
87
|
*/
|
|
86
|
-
export function bufferToSqlite(bytes: Buffer, dateRenderMode: DateRenderMode): SqliteRow {
|
|
88
|
+
export function bufferToSqlite(bytes: Buffer, dateRenderMode: DateRenderMode, fixedBooleansInJson: boolean): SqliteRow {
|
|
87
89
|
const row: SqliteRow = {};
|
|
88
90
|
const jsonWriter = SHARED_WRITER;
|
|
91
|
+
const options: SerializeToJsonOptions = { dateRenderMode, fixedBooleans: fixedBooleansInJson };
|
|
92
|
+
|
|
89
93
|
// BSON documents are length-prefixed and null-terminated. We parse directly
|
|
90
94
|
// from raw bytes, so structural validation happens here rather than in the
|
|
91
95
|
// upstream BSON decoder.
|
|
@@ -112,14 +116,14 @@ export function bufferToSqlite(bytes: Buffer, dateRenderMode: DateRenderMode): S
|
|
|
112
116
|
}
|
|
113
117
|
case BSON_TYPE_ARRAY: {
|
|
114
118
|
jsonWriter.reset();
|
|
115
|
-
const result = serializeNestedArrayToJson(bytes, offset, 0, jsonWriter,
|
|
119
|
+
const result = serializeNestedArrayToJson(bytes, offset, 0, jsonWriter, options);
|
|
116
120
|
row[key] = jsonWriter.toString();
|
|
117
121
|
offset = result.nextOffset;
|
|
118
122
|
break;
|
|
119
123
|
}
|
|
120
124
|
case BSON_TYPE_DOCUMENT: {
|
|
121
125
|
jsonWriter.reset();
|
|
122
|
-
const result = serializeNestedObjectToJson(bytes, offset, 0, jsonWriter,
|
|
126
|
+
const result = serializeNestedObjectToJson(bytes, offset, 0, jsonWriter, options);
|
|
123
127
|
row[key] = jsonWriter.toString();
|
|
124
128
|
offset = result.nextOffset;
|
|
125
129
|
break;
|
|
@@ -195,7 +199,7 @@ export function bufferToSqlite(bytes: Buffer, dateRenderMode: DateRenderMode): S
|
|
|
195
199
|
}
|
|
196
200
|
case BSON_TYPE_CODE_WITH_SCOPE: {
|
|
197
201
|
jsonWriter.reset();
|
|
198
|
-
const nextOffset = writeCodeWithScopeJson(bytes, offset, 0, jsonWriter,
|
|
202
|
+
const nextOffset = writeCodeWithScopeJson(bytes, offset, 0, jsonWriter, options);
|
|
199
203
|
row[key] = jsonWriter.toString();
|
|
200
204
|
offset = nextOffset;
|
|
201
205
|
break;
|
|
@@ -437,12 +441,17 @@ function skipBsonValue(bytes: Buffer, offset: number, type: number) {
|
|
|
437
441
|
}
|
|
438
442
|
}
|
|
439
443
|
|
|
444
|
+
interface SerializeToJsonOptions {
|
|
445
|
+
dateRenderMode: DateRenderMode;
|
|
446
|
+
fixedBooleans: boolean;
|
|
447
|
+
}
|
|
448
|
+
|
|
440
449
|
function serializeNestedObjectToJson(
|
|
441
450
|
bytes: Buffer,
|
|
442
451
|
offset: number,
|
|
443
452
|
depth: number,
|
|
444
453
|
writer: JsonBufferWriter,
|
|
445
|
-
|
|
454
|
+
options: SerializeToJsonOptions
|
|
446
455
|
): { nextOffset: number } {
|
|
447
456
|
if (depth > NESTED_DEPTH_LIMIT) {
|
|
448
457
|
throw new Error(`json nested object depth exceeds the limit of ${NESTED_DEPTH_LIMIT}`);
|
|
@@ -475,7 +484,7 @@ function serializeNestedObjectToJson(
|
|
|
475
484
|
type,
|
|
476
485
|
depth,
|
|
477
486
|
writer,
|
|
478
|
-
|
|
487
|
+
options
|
|
479
488
|
);
|
|
480
489
|
cursor = afterValue;
|
|
481
490
|
// Malformed BSON must fail fast instead of getting the parser stuck on the
|
|
@@ -499,7 +508,7 @@ function serializeNestedArrayToJson(
|
|
|
499
508
|
offset: number,
|
|
500
509
|
depth: number,
|
|
501
510
|
writer: JsonBufferWriter,
|
|
502
|
-
|
|
511
|
+
options: SerializeToJsonOptions
|
|
503
512
|
): { nextOffset: number } {
|
|
504
513
|
if (depth > NESTED_DEPTH_LIMIT) {
|
|
505
514
|
throw new Error(`json nested object depth exceeds the limit of ${NESTED_DEPTH_LIMIT}`);
|
|
@@ -527,7 +536,7 @@ function serializeNestedArrayToJson(
|
|
|
527
536
|
type,
|
|
528
537
|
depth,
|
|
529
538
|
writer,
|
|
530
|
-
|
|
539
|
+
options
|
|
531
540
|
);
|
|
532
541
|
cursor = afterValue;
|
|
533
542
|
assertAdvanced(previousCursor, cursor);
|
|
@@ -547,7 +556,7 @@ function serializeNestedElementValue(
|
|
|
547
556
|
type: number,
|
|
548
557
|
depth: number,
|
|
549
558
|
writer: JsonBufferWriter,
|
|
550
|
-
|
|
559
|
+
options: SerializeToJsonOptions
|
|
551
560
|
): { nextOffset: number; defined: boolean } {
|
|
552
561
|
switch (type) {
|
|
553
562
|
case BSON_TYPE_DOUBLE: // Double
|
|
@@ -555,9 +564,9 @@ function serializeNestedElementValue(
|
|
|
555
564
|
case BSON_TYPE_STRING: // String
|
|
556
565
|
return serializeNestedStringElement(bytes, offset, writer);
|
|
557
566
|
case BSON_TYPE_DOCUMENT: // Embedded document
|
|
558
|
-
return serializeNestedObjectElement(bytes, offset, depth, writer,
|
|
567
|
+
return serializeNestedObjectElement(bytes, offset, depth, writer, options);
|
|
559
568
|
case BSON_TYPE_ARRAY: // Array
|
|
560
|
-
return serializeNestedArrayElement(bytes, offset, depth, writer,
|
|
569
|
+
return serializeNestedArrayElement(bytes, offset, depth, writer, options);
|
|
561
570
|
case BSON_TYPE_BINARY: // Binary
|
|
562
571
|
return serializeNestedBinaryElement(bytes, offset, writer);
|
|
563
572
|
case BSON_TYPE_UNDEFINED: // Undefined
|
|
@@ -567,11 +576,22 @@ function serializeNestedElementValue(
|
|
|
567
576
|
writer.writeQuotedHexLower(bytes, offset, 12);
|
|
568
577
|
return { nextOffset: offset + 12, defined: true };
|
|
569
578
|
}
|
|
570
|
-
case BSON_TYPE_BOOLEAN:
|
|
571
|
-
|
|
579
|
+
case BSON_TYPE_BOOLEAN: {
|
|
580
|
+
// Boolean
|
|
581
|
+
const value = !!bytes[offset];
|
|
582
|
+
|
|
583
|
+
if (options.fixedBooleans) {
|
|
584
|
+
const str = value ? 'true' : 'false';
|
|
585
|
+
|
|
586
|
+
writer.writeAscii(str);
|
|
587
|
+
} else {
|
|
588
|
+
writer.writeByte(value ? BYTE_ONE : BYTE_ZERO);
|
|
589
|
+
}
|
|
590
|
+
|
|
572
591
|
return { nextOffset: offset + 1, defined: true };
|
|
592
|
+
}
|
|
573
593
|
case BSON_TYPE_UTC_DATETIME: // UTC datetime
|
|
574
|
-
return serializeNestedDateTimeElement(bytes, offset, writer, dateRenderMode);
|
|
594
|
+
return serializeNestedDateTimeElement(bytes, offset, writer, options.dateRenderMode);
|
|
575
595
|
case BSON_TYPE_NULL: // Null
|
|
576
596
|
case BSON_TYPE_MIN_KEY: // MinKey
|
|
577
597
|
case BSON_TYPE_MAX_KEY: // MaxKey
|
|
@@ -586,7 +606,7 @@ function serializeNestedElementValue(
|
|
|
586
606
|
case BSON_TYPE_SYMBOL: // Symbol
|
|
587
607
|
return serializeNestedSymbolElement(bytes, offset, writer);
|
|
588
608
|
case BSON_TYPE_CODE_WITH_SCOPE: // JavaScript code with scope
|
|
589
|
-
return serializeNestedCodeWithScopeElement(bytes, offset, depth, writer,
|
|
609
|
+
return serializeNestedCodeWithScopeElement(bytes, offset, depth, writer, options);
|
|
590
610
|
case BSON_TYPE_INT32: {
|
|
591
611
|
// Int32
|
|
592
612
|
writer.writeAscii(String(readInt32LE(bytes, offset)));
|
|
@@ -641,9 +661,9 @@ function serializeNestedObjectElement(
|
|
|
641
661
|
offset: number,
|
|
642
662
|
depth: number,
|
|
643
663
|
writer: JsonBufferWriter,
|
|
644
|
-
|
|
664
|
+
options: SerializeToJsonOptions
|
|
645
665
|
): { nextOffset: number; defined: boolean } {
|
|
646
|
-
const result = serializeNestedObjectToJson(bytes, offset, depth + 1, writer,
|
|
666
|
+
const result = serializeNestedObjectToJson(bytes, offset, depth + 1, writer, options);
|
|
647
667
|
return { nextOffset: result.nextOffset, defined: true };
|
|
648
668
|
}
|
|
649
669
|
|
|
@@ -652,9 +672,9 @@ function serializeNestedArrayElement(
|
|
|
652
672
|
offset: number,
|
|
653
673
|
depth: number,
|
|
654
674
|
writer: JsonBufferWriter,
|
|
655
|
-
|
|
675
|
+
options: SerializeToJsonOptions
|
|
656
676
|
): { nextOffset: number; defined: boolean } {
|
|
657
|
-
const result = serializeNestedArrayToJson(bytes, offset, depth + 1, writer,
|
|
677
|
+
const result = serializeNestedArrayToJson(bytes, offset, depth + 1, writer, options);
|
|
658
678
|
return { nextOffset: result.nextOffset, defined: true };
|
|
659
679
|
}
|
|
660
680
|
|
|
@@ -791,14 +811,14 @@ function writeCodeWithScopeJson(
|
|
|
791
811
|
offset: number,
|
|
792
812
|
depth: number,
|
|
793
813
|
writer: JsonBufferWriter,
|
|
794
|
-
|
|
814
|
+
options: SerializeToJsonOptions
|
|
795
815
|
) {
|
|
796
816
|
const totalLength = readInt32LE(bytes, offset);
|
|
797
817
|
const { value: code, nextOffset: afterCode } = readBsonString(bytes, offset + 4);
|
|
798
818
|
writer.writeAscii('{"code":');
|
|
799
819
|
writer.writeQuotedJsonString(code);
|
|
800
820
|
writer.writeAscii(',"scope":');
|
|
801
|
-
serializeNestedObjectToJson(bytes, afterCode, depth + 1, writer,
|
|
821
|
+
serializeNestedObjectToJson(bytes, afterCode, depth + 1, writer, options);
|
|
802
822
|
writer.writeByte(BYTE_RBRACE);
|
|
803
823
|
// code_w_scope carries its own total byte length, so we trust that wrapper
|
|
804
824
|
// rather than reconstructing the end position from the nested scope.
|
|
@@ -885,10 +905,10 @@ function serializeNestedCodeWithScopeElement(
|
|
|
885
905
|
offset: number,
|
|
886
906
|
depth: number,
|
|
887
907
|
writer: JsonBufferWriter,
|
|
888
|
-
|
|
908
|
+
options: SerializeToJsonOptions
|
|
889
909
|
): { nextOffset: number; defined: boolean } {
|
|
890
910
|
return {
|
|
891
|
-
nextOffset: writeCodeWithScopeJson(bytes, offset, depth, writer,
|
|
911
|
+
nextOffset: writeCodeWithScopeJson(bytes, offset, depth, writer, options),
|
|
892
912
|
defined: true
|
|
893
913
|
};
|
|
894
914
|
}
|
|
@@ -121,6 +121,15 @@ const testCases: ConverterCase[] = [
|
|
|
121
121
|
serializableCase('array', [1, 'two', false, null, { deep: 3 }], jsonTextPlacements('[1,"two",0,null,{"deep":3}]')),
|
|
122
122
|
serializableCase('objectId', objectId, jsonStringPlacements('66e834cc91d805df11fa0ecb')),
|
|
123
123
|
serializableCase('bool', true, placements(1n, '[1]', '{"nested":1}')),
|
|
124
|
+
{
|
|
125
|
+
name: 'bool (fixed json)',
|
|
126
|
+
buildBuffer: (placement) => serializeCaseDocument(`bool (fixed json):${placement}`, placement, false),
|
|
127
|
+
expected: placements(0n, '[false]', '{"nested":false}'),
|
|
128
|
+
context: new CompatibilityContext({
|
|
129
|
+
edition: CompatibilityEdition.COMPILED_STREAMS,
|
|
130
|
+
overrides: new Map([[CompatibilityOption.fixedBooleanInJson, true]])
|
|
131
|
+
})
|
|
132
|
+
} satisfies ConverterCase,
|
|
124
133
|
serializableCase('date', normalDate, jsonStringPlacements('2023-03-06 13:47:00.000Z')),
|
|
125
134
|
serializableCase('date:+010000', positiveExtendedDate, jsonStringPlacements('+010000-01-01 00:00:00.000Z')),
|
|
126
135
|
serializableCase('date:-000001', negativeExtendedDate, jsonStringPlacements('-000001-12-31 23:59:59.999Z')),
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
updateSyncRulesFromYaml,
|
|
19
19
|
utils
|
|
20
20
|
} from '@powersync/service-core';
|
|
21
|
-
import { bucketRequest, METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
|
|
21
|
+
import { bucketRequest, getTestStorage, METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
|
|
22
22
|
|
|
23
23
|
import { SentinelLSN } from '@module/common/SentinelLSN.js';
|
|
24
24
|
import { ChangeStream, ChangeStreamOptions } from '@module/replication/ChangeStream.js';
|
|
@@ -119,7 +119,7 @@ export class ChangeStreamTestContext {
|
|
|
119
119
|
updateSyncRulesFromYaml(content, { validate: true, storageVersion: this.storageVersion })
|
|
120
120
|
);
|
|
121
121
|
this.syncRulesContent = replicationStream.syncConfigContent[0];
|
|
122
|
-
this.storage = this.factory
|
|
122
|
+
this.storage = await getTestStorage(this.factory, replicationStream);
|
|
123
123
|
return this.storage!;
|
|
124
124
|
}
|
|
125
125
|
|
|
@@ -130,7 +130,7 @@ export class ChangeStreamTestContext {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
this.syncRulesContent = syncConfig.content;
|
|
133
|
-
this.storage = syncConfig.
|
|
133
|
+
this.storage = await getTestStorage(this.factory, syncConfig.replicationStream);
|
|
134
134
|
return this.storage!;
|
|
135
135
|
}
|
|
136
136
|
|
|
@@ -141,7 +141,7 @@ export class ChangeStreamTestContext {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
this.syncRulesContent = syncConfig.content;
|
|
144
|
-
this.storage = syncConfig.
|
|
144
|
+
this.storage = await getTestStorage(this.factory, syncConfig.replicationStream);
|
|
145
145
|
return this.storage!;
|
|
146
146
|
}
|
|
147
147
|
|
|
@@ -759,8 +759,7 @@ bucket_definitions:
|
|
|
759
759
|
expect(JSON.parse(lastOp.data as string)).toMatchObject({ description: 'after_keepalive' });
|
|
760
760
|
});
|
|
761
761
|
|
|
762
|
-
|
|
763
|
-
test.skip('respects maxAwaitTimeMS for idle getMore calls in documentDbMode', async () => {
|
|
762
|
+
test('respects maxAwaitTimeMS for idle getMore calls in documentDbMode', async () => {
|
|
764
763
|
const maxAwaitTimeMS = 2_000;
|
|
765
764
|
|
|
766
765
|
await using context = await openContext({
|
|
@@ -5,7 +5,6 @@ import { getCursorBatchBytes } from '@module/replication/replication-index.js';
|
|
|
5
5
|
import { mongo } from '@powersync/lib-service-mongodb';
|
|
6
6
|
import { bson } from '@powersync/service-core';
|
|
7
7
|
import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
|
|
8
|
-
import { testTimeout } from './test-timeouts.js';
|
|
9
8
|
import { clearTestDb, connectMongoData, requireFailCommand } from './util.js';
|
|
10
9
|
|
|
11
10
|
// DocumentDB only supports cluster-level change streams — collection- and
|
|
@@ -102,68 +101,6 @@ describe('internal mongodb utils', () => {
|
|
|
102
101
|
}
|
|
103
102
|
);
|
|
104
103
|
|
|
105
|
-
test(
|
|
106
|
-
'keeps getMore maxTimeMS when client-side maxAwaitTimeMS is enabled',
|
|
107
|
-
{ timeout: testTimeout(30_000, { cloudOverride: 150_000 }) },
|
|
108
|
-
async () => {
|
|
109
|
-
const { db, client } = await connectMongoData({ monitorCommands: true });
|
|
110
|
-
await using _ = { [Symbol.asyncDispose]: async () => await client.close() };
|
|
111
|
-
await clearTestDb(db);
|
|
112
|
-
const collection = db.collection('test_data');
|
|
113
|
-
// Keep the local Mongo path fast, but give Azure DocumentDB enough time
|
|
114
|
-
// for slow cloud change-stream delivery when maxTimeMS is sent to getMore.
|
|
115
|
-
const maxAwaitTimeMS = testTimeout(50, { cloudOverride: 10_000 });
|
|
116
|
-
|
|
117
|
-
const started: any[] = [];
|
|
118
|
-
client.on('commandStarted', (event) => {
|
|
119
|
-
if (event.commandName == 'aggregate' || event.commandName == 'getMore') {
|
|
120
|
-
started.push(event);
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
const stream = rawChangeStream(
|
|
125
|
-
DATABASE_TYPE == DatabaseType.DOCUMENTDB ? client.db('admin') : db,
|
|
126
|
-
[
|
|
127
|
-
{
|
|
128
|
-
$changeStream: {
|
|
129
|
-
fullDocument: 'updateLookup',
|
|
130
|
-
...(DATABASE_TYPE == DatabaseType.DOCUMENTDB ? { allChangesForCluster: true } : {})
|
|
131
|
-
}
|
|
132
|
-
},
|
|
133
|
-
...(DATABASE_TYPE == DatabaseType.DOCUMENTDB
|
|
134
|
-
? [
|
|
135
|
-
{
|
|
136
|
-
$match: {
|
|
137
|
-
'ns.db': db.databaseName,
|
|
138
|
-
'ns.coll': collection.collectionName
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
]
|
|
142
|
-
: [])
|
|
143
|
-
],
|
|
144
|
-
{
|
|
145
|
-
batchSize: 10,
|
|
146
|
-
maxAwaitTimeMS,
|
|
147
|
-
clientSideMaxAwaitTimeMS: true,
|
|
148
|
-
maxTimeMS: 1_000
|
|
149
|
-
}
|
|
150
|
-
);
|
|
151
|
-
|
|
152
|
-
await stream.next();
|
|
153
|
-
await collection.insertOne({ test: 1 });
|
|
154
|
-
const nextBatch = await readUntilNonEmptyBatch(stream);
|
|
155
|
-
await stream.return?.();
|
|
156
|
-
|
|
157
|
-
expect(nextBatch.events).toHaveLength(1);
|
|
158
|
-
|
|
159
|
-
const aggregate = started.find((event) => event.commandName == 'aggregate');
|
|
160
|
-
const getMore = started.find((event) => event.commandName == 'getMore');
|
|
161
|
-
|
|
162
|
-
expect(aggregate?.command.maxTimeMS).toEqual(1_000);
|
|
163
|
-
expect(getMore?.command.maxTimeMS).toEqual(maxAwaitTimeMS);
|
|
164
|
-
}
|
|
165
|
-
);
|
|
166
|
-
|
|
167
104
|
// This test uses configureFailPoint to inject a getMore timeout. Azure
|
|
168
105
|
// DocumentDB does not support configureFailPoint.
|
|
169
106
|
test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)(
|