@semics-tech/mongolite 1.3.0 → 1.4.1
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 +80 -21
- package/dist/cjs/collection.js +9 -6
- package/dist/cjs/cursors/findCursor.js +6 -4
- package/dist/cjs/index.js +108 -1
- package/dist/cjs/mongo-client.js +18 -1
- package/dist/cjs/server.js +164 -0
- package/dist/cjs/sync/commands.js +126 -0
- package/dist/cjs/sync/diff.js +174 -0
- package/dist/cjs/sync/httpSink.js +159 -0
- package/dist/cjs/sync/index.js +40 -0
- package/dist/cjs/sync/mongoExecutor.js +306 -0
- package/dist/cjs/sync/mongoSink.js +161 -0
- package/dist/cjs/sync/outbox.js +358 -0
- package/dist/cjs/sync/protocol.js +177 -0
- package/dist/cjs/sync/replicator.js +747 -0
- package/dist/cjs/sync/shadow.js +178 -0
- package/dist/cjs/sync/types.js +2 -0
- package/dist/cloudflare.d.ts +1 -1
- package/dist/cloudflare.js.map +1 -1
- package/dist/collection.js +9 -6
- package/dist/collection.js.map +1 -1
- package/dist/cursors/findCursor.d.ts +2 -1
- package/dist/cursors/findCursor.js +6 -4
- package/dist/cursors/findCursor.js.map +1 -1
- package/dist/index.d.ts +68 -2
- package/dist/index.js +96 -1
- package/dist/index.js.map +1 -1
- package/dist/mongo-client.d.ts +13 -0
- package/dist/mongo-client.js +18 -1
- package/dist/mongo-client.js.map +1 -1
- package/dist/server.d.ts +97 -0
- package/dist/server.js +158 -0
- package/dist/server.js.map +1 -0
- package/dist/sync/commands.d.ts +49 -0
- package/dist/sync/commands.js +118 -0
- package/dist/sync/commands.js.map +1 -0
- package/dist/sync/diff.d.ts +55 -0
- package/dist/sync/diff.js +169 -0
- package/dist/sync/diff.js.map +1 -0
- package/dist/sync/httpSink.d.ts +93 -0
- package/dist/sync/httpSink.js +155 -0
- package/dist/sync/httpSink.js.map +1 -0
- package/dist/sync/index.d.ts +17 -0
- package/dist/sync/index.js +10 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/mongoExecutor.d.ts +37 -0
- package/dist/sync/mongoExecutor.js +302 -0
- package/dist/sync/mongoExecutor.js.map +1 -0
- package/dist/sync/mongoSink.d.ts +109 -0
- package/dist/sync/mongoSink.js +123 -0
- package/dist/sync/mongoSink.js.map +1 -0
- package/dist/sync/outbox.d.ts +120 -0
- package/dist/sync/outbox.js +355 -0
- package/dist/sync/outbox.js.map +1 -0
- package/dist/sync/protocol.d.ts +93 -0
- package/dist/sync/protocol.js +168 -0
- package/dist/sync/protocol.js.map +1 -0
- package/dist/sync/replicator.d.ts +177 -0
- package/dist/sync/replicator.js +744 -0
- package/dist/sync/replicator.js.map +1 -0
- package/dist/sync/shadow.d.ts +54 -0
- package/dist/sync/shadow.js +173 -0
- package/dist/sync/shadow.js.map +1 -0
- package/dist/sync/types.d.ts +388 -0
- package/dist/sync/types.js +2 -0
- package/dist/sync/types.js.map +1 -0
- package/package.json +16 -2
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.UPDATED_AT_FIELD = exports.VERSION_FIELD = void 0;
|
|
4
|
+
exports.isConditional = isConditional;
|
|
5
|
+
exports.isReservedPath = isReservedPath;
|
|
6
|
+
exports.stripReservedFields = stripReservedFields;
|
|
7
|
+
exports.readVersion = readVersion;
|
|
8
|
+
exports.toUpstreamId = toUpstreamId;
|
|
9
|
+
exports.buildWriteCommand = buildWriteCommand;
|
|
10
|
+
/**
|
|
11
|
+
* Builds the MongoDB write commands that realise a {@link SyncOperation}.
|
|
12
|
+
*
|
|
13
|
+
* This lives on its own because three places must agree on it byte for byte: the
|
|
14
|
+
* direct MongoDB sink, the HTTP sink that ships commands to a remote API, and the
|
|
15
|
+
* receiver on the far side that applies them. If any of the three built its own
|
|
16
|
+
* commands they would drift, and the drift would show up as data loss rather than as a
|
|
17
|
+
* test failure.
|
|
18
|
+
*/
|
|
19
|
+
const bson_1 = require("bson");
|
|
20
|
+
/** Field carrying the optimistic-concurrency version on upstream documents. */
|
|
21
|
+
exports.VERSION_FIELD = '_v';
|
|
22
|
+
/** Field carrying the server-stamped last-write time on upstream documents. */
|
|
23
|
+
exports.UPDATED_AT_FIELD = '_updatedAt';
|
|
24
|
+
/** True when this operation asserts a specific upstream revision. */
|
|
25
|
+
function isConditional(op) {
|
|
26
|
+
return op.baseVersion !== null && op.baseVersion !== undefined;
|
|
27
|
+
}
|
|
28
|
+
/** `_id` is the identity; `_v`/`_updatedAt` are managed by the update itself. */
|
|
29
|
+
function isReservedPath(path) {
|
|
30
|
+
const root = path.split('.')[0];
|
|
31
|
+
return root === '_id' || root === exports.VERSION_FIELD || root === exports.UPDATED_AT_FIELD;
|
|
32
|
+
}
|
|
33
|
+
function stripReservedFields(value) {
|
|
34
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => !isReservedPath(key)));
|
|
35
|
+
}
|
|
36
|
+
/** Reads `_v` off an upstream document, tolerating its absence. */
|
|
37
|
+
function readVersion(document) {
|
|
38
|
+
if (!document)
|
|
39
|
+
return null;
|
|
40
|
+
const raw = document[exports.VERSION_FIELD];
|
|
41
|
+
return typeof raw === 'number' ? raw : null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* MongoLite stores `_id` as a string. Converting ObjectId-shaped ids back to real
|
|
45
|
+
* `ObjectId`s keeps replicated documents indistinguishable from ones written by a
|
|
46
|
+
* native MongoDB client.
|
|
47
|
+
*/
|
|
48
|
+
function toUpstreamId(documentId, idMapping = 'auto', ObjectId = bson_1.ObjectId) {
|
|
49
|
+
if (idMapping === 'string')
|
|
50
|
+
return documentId;
|
|
51
|
+
try {
|
|
52
|
+
return ObjectId.isValid(documentId) && /^[0-9a-fA-F]{24}$/.test(documentId)
|
|
53
|
+
? new ObjectId(documentId)
|
|
54
|
+
: documentId;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return documentId;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Builds the upstream write for one operation.
|
|
62
|
+
*
|
|
63
|
+
* Every form is idempotent by `_id`, which is what makes at-least-once delivery safe:
|
|
64
|
+
* replaying a batch reproduces the same upstream state.
|
|
65
|
+
*
|
|
66
|
+
* With versioning on, writes are additionally *conditional* on the document still being
|
|
67
|
+
* at the version we last saw. That is the difference between "my change is newer, so it
|
|
68
|
+
* wins" — which cannot detect a lost update — and "I am replacing exactly the revision
|
|
69
|
+
* I read", which can.
|
|
70
|
+
*/
|
|
71
|
+
function buildWriteCommand(op, options = {}) {
|
|
72
|
+
const { idMapping = 'auto', versioning = true, ObjectId } = options;
|
|
73
|
+
const _id = toUpstreamId(op.documentId, idMapping, ObjectId);
|
|
74
|
+
if (op.type === 'delete') {
|
|
75
|
+
const filter = isConditional(op) ? { _id, [exports.VERSION_FIELD]: op.baseVersion } : { _id };
|
|
76
|
+
return { deleteOne: { filter } };
|
|
77
|
+
}
|
|
78
|
+
if (!versioning) {
|
|
79
|
+
// The local database is treated as authoritative: replace wholesale, so a field
|
|
80
|
+
// deleted locally disappears upstream too.
|
|
81
|
+
return {
|
|
82
|
+
replaceOne: { filter: { _id }, replacement: { ...(op.document ?? {}), _id }, upsert: true },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// Never been upstream: create it, and let a duplicate key tell us we lost the race to
|
|
86
|
+
// create. `$setOnInsert` alone means an existing document is left untouched.
|
|
87
|
+
if (!isConditional(op)) {
|
|
88
|
+
const document = stripReservedFields(op.document ?? {});
|
|
89
|
+
return {
|
|
90
|
+
updateOne: {
|
|
91
|
+
filter: { _id },
|
|
92
|
+
update: {
|
|
93
|
+
$setOnInsert: { ...document, [exports.VERSION_FIELD]: 1 },
|
|
94
|
+
$currentDate: { [exports.UPDATED_AT_FIELD]: true },
|
|
95
|
+
},
|
|
96
|
+
upsert: true,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// Known revision: apply only what changed, conditional on that revision.
|
|
101
|
+
const update = {
|
|
102
|
+
$inc: { [exports.VERSION_FIELD]: 1 },
|
|
103
|
+
$currentDate: { [exports.UPDATED_AT_FIELD]: true },
|
|
104
|
+
};
|
|
105
|
+
const diff = op.diff;
|
|
106
|
+
if (diff) {
|
|
107
|
+
const set = stripReservedFields(diff.set);
|
|
108
|
+
const unset = diff.unset.filter((path) => !isReservedPath(path));
|
|
109
|
+
if (Object.keys(set).length > 0)
|
|
110
|
+
update.$set = set;
|
|
111
|
+
if (unset.length > 0) {
|
|
112
|
+
update.$unset = Object.fromEntries(unset.map((path) => [path, '']));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
// No shadow to diff against — fall back to writing the whole document, minus the
|
|
117
|
+
// bookkeeping fields the update itself manages.
|
|
118
|
+
update.$set = stripReservedFields(op.document ?? {});
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
updateOne: {
|
|
122
|
+
filter: { _id, [exports.VERSION_FIELD]: op.baseVersion },
|
|
123
|
+
update,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Field-level diffing between the last-known server state and the local document.
|
|
4
|
+
*
|
|
5
|
+
* Replication used to push whole documents, which meant a local edit to one field
|
|
6
|
+
* rewrote every other field too — including fields another writer had just changed,
|
|
7
|
+
* and including fields whose BSON type the local store cannot represent. Diffing
|
|
8
|
+
* narrows each push to `$set`/`$unset` of what genuinely changed, so everything else
|
|
9
|
+
* upstream is left exactly as it was.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.deepEqual = deepEqual;
|
|
13
|
+
exports.computeDiff = computeDiff;
|
|
14
|
+
exports.isEmptyDiff = isEmptyDiff;
|
|
15
|
+
exports.applyDiff = applyDiff;
|
|
16
|
+
/**
|
|
17
|
+
* True when `value` is a genuine plain object worth recursing into.
|
|
18
|
+
*
|
|
19
|
+
* Class instances are deliberately excluded. A `Date`, `ObjectId` or `Binary` is
|
|
20
|
+
* `typeof 'object'`, and treating one as a bag of fields would copy it into a plain
|
|
21
|
+
* object — destroying exactly the BSON types the shadow exists to preserve.
|
|
22
|
+
*/
|
|
23
|
+
function isPlainObject(value) {
|
|
24
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
25
|
+
return false;
|
|
26
|
+
const proto = Object.getPrototypeOf(value);
|
|
27
|
+
return proto === Object.prototype || proto === null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Structural equality, insensitive to key order.
|
|
31
|
+
*
|
|
32
|
+
* `JSON.stringify` comparison would report `{a:1,b:2}` and `{b:2,a:1}` as different and
|
|
33
|
+
* push a spurious update on every sync.
|
|
34
|
+
*/
|
|
35
|
+
function deepEqual(a, b) {
|
|
36
|
+
if (a === b)
|
|
37
|
+
return true;
|
|
38
|
+
// NaN is the one primitive that isn't equal to itself.
|
|
39
|
+
if (typeof a === 'number' && typeof b === 'number')
|
|
40
|
+
return Number.isNaN(a) && Number.isNaN(b);
|
|
41
|
+
// Two equal instants are separate objects; without this every sync would rewrite
|
|
42
|
+
// any date-valued field.
|
|
43
|
+
if (a instanceof Date || b instanceof Date) {
|
|
44
|
+
return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
47
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
48
|
+
return false;
|
|
49
|
+
return a.every((item, index) => deepEqual(item, b[index]));
|
|
50
|
+
}
|
|
51
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
52
|
+
const aKeys = Object.keys(a);
|
|
53
|
+
const bKeys = Object.keys(b);
|
|
54
|
+
if (aKeys.length !== bKeys.length)
|
|
55
|
+
return false;
|
|
56
|
+
return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key]));
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Computes the minimal update that turns `base` into `local`.
|
|
62
|
+
*
|
|
63
|
+
* `base` must be the **lossy JSON projection** of the server document — the same shape
|
|
64
|
+
* the local store would have produced for it (see `projectToLocalShape` in `shadow.ts`).
|
|
65
|
+
* That is what makes type preservation work: a server `Date` projects to the same ISO
|
|
66
|
+
* string the local store holds, compares equal, and so never appears in the diff — the
|
|
67
|
+
* upstream `Date` survives untouched.
|
|
68
|
+
*
|
|
69
|
+
* Nested objects recurse into dotted paths, so two writers editing different sub-fields
|
|
70
|
+
* of the same document do not collide.
|
|
71
|
+
*
|
|
72
|
+
* **Arrays are atomic.** Any change to an array replaces the whole array. Positional
|
|
73
|
+
* diffing of arrays is famously error-prone once elements are inserted or reordered, and
|
|
74
|
+
* a wrong answer here silently corrupts data — so this deliberately does not attempt it.
|
|
75
|
+
*
|
|
76
|
+
* @param base The last-known server state, projected to local shape. `null` if the
|
|
77
|
+
* document has never been upstream, in which case every field is new.
|
|
78
|
+
* @param local The current local document.
|
|
79
|
+
*/
|
|
80
|
+
function computeDiff(base, local) {
|
|
81
|
+
const diff = { set: {}, unset: [] };
|
|
82
|
+
collect(base ?? {}, local, '', diff);
|
|
83
|
+
return diff;
|
|
84
|
+
}
|
|
85
|
+
function collect(base, local, prefix, diff) {
|
|
86
|
+
const keys = new Set([...Object.keys(base), ...Object.keys(local)]);
|
|
87
|
+
for (const key of keys) {
|
|
88
|
+
// `_id` is the identity, never part of the update.
|
|
89
|
+
if (prefix === '' && key === '_id')
|
|
90
|
+
continue;
|
|
91
|
+
const path = prefix === '' ? key : `${prefix}.${key}`;
|
|
92
|
+
const inLocal = Object.prototype.hasOwnProperty.call(local, key);
|
|
93
|
+
const inBase = Object.prototype.hasOwnProperty.call(base, key);
|
|
94
|
+
if (!inLocal) {
|
|
95
|
+
diff.unset.push(path);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const localValue = local[key];
|
|
99
|
+
const baseValue = base[key];
|
|
100
|
+
if (!inBase) {
|
|
101
|
+
diff.set[path] = localValue;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// Recurse only when both sides are objects; otherwise the shapes differ and the
|
|
105
|
+
// whole subtree is replaced.
|
|
106
|
+
if (isPlainObject(baseValue) && isPlainObject(localValue)) {
|
|
107
|
+
collect(baseValue, localValue, path, diff);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!deepEqual(baseValue, localValue)) {
|
|
111
|
+
diff.set[path] = localValue;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** True when the diff would change nothing upstream. */
|
|
116
|
+
function isEmptyDiff(diff) {
|
|
117
|
+
return Object.keys(diff.set).length === 0 && diff.unset.length === 0;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Applies a diff to a copy of `document`, mirroring what the upstream `$set`/`$unset`
|
|
121
|
+
* does.
|
|
122
|
+
*
|
|
123
|
+
* Used to roll the shadow forward after a successful push without a second round trip.
|
|
124
|
+
* Because it edits the *server* document in place, fields the diff never mentions keep
|
|
125
|
+
* their original BSON values — which is the whole point of diffing in the first place.
|
|
126
|
+
*/
|
|
127
|
+
function applyDiff(document, diff) {
|
|
128
|
+
const result = clone(document);
|
|
129
|
+
for (const [path, value] of Object.entries(diff.set)) {
|
|
130
|
+
setPath(result, path.split('.'), value);
|
|
131
|
+
}
|
|
132
|
+
for (const path of diff.unset) {
|
|
133
|
+
unsetPath(result, path.split('.'));
|
|
134
|
+
}
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Structured clone that leaves class instances (BSON `Date`, `ObjectId`, `Binary`, …)
|
|
139
|
+
* by reference. Copying them field-by-field would destroy exactly the types the shadow
|
|
140
|
+
* exists to preserve.
|
|
141
|
+
*/
|
|
142
|
+
function clone(value) {
|
|
143
|
+
if (Array.isArray(value))
|
|
144
|
+
return value.map(clone);
|
|
145
|
+
if (isPlainObject(value)) {
|
|
146
|
+
const out = {};
|
|
147
|
+
for (const [key, item] of Object.entries(value))
|
|
148
|
+
out[key] = clone(item);
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
function setPath(target, segments, value) {
|
|
154
|
+
const [head, ...rest] = segments;
|
|
155
|
+
if (rest.length === 0) {
|
|
156
|
+
target[head] = value;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const next = target[head];
|
|
160
|
+
// Mirror MongoDB: a dotted path creates intermediate objects as needed.
|
|
161
|
+
const container = isPlainObject(next) ? next : {};
|
|
162
|
+
target[head] = container;
|
|
163
|
+
setPath(container, rest, value);
|
|
164
|
+
}
|
|
165
|
+
function unsetPath(target, segments) {
|
|
166
|
+
const [head, ...rest] = segments;
|
|
167
|
+
if (rest.length === 0) {
|
|
168
|
+
delete target[head];
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const next = target[head];
|
|
172
|
+
if (isPlainObject(next))
|
|
173
|
+
unsetPath(next, rest);
|
|
174
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SyncRequestRejected = exports.HttpUpstreamSink = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* A {@link SyncSink} that ships changes to a remote HTTP API instead of connecting to
|
|
6
|
+
* MongoDB directly, for deployments where the local instance cannot reach the database.
|
|
7
|
+
*
|
|
8
|
+
* The API receives MongoDB-shaped commands and applies them on the client's behalf —
|
|
9
|
+
* see `@semics-tech/mongolite/server` for a receiver that does exactly that.
|
|
10
|
+
*
|
|
11
|
+
* Version predicates travel with each operation and conflicts come back in the response,
|
|
12
|
+
* so replication behaves the same over HTTP as it does over a direct connection: the
|
|
13
|
+
* upstream stays the source of truth, and a concurrent writer is detected rather than
|
|
14
|
+
* overwritten.
|
|
15
|
+
*/
|
|
16
|
+
const commands_js_1 = require("./commands.js");
|
|
17
|
+
const protocol_js_1 = require("./protocol.js");
|
|
18
|
+
class HttpUpstreamSink {
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.options = options;
|
|
21
|
+
this.name = 'http';
|
|
22
|
+
this.cookie = null;
|
|
23
|
+
if (!options.baseUrl)
|
|
24
|
+
throw new Error('HttpUpstreamSink requires a `baseUrl`.');
|
|
25
|
+
if (!options.database)
|
|
26
|
+
throw new Error('HttpUpstreamSink requires a `database`.');
|
|
27
|
+
const globalFetch = globalThis.fetch;
|
|
28
|
+
if (!options.fetch && typeof globalFetch !== 'function') {
|
|
29
|
+
throw new Error('HttpUpstreamSink needs a `fetch` implementation. Node 18+ provides one globally; ' +
|
|
30
|
+
'on older runtimes pass `fetch` explicitly.');
|
|
31
|
+
}
|
|
32
|
+
this.fetchImpl = options.fetch ?? globalFetch;
|
|
33
|
+
this.idMapping = options.idMapping ?? 'auto';
|
|
34
|
+
this.versioning = options.versioning ?? true;
|
|
35
|
+
}
|
|
36
|
+
async apply(operations) {
|
|
37
|
+
const includeCommands = this.options.includeCommands ?? true;
|
|
38
|
+
const request = {
|
|
39
|
+
protocol: protocol_js_1.SYNC_PROTOCOL_VERSION,
|
|
40
|
+
replicator: this.options.replicator,
|
|
41
|
+
operations: operations.map((op) => (0, protocol_js_1.toWireOperation)(op, includeCommands
|
|
42
|
+
? (0, commands_js_1.buildWriteCommand)(op, { idMapping: this.idMapping, versioning: this.versioning })
|
|
43
|
+
: undefined)),
|
|
44
|
+
};
|
|
45
|
+
let response;
|
|
46
|
+
try {
|
|
47
|
+
response = await this.send('_sync', request);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
// A request the API rejects outright will be rejected again on every retry, so
|
|
51
|
+
// it is reported as a per-operation failure — dead-lettered, not spun on. The
|
|
52
|
+
// sink's contract is that throwing means "transient", and this is not that.
|
|
53
|
+
if (err instanceof SyncRequestRejected) {
|
|
54
|
+
return {
|
|
55
|
+
applied: 0,
|
|
56
|
+
failures: operations.map((_op, index) => ({
|
|
57
|
+
index,
|
|
58
|
+
message: err.message,
|
|
59
|
+
code: err.status,
|
|
60
|
+
})),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
applied: response.applied ?? 0,
|
|
67
|
+
failures: response.failures,
|
|
68
|
+
conflicts: response.conflicts,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async fetch(collection, documentIds) {
|
|
72
|
+
if (documentIds.length === 0)
|
|
73
|
+
return [];
|
|
74
|
+
const response = await this.send('_sync/fetch', {
|
|
75
|
+
protocol: protocol_js_1.SYNC_PROTOCOL_VERSION,
|
|
76
|
+
collection,
|
|
77
|
+
documentIds,
|
|
78
|
+
});
|
|
79
|
+
return response.documents ?? [];
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------- internals
|
|
82
|
+
endpoint(path) {
|
|
83
|
+
const base = this.options.baseUrl.replace(/\/+$/, '');
|
|
84
|
+
const prefix = (this.options.pathPrefix ?? 'sync').replace(/^\/+|\/+$/g, '');
|
|
85
|
+
return `${base}/${prefix}/${encodeURIComponent(this.options.database)}/${path}`;
|
|
86
|
+
}
|
|
87
|
+
async requestHeaders() {
|
|
88
|
+
const headers = {
|
|
89
|
+
'content-type': protocol_js_1.SYNC_CONTENT_TYPE,
|
|
90
|
+
accept: protocol_js_1.SYNC_CONTENT_TYPE,
|
|
91
|
+
...this.options.headers,
|
|
92
|
+
...(this.options.getAuthHeaders ? await this.options.getAuthHeaders() : {}),
|
|
93
|
+
};
|
|
94
|
+
if (this.cookie && (this.options.cookies ?? true))
|
|
95
|
+
headers.cookie = this.cookie;
|
|
96
|
+
return headers;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Posts a body and decodes the reply.
|
|
100
|
+
*
|
|
101
|
+
* Retries are deliberately *not* handled here — the replicator already owns backoff,
|
|
102
|
+
* outage buffering and checkpointing. This throws on anything retryable and lets it do
|
|
103
|
+
* its job; throwing twice over would compound the delays.
|
|
104
|
+
*/
|
|
105
|
+
async send(path, body) {
|
|
106
|
+
const timeoutMs = this.options.timeoutMs ?? 30000;
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
109
|
+
let response;
|
|
110
|
+
try {
|
|
111
|
+
response = await this.fetchImpl(this.endpoint(path), {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: await this.requestHeaders(),
|
|
114
|
+
body: (0, protocol_js_1.encodeBody)(body),
|
|
115
|
+
signal: controller.signal,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
// Network failure, DNS, timeout — all transient as far as we can tell.
|
|
120
|
+
throw new Error(`Sync request to ${this.endpoint(path)} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
}
|
|
125
|
+
if (this.options.cookies ?? true) {
|
|
126
|
+
const setCookie = response.headers.get('set-cookie');
|
|
127
|
+
if (setCookie)
|
|
128
|
+
this.cookie = setCookie;
|
|
129
|
+
}
|
|
130
|
+
const text = await response.text();
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
const detail = text.slice(0, 500);
|
|
133
|
+
// 4xx means the request itself is wrong and will stay wrong; throwing would spin
|
|
134
|
+
// forever. Surfacing it as a per-operation failure dead-letters the batch instead.
|
|
135
|
+
// 408 and 429 are the exceptions — they explicitly invite a retry.
|
|
136
|
+
const retryable = response.status >= 500 || response.status === 408 || response.status === 429;
|
|
137
|
+
if (retryable) {
|
|
138
|
+
throw new Error(`Sync request failed with ${response.status}: ${detail}`);
|
|
139
|
+
}
|
|
140
|
+
throw new SyncRequestRejected(`Sync request rejected with ${response.status}: ${detail}`, response.status);
|
|
141
|
+
}
|
|
142
|
+
return (0, protocol_js_1.decodeBody)(text);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
exports.HttpUpstreamSink = HttpUpstreamSink;
|
|
146
|
+
/**
|
|
147
|
+
* The remote API refused the request outright.
|
|
148
|
+
*
|
|
149
|
+
* Distinct from a transport failure: retrying will produce the same refusal, so the
|
|
150
|
+
* batch is dead-lettered rather than retried.
|
|
151
|
+
*/
|
|
152
|
+
class SyncRequestRejected extends Error {
|
|
153
|
+
constructor(message, status) {
|
|
154
|
+
super(message);
|
|
155
|
+
this.status = status;
|
|
156
|
+
this.name = 'SyncRequestRejected';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
exports.SyncRequestRejected = SyncRequestRejected;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.deepEqual = exports.isEmptyDiff = exports.applyDiff = exports.computeDiff = exports.projectToLocalShape = exports.SHADOW_TABLE = exports.SyncShadow = exports.fromWireOperation = exports.toWireOperation = exports.decodeBody = exports.encodeBody = exports.SyncProtocolError = exports.SYNC_PROTOCOL_VERSION = exports.fetchDocuments = exports.executeWrites = exports.toUpstreamId = exports.buildWriteCommand = exports.SyncRequestRejected = exports.HttpUpstreamSink = exports.UPDATED_AT_FIELD = exports.VERSION_FIELD = exports.MongoUpstreamSink = exports.SYNC_OBJECT_PREFIX = exports.DEAD_LETTER_TABLE = exports.STATE_TABLE = exports.OUTBOX_TABLE = exports.SyncOutbox = exports.SyncReplicator = void 0;
|
|
4
|
+
var replicator_js_1 = require("./replicator.js");
|
|
5
|
+
Object.defineProperty(exports, "SyncReplicator", { enumerable: true, get: function () { return replicator_js_1.SyncReplicator; } });
|
|
6
|
+
var outbox_js_1 = require("./outbox.js");
|
|
7
|
+
Object.defineProperty(exports, "SyncOutbox", { enumerable: true, get: function () { return outbox_js_1.SyncOutbox; } });
|
|
8
|
+
Object.defineProperty(exports, "OUTBOX_TABLE", { enumerable: true, get: function () { return outbox_js_1.OUTBOX_TABLE; } });
|
|
9
|
+
Object.defineProperty(exports, "STATE_TABLE", { enumerable: true, get: function () { return outbox_js_1.STATE_TABLE; } });
|
|
10
|
+
Object.defineProperty(exports, "DEAD_LETTER_TABLE", { enumerable: true, get: function () { return outbox_js_1.DEAD_LETTER_TABLE; } });
|
|
11
|
+
Object.defineProperty(exports, "SYNC_OBJECT_PREFIX", { enumerable: true, get: function () { return outbox_js_1.SYNC_OBJECT_PREFIX; } });
|
|
12
|
+
var mongoSink_js_1 = require("./mongoSink.js");
|
|
13
|
+
Object.defineProperty(exports, "MongoUpstreamSink", { enumerable: true, get: function () { return mongoSink_js_1.MongoUpstreamSink; } });
|
|
14
|
+
Object.defineProperty(exports, "VERSION_FIELD", { enumerable: true, get: function () { return mongoSink_js_1.VERSION_FIELD; } });
|
|
15
|
+
Object.defineProperty(exports, "UPDATED_AT_FIELD", { enumerable: true, get: function () { return mongoSink_js_1.UPDATED_AT_FIELD; } });
|
|
16
|
+
var httpSink_js_1 = require("./httpSink.js");
|
|
17
|
+
Object.defineProperty(exports, "HttpUpstreamSink", { enumerable: true, get: function () { return httpSink_js_1.HttpUpstreamSink; } });
|
|
18
|
+
Object.defineProperty(exports, "SyncRequestRejected", { enumerable: true, get: function () { return httpSink_js_1.SyncRequestRejected; } });
|
|
19
|
+
var commands_js_1 = require("./commands.js");
|
|
20
|
+
Object.defineProperty(exports, "buildWriteCommand", { enumerable: true, get: function () { return commands_js_1.buildWriteCommand; } });
|
|
21
|
+
Object.defineProperty(exports, "toUpstreamId", { enumerable: true, get: function () { return commands_js_1.toUpstreamId; } });
|
|
22
|
+
var mongoExecutor_js_1 = require("./mongoExecutor.js");
|
|
23
|
+
Object.defineProperty(exports, "executeWrites", { enumerable: true, get: function () { return mongoExecutor_js_1.executeWrites; } });
|
|
24
|
+
Object.defineProperty(exports, "fetchDocuments", { enumerable: true, get: function () { return mongoExecutor_js_1.fetchDocuments; } });
|
|
25
|
+
var protocol_js_1 = require("./protocol.js");
|
|
26
|
+
Object.defineProperty(exports, "SYNC_PROTOCOL_VERSION", { enumerable: true, get: function () { return protocol_js_1.SYNC_PROTOCOL_VERSION; } });
|
|
27
|
+
Object.defineProperty(exports, "SyncProtocolError", { enumerable: true, get: function () { return protocol_js_1.SyncProtocolError; } });
|
|
28
|
+
Object.defineProperty(exports, "encodeBody", { enumerable: true, get: function () { return protocol_js_1.encodeBody; } });
|
|
29
|
+
Object.defineProperty(exports, "decodeBody", { enumerable: true, get: function () { return protocol_js_1.decodeBody; } });
|
|
30
|
+
Object.defineProperty(exports, "toWireOperation", { enumerable: true, get: function () { return protocol_js_1.toWireOperation; } });
|
|
31
|
+
Object.defineProperty(exports, "fromWireOperation", { enumerable: true, get: function () { return protocol_js_1.fromWireOperation; } });
|
|
32
|
+
var shadow_js_1 = require("./shadow.js");
|
|
33
|
+
Object.defineProperty(exports, "SyncShadow", { enumerable: true, get: function () { return shadow_js_1.SyncShadow; } });
|
|
34
|
+
Object.defineProperty(exports, "SHADOW_TABLE", { enumerable: true, get: function () { return shadow_js_1.SHADOW_TABLE; } });
|
|
35
|
+
Object.defineProperty(exports, "projectToLocalShape", { enumerable: true, get: function () { return shadow_js_1.projectToLocalShape; } });
|
|
36
|
+
var diff_js_1 = require("./diff.js");
|
|
37
|
+
Object.defineProperty(exports, "computeDiff", { enumerable: true, get: function () { return diff_js_1.computeDiff; } });
|
|
38
|
+
Object.defineProperty(exports, "applyDiff", { enumerable: true, get: function () { return diff_js_1.applyDiff; } });
|
|
39
|
+
Object.defineProperty(exports, "isEmptyDiff", { enumerable: true, get: function () { return diff_js_1.isEmptyDiff; } });
|
|
40
|
+
Object.defineProperty(exports, "deepEqual", { enumerable: true, get: function () { return diff_js_1.deepEqual; } });
|