• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

share / sharedb / 30628980286

31 Jul 2026 11:59AM UTC coverage: 97.473% (+0.006%) from 97.467%
30628980286

Pull #719

github

web-flow
Merge f09c85f5b into c951560f7
Pull Request #719: 🐛 Restore the op before fixups when retrying a submit

1676 of 1889 branches covered (88.72%)

13 of 13 new or added lines in 1 file covered. (100.0%)

2 existing lines in 1 file now uncovered.

3510 of 3601 relevant lines covered (97.47%)

889.44 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.93
/lib/submit-request.js
1
var ot = require('./ot');
3✔
2
var projections = require('./projections');
3✔
3
var ShareDBError = require('./error');
3✔
4
var types = require('./types');
3✔
5
var protocol = require('./protocol');
3✔
6
var util = require('./util');
3✔
7

8
var ERROR_CODE = ShareDBError.CODES;
3✔
9

10
function SubmitRequest(backend, agent, index, id, op, options) {
11
  this.backend = backend;
3,267✔
12
  this.agent = agent;
3,267✔
13
  // If a projection, rewrite the call into a call against the collection
14
  var projection = backend.projections[index];
3,267✔
15
  this.index = index;
3,267✔
16
  this.projection = projection;
3,267✔
17
  this.collection = (projection) ? projection.target : index;
3,267✔
18
  this.id = id;
3,267✔
19
  this.op = op;
3,267✔
20
  this.options = options;
3,267✔
21

22
  this.extra = op.x;
3,267✔
23
  delete op.x;
3,267✔
24

25
  this.start = Date.now();
3,267✔
26
  this._addOpMeta();
3,267✔
27

28
  // Set as this request is sent through middleware
29
  this.action = null;
3,267✔
30
  // For custom use in middleware
31
  this.custom = Object.create(null);
3,267✔
32

33
  // Whether or not to store a milestone snapshot. If left as null, the milestone
34
  // snapshots are saved according to the interval provided to the milestone db
35
  // options. If overridden to a boolean value, then that value is used instead of
36
  // the interval logic.
37
  this.saveMilestoneSnapshot = null;
3,267✔
38
  this.suppressPublish = backend.suppressPublish;
3,267✔
39
  this.maxRetries = backend.maxSubmitRetries;
3,267✔
40
  this.retries = 0;
3,267✔
41

42
  // return values
43
  this.snapshot = null;
3,267✔
44
  this.ops = [];
3,267✔
45
  this.channels = null;
3,267✔
46
  this._fixupOps = [];
3,267✔
47
  this._opBeforeFixups = null;
3,267✔
48
}
49
module.exports = SubmitRequest;
3✔
50

51
SubmitRequest.prototype.$fixup = function(op) {
3✔
52
  if (this.action !== this.backend.MIDDLEWARE_ACTIONS.apply) {
69✔
53
    throw new ShareDBError(
3✔
54
      ERROR_CODE.ERR_FIXUP_IS_ONLY_VALID_ON_APPLY,
55
      'fixup can only be called during the apply middleware'
56
    );
57
  }
58

59
  if (this.op.del) {
66✔
60
    throw new ShareDBError(
3✔
61
      ERROR_CODE.ERR_CANNOT_FIXUP_DELETION,
62
      'fixup cannot be applied on deletion ops'
63
    );
64
  }
65

66
  var typeId = this.op.create ? this.op.create.type : this.snapshot.type;
63✔
67
  var type = types.map[typeId];
63✔
68
  if (typeof type.compose !== 'function') {
63✔
69
    throw new ShareDBError(
3✔
70
      ERROR_CODE.ERR_TYPE_DOES_NOT_SUPPORT_COMPOSE,
71
      typeId + ' does not support compose'
72
    );
73
  }
74

75
  if (!this._fixupOps.length) {
60✔
76
    this._opBeforeFixups = util.clone(this.op.create ? this.op.create.data : this.op.op);
57✔
77
  }
78

79
  if (this.op.create) this.op.create.data = type.apply(this.op.create.data, op);
60✔
80
  else this.op.op = type.compose(this.op.op, op);
48✔
81

82
  var fixupOp = {
60✔
83
    src: this.op.src,
84
    seq: this.op.seq,
85
    v: this.op.v,
86
    op: op
87
  };
88

89
  this._fixupOps.push(fixupOp);
60✔
90
};
91

92
SubmitRequest.prototype.submit = function(callback) {
3✔
93
  var request = this;
3,249✔
94
  var backend = this.backend;
3,249✔
95
  var collection = this.collection;
3,249✔
96
  var id = this.id;
3,249✔
97
  var op = this.op;
3,249✔
98
  // Send a special projection so that getSnapshot knows to return all fields.
99
  // With a null projection, it strips document metadata
100
  var fields = {$submit: true};
3,249✔
101

102
  var snapshotOptions = {};
3,249✔
103
  snapshotOptions.agentCustom = request.agent.custom;
3,249✔
104
  backend.db.getSnapshot(collection, id, fields, snapshotOptions, function(err, snapshot) {
3,249✔
105
    if (err) return callback(err);
3,249!
106

107
    request.snapshot = snapshot;
3,249✔
108
    request._addSnapshotMeta();
3,249✔
109

110
    if (op.v == null) {
3,249✔
111
      if (op.create && snapshot.type && op.src) {
1,986✔
112
        // If the document was already created by another op, we will return a
113
        // 'Document already exists' error in response and fail to submit this
114
        // op. However, this could also happen in the case that the op was
115
        // already committed and the create op was simply resent. In that
116
        // case, we should return a non-fatal 'Op already submitted' error. We
117
        // must get the past ops and check their src and seq values to
118
        // differentiate.
119
        request._fetchCreateOpVersion(function(error, version) {
27✔
120
          if (error) return callback(error);
27✔
121
          if (version == null) {
24✔
122
            callback(request.alreadyCreatedError());
12✔
123
          } else {
124
            op.v = version;
12✔
125
            callback(request.alreadySubmittedError());
12✔
126
          }
127
        });
128
        return;
27✔
129
      }
130

131
      // Submitting an op with a null version means that it should get the
132
      // version from the latest snapshot. Generally this will mean the op
133
      // won't be transformed, though transform could be called on it in the
134
      // case of a retry from a simultaneous submit
135
      op.v = snapshot.v;
1,959✔
136
    }
137

138
    if (op.v === snapshot.v) {
3,222✔
139
      // The snapshot hasn't changed since the op's base version. Apply
140
      // without transforming the op
141
      return request.apply(callback);
3,147✔
142
    }
143

144
    if (op.v > snapshot.v) {
75✔
145
      // The op version should be from a previous snapshot, so it should never
146
      // never exceed the current snapshot's version
147
      return callback(request.newerVersionError());
3✔
148
    }
149

150
    // Transform the op up to the current snapshot version, then apply
151
    var from = op.v;
72✔
152
    backend.db.getOpsToSnapshot(collection, id, from, snapshot, {metadata: true}, function(err, ops) {
72✔
153
      if (err) return callback(err);
72!
154

155
      if (ops.length !== snapshot.v - from) {
72✔
156
        return callback(request.missingOpsError());
3✔
157
      }
158

159
      err = request._transformOp(ops);
69✔
160
      if (err) return callback(err);
69✔
161

162
      var skipNoOp = backend.doNotCommitNoOps &&
57✔
163
        protocol.checkAtLeast(request.agent.protocol, '1.2') &&
164
        request.op.op &&
165
        request.op.op.length === 0;
166

167
      if (skipNoOp) {
57✔
168
        // The op is a no-op, either because it was submitted as such, or - more
169
        // likely - because it was transformed into one. Let's avoid committing it
170
        // and tell the client.
171
        return callback(request.noOpError());
6✔
172
      }
173

174
      if (op.v !== snapshot.v) {
51!
175
        // This shouldn't happen, but is just a final sanity check to make
176
        // sure we have transformed the op to the current snapshot version
UNCOV
177
        return callback(request.versionAfterTransformError());
×
178
      }
179

180
      request.apply(callback);
51✔
181
    });
182
  });
183
};
184

185
SubmitRequest.prototype.apply = function(callback) {
3✔
186
  // If we're being projected, verify that the op is allowed
187
  var projection = this.projection;
3,198✔
188
  if (projection && !projections.isOpAllowed(this.snapshot.type, projection.fields, this.op)) {
3,198✔
189
    return callback(this.projectionError());
9✔
190
  }
191

192
  // Always set the channels before each attempt to apply. If the channels are
193
  // modified in a middleware and we retry, we want to reset to a new array
194
  this.channels = this.backend.getChannels(this.collection, this.id);
3,189✔
195
  this._fixupOps = [];
3,189✔
196
  delete this.op.m.fixup;
3,189✔
197

198
  var request = this;
3,189✔
199
  this.backend.trigger(this.backend.MIDDLEWARE_ACTIONS.apply, this.agent, this, function(err) {
3,189✔
200
    if (err) return callback(err);
3,186✔
201

202
    // Apply the submitted op to the snapshot
203
    err = ot.apply(request.snapshot, request.op);
3,174✔
204
    if (err) return callback(err);
3,174✔
205

206
    request.commit(callback);
3,159✔
207
  });
208
};
209

210
SubmitRequest.prototype.commit = function(callback) {
3✔
211
  var request = this;
3,159✔
212
  var backend = this.backend;
3,159✔
213
  backend.trigger(backend.MIDDLEWARE_ACTIONS.commit, this.agent, this, function(err) {
3,159✔
214
    if (err) return callback(err);
3,144✔
215
    if (request._fixupOps.length) request.op.m.fixup = request._fixupOps;
3,141✔
216
    if (request.op.create) {
3,141✔
217
      // When we create the snapshot, we store a pointer to the op that created
218
      // it. This allows us to return OP_ALREADY_SUBMITTED errors when appropriate.
219
      request.snapshot.m._create = {
1,980✔
220
        src: request.op.src,
221
        seq: request.op.seq,
222
        v: request.op.v
223
      };
224
    }
225

226
    // Try committing the operation and snapshot to the database atomically
227
    backend.db.commit(
3,141✔
228
      request.collection,
229
      request.id,
230
      request.op,
231
      request.snapshot,
232
      request.options,
233
      function(err, succeeded) {
234
        if (err) return callback(err);
3,141!
235
        if (!succeeded) {
3,141✔
236
          // Between our fetch and our call to commit, another client committed an
237
          // operation. We expect this to be relatively infrequent but normal.
238
          return request.retry(callback);
36✔
239
        }
240
        if (!request.suppressPublish) {
3,105✔
241
          var op = request.op;
3,039✔
242
          op.c = request.collection;
3,039✔
243
          op.d = request.id;
3,039✔
244
          op.m = undefined;
3,039✔
245
          // Needed for agent to detect if it can ignore sending the op back to
246
          // the client that submitted it in subscriptions
247
          if (request.collection !== request.index) op.i = request.index;
3,039✔
248
          backend.pubsub.publish(request.channels, op);
3,039✔
249
        }
250
        if (request._shouldSaveMilestoneSnapshot(request.snapshot)) {
3,105✔
251
          request.backend.milestoneDb.saveMilestoneSnapshot(request.collection, request.snapshot);
63✔
252
        }
253
        callback();
3,105✔
254
      });
255
  });
256
};
257

258
SubmitRequest.prototype.retry = function(callback) {
3✔
259
  this.retries++;
36✔
260
  if (this.maxRetries != null && this.retries > this.maxRetries) {
36✔
261
    return callback(this.maxRetriesError());
3✔
262
  }
263
  this._restoreOpBeforeFixups();
33✔
264
  this.backend.emit('timing', 'submit.retry', Date.now() - this.start, this);
33✔
265
  this.submit(callback);
33✔
266
};
267

268
// The apply middleware is re-triggered on every attempt, so the abandoned
269
// attempt's fixups must be undone before we transform the op forward again
270
SubmitRequest.prototype._restoreOpBeforeFixups = function() {
3✔
271
  if (!this._fixupOps.length) return;
33✔
272
  if (this.op.create) this.op.create.data = this._opBeforeFixups;
12✔
273
  else this.op.op = this._opBeforeFixups;
9✔
274
};
275

276
SubmitRequest.prototype._transformOp = function(ops) {
3✔
277
  var type = this.snapshot.type;
69✔
278
  for (var i = 0; i < ops.length; i++) {
69✔
279
    var op = ops[i];
69✔
280

281
    if (this.op.src && this.op.src === op.src && this.op.seq === op.seq) {
69✔
282
      // The op has already been submitted. There are a variety of ways this
283
      // can happen in normal operation, such as a client resending an
284
      // unacknowledged operation at reconnect. It's important we don't apply
285
      // the same op twice
286
      if (op.m.fixup) this._fixupOps = op.m.fixup;
3!
287
      return this.alreadySubmittedError();
3✔
288
    }
289

290
    if (this.op.v !== op.v) {
66✔
291
      return this.versionDuringTransformError();
3✔
292
    }
293

294
    var err = ot.transform(type, this.op, op);
63✔
295
    if (err) return err;
63✔
296
    delete op.m;
57✔
297
    this.ops.push(op);
57✔
298
  }
299
};
300

301
SubmitRequest.prototype._addOpMeta = function() {
3✔
302
  this.op.m = {
3,267✔
303
    ts: this.start
304
  };
305
  if (this.op.create) {
3,267✔
306
    // Consistently store the full URI of the type, not just its short name
307
    this.op.create.type = ot.normalizeType(this.op.create.type);
2,028✔
308
  }
309
};
310

311
SubmitRequest.prototype._addSnapshotMeta = function() {
3✔
312
  var meta = this.snapshot.m || (this.snapshot.m = {});
3,249✔
313
  if (this.op.create) {
3,249✔
314
    meta.ctime = this.start;
2,013✔
315
  } else if (this.op.del) {
1,236✔
316
    this.op.m.data = this.snapshot.data;
126✔
317
  }
318
  meta.mtime = this.start;
3,249✔
319
};
320

321
SubmitRequest.prototype._shouldSaveMilestoneSnapshot = function(snapshot) {
3✔
322
  // If the flag is null, it's not been overridden by the consumer, so apply the interval
323
  if (this.saveMilestoneSnapshot === null) {
3,105✔
324
    return snapshot && snapshot.v % this.backend.milestoneDb.interval === 0;
3,093✔
325
  }
326

327
  return this.saveMilestoneSnapshot;
12✔
328
};
329

330
SubmitRequest.prototype._fetchCreateOpVersion = function(callback) {
3✔
331
  var create = this.snapshot.m._create;
27✔
332
  if (create) {
27✔
333
    var version = (create.src === this.op.src && create.seq === this.op.seq) ? create.v : null;
15✔
334
    return callback(null, version);
15✔
335
  }
336

337
  // We can only reach here if the snapshot is missing the create metadata.
338
  // This can happen if a client tries to re-create or resubmit a create op to
339
  // a "legacy" snapshot that existed before we started adding the meta (should
340
  // be uncommon) or when using a driver that doesn't support metadata (eg Postgres).
341
  this.backend.db.getCommittedOpVersion(this.collection, this.id, this.snapshot, this.op, null, callback);
12✔
342
};
343

344
// Non-fatal client errors:
345
SubmitRequest.prototype.alreadySubmittedError = function() {
3✔
346
  return new ShareDBError(ERROR_CODE.ERR_OP_ALREADY_SUBMITTED, 'Op already submitted');
15✔
347
};
348
SubmitRequest.prototype.rejectedError = function() {
3✔
349
  return new ShareDBError(ERROR_CODE.ERR_OP_SUBMIT_REJECTED, 'Op submit rejected');
24✔
350
};
351
// Fatal client errors:
352
SubmitRequest.prototype.alreadyCreatedError = function() {
3✔
353
  return new ShareDBError(ERROR_CODE.ERR_DOC_ALREADY_CREATED, 'Invalid op submitted. Document already created');
12✔
354
};
355
SubmitRequest.prototype.newerVersionError = function() {
3✔
356
  return new ShareDBError(
3✔
357
    ERROR_CODE.ERR_OP_VERSION_NEWER_THAN_CURRENT_SNAPSHOT,
358
    'Invalid op submitted. Op version newer than current snapshot'
359
  );
360
};
361
SubmitRequest.prototype.projectionError = function() {
3✔
362
  return new ShareDBError(
9✔
363
    ERROR_CODE.ERR_OP_NOT_ALLOWED_IN_PROJECTION,
364
    'Invalid op submitted. Operation invalid in projected collection'
365
  );
366
};
367
// Fatal internal errors:
368
SubmitRequest.prototype.missingOpsError = function() {
3✔
369
  return new ShareDBError(
3✔
370
    ERROR_CODE.ERR_SUBMIT_TRANSFORM_OPS_NOT_FOUND,
371
    'Op submit failed. DB missing ops needed to transform it up to the current snapshot version'
372
  );
373
};
374
SubmitRequest.prototype.versionDuringTransformError = function() {
3✔
375
  return new ShareDBError(
3✔
376
    ERROR_CODE.ERR_OP_VERSION_MISMATCH_DURING_TRANSFORM,
377
    'Op submit failed. Versions mismatched during op transform'
378
  );
379
};
380
SubmitRequest.prototype.versionAfterTransformError = function() {
3✔
UNCOV
381
  return new ShareDBError(
×
382
    ERROR_CODE.ERR_OP_VERSION_MISMATCH_AFTER_TRANSFORM,
383
    'Op submit failed. Op version mismatches snapshot after op transform'
384
  );
385
};
386
SubmitRequest.prototype.maxRetriesError = function() {
3✔
387
  return new ShareDBError(
3✔
388
    ERROR_CODE.ERR_MAX_SUBMIT_RETRIES_EXCEEDED,
389
    'Op submit failed. Exceeded max submit retries of ' + this.maxRetries
390
  );
391
};
392
SubmitRequest.prototype.noOpError = function() {
3✔
393
  return new ShareDBError(
6✔
394
    ERROR_CODE.ERR_NO_OP,
395
    'Op is a no-op. Skipping apply.'
396
  );
397
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc