diff --git a/shared-libs/infodoc/src/infodoc.js b/shared-libs/infodoc/src/infodoc.js index 2fcdad0bd44..c133de3cb7f 100644 --- a/shared-libs/infodoc/src/infodoc.js +++ b/shared-libs/infodoc/src/infodoc.js @@ -19,8 +19,7 @@ const findInfoDocs = (database, ids) => { }; // -// Given a set of changes, find all the infoDocs or create them as necessary. Also takes care of -// migrating legacy infodocs from the medic db, and legacy transition information from records. +// Given a set of changes, find all the infoDocs or create them as necessary. // // @param {Array} changes an array of PouchDB changes objects, each containing at least {id, doc} // @return {Array} array of infodocs. NB: will not necessarily be in the same order as the @@ -35,9 +34,7 @@ const resolveInfoDocs = (changes, writeDirtyInfoDocs) => { return results.reduce((acc, row) => { if (!row.doc) { acc.missing.push({ _id: row.key }); - } else if (!row.doc.transitions) { - // No transitions may mean that API created this infodoc on write but sentinel hasn't seen - // it yet. It's possible that there is a legacy infodoc with transition information. + } else if (!row.doc.transitions && !row.doc.transitions_started) { acc.missingTransitions.push(row.doc); } else { acc.valid.push(row.doc); @@ -48,95 +45,35 @@ const resolveInfoDocs = (changes, writeDirtyInfoDocs) => { }, { valid: [], missing: [], missingTransitions: [] }); }; + const changeForInfoDoc = infoDocId => changes.find(change => getInfoDocId(change.id) === infoDocId); + const infoDocIds = changes.map(change => getInfoDocId(change.id)); - // First attempt, directly from sentinel where they should live return findInfoDocs(db.sentinel, infoDocIds) .then(results => { - const { valid, missing, missingTransitions: missingTransitionsSentinel } = splitInfoDocRows(results); - - const lookForInMedic = missing.concat(missingTransitionsSentinel).map(r => r._id); - - if (!lookForInMedic.length) { - return valid; - } - - // the infodocs missing transitions are still valid, we just need to look for their transitions! - const infoDocs = valid.concat(missingTransitionsSentinel); - - // Missing infodocs or missing transitions may be either - return findInfoDocs(db.medic, lookForInMedic) - .then(results => { - const migratedInfoDocs = []; - const { valid, missing, missingTransitions: missingTransitionsMedic } = splitInfoDocRows(results); - - // Back when infodocs were in the medic db, transitions were still stored against the - // actual document. We'll deal with this below - valid.push(...missingTransitionsMedic); - - // Convert valid MedicDB infodocs into Sentinel ones - valid.forEach(medicInfoDoc => { - const sentinelInfoDoc = missingTransitionsSentinel.find(d => d._id === medicInfoDoc._id); - - const change = changes.find(change => change.id === medicInfoDoc.doc_id); - - if (sentinelInfoDoc) { - // Merge information from the medic infodoc into sentinel's - Object.keys(medicInfoDoc).forEach(k => { - if (sentinelInfoDoc[k] === undefined) { - sentinelInfoDoc[k] = medicInfoDoc[k]; - } - }); - - // Explicitly take the older (and so more correct) initial_replication_date. These would - // be different if a new write occurred on an old infodoc-unmigrated document, as api - // creates a new sentinel infodoc with an initial and latest replication date - sentinelInfoDoc.initial_replication_date = medicInfoDoc.initial_replication_date; - - // Source transitions from the document if they don't exist - sentinelInfoDoc.transitions = sentinelInfoDoc.transitions || (change.doc && change.doc.transitions); - - migratedInfoDocs.push(sentinelInfoDoc); - } else { - const infoDoc = Object.assign({}, medicInfoDoc); - delete infoDoc._rev; - infoDoc.transitions = change.doc && change.doc.transitions; - infoDocs.push(infoDoc); - migratedInfoDocs.push(infoDoc); - } - - medicInfoDoc._deleted = true; - }); - - // Intentionally not waiting on the promise for performance - if (valid.length) { - db.medic.bulkDocs(valid); - } - - // Infodocs that aren't in the Medic DB. This could mean there isn't one at all, or it - // could be that there was one without transition data back in sentinel - missing.forEach(missingDoc => { - const docId = getDocId(missingDoc._id); + const { valid, missing, missingTransitions } = splitInfoDocRows(results); - const collectedInfoDoc = infoDocs.find(i => i._id === missingDoc._id); - const change = changes.find(change => change.id === docId); - const infoDoc = collectedInfoDoc || blankInfoDoc(docId, !change.doc._rev && Date.now()); + const infoDocs = valid.concat(missingTransitions); + const dirtyInfoDocs = []; - infoDoc.transitions = change.doc && change.doc.transitions; - - if (!collectedInfoDoc) { - infoDocs.push(infoDoc); - } + missingTransitions.forEach(infoDoc => { + const change = changeForInfoDoc(infoDoc._id); + infoDoc.transitions = infoDoc.transitions || change.doc?.transitions; + dirtyInfoDocs.push(infoDoc); + }); - migratedInfoDocs.push(infoDoc); - }); + missing.forEach(missingDoc => { + const change = changeForInfoDoc(missingDoc._id); + const infoDoc = blankInfoDoc(change.id, !change.doc._rev && Date.now()); + infoDoc.transitions = change.doc && change.doc.transitions; + infoDocs.push(infoDoc); + dirtyInfoDocs.push(infoDoc); + }); - // Store any infoDocs that have been migrated. - if (writeDirtyInfoDocs && migratedInfoDocs.length) { - return bulkUpdate(migratedInfoDocs); - } - return infoDocs; - }); + if (writeDirtyInfoDocs && dirtyInfoDocs.length) { + return bulkUpdate(dirtyInfoDocs).then(() => infoDocs); + } + return infoDocs; }); }; @@ -151,33 +88,62 @@ const updateTransition = (change, transition, ok) => { }; }; -const saveTransitions = change => { - return saveProperty(change.id, change.info, 'transitions', {}); +const saveTransitions = (change, clearStarted = false) => { + const modify = infoDoc => { + infoDoc.transitions = change.info?.transitions || {}; + // Clear the in-progress marker in the same write that commits the transitions (API branch only) + if (clearStarted) { + delete infoDoc.transitions_started; + } + }; + return modifyInfoDoc(change.id, modify, change.info); }; const saveCompletedTasks = (id, infodoc, completedTasks = []) => { - return saveProperty(id, infodoc, 'completed_tasks', completedTasks); + return modifyInfoDoc(id, infoDoc => { + infoDoc.completed_tasks = infodoc?.completed_tasks || completedTasks; + }, infodoc); +}; + +const markTransitionsStarted = (id) => { + const modify = infoDoc => { + infoDoc.transitions_started = new Date().toISOString(); + }; + return modifyInfoDoc(id, modify, blankInfoDoc(id)); }; -const saveProperty = async (id, infodoc, property, defaultValue = {}) => { - let updatedInfoDoc; +const clearTransitionsStarted = (id) => { + const modify = infoDoc => { + delete infoDoc.transitions_started; + }; + return modifyInfoDoc(id, modify, blankInfoDoc(id)); +}; + +// Fetch the infodoc. If it is missing, return `fallback` (to be created) when provided; +const fetchInfoDoc = async (id, fallback) => { try { - updatedInfoDoc = await db.sentinel.get(getInfoDocId(id)); - updatedInfoDoc[property] = (infodoc && infodoc[property]) || defaultValue; + return await db.sentinel.get(getInfoDocId(id)); } catch (err) { - if (err.status !== 404) { - throw err; + if (err.status === 404 && fallback) { + return fallback; } - updatedInfoDoc = infodoc; + throw err; } +}; + +// Fetch the infodoc, apply `modify`, and save, retrying on conflict +const modifyInfoDoc = async (id, modify, fallback) => { + const infoDoc = await fetchInfoDoc(id, fallback); + + modify(infoDoc); try { - return await db.sentinel.put(updatedInfoDoc); + return await db.sentinel.put(infoDoc); } catch (err) { if (err.status !== 409) { throw err; } - return saveProperty(id, infodoc, property, defaultValue); + return modifyInfoDoc(id, modify, fallback); } }; @@ -283,6 +249,8 @@ module.exports = { bulkUpdate: bulkUpdate, saveTransitions: saveTransitions, saveCompletedTasks: saveCompletedTasks, + markTransitionsStarted: markTransitionsStarted, + clearTransitionsStarted: clearTransitionsStarted, // Used to update infodoc metadata that occurs at write time. A delete does not count as a write // in this instance, as deletes resolve as infodoc cleanups once sentinel's background-cleanup diff --git a/shared-libs/infodoc/test/infodoc.js b/shared-libs/infodoc/test/infodoc.js index dbf713c81c0..1fdfc630bc1 100644 --- a/shared-libs/infodoc/test/infodoc.js +++ b/shared-libs/infodoc/test/infodoc.js @@ -103,7 +103,7 @@ describe('infodoc', () => { }); }); - it('should return infodocs when all are found in medic db', () => { + it('should fill transition info from the document if sentinel infodocs exist with no transitions', () => { const changes = [ { id: 'a', doc: { _id: 'a', _rev: '1-abc', transitions: { some: 'a data' }}}, { id: 'b', doc: { _id: 'b', _rev: '1-abc', transitions: { some: 'b data' }}}, @@ -115,64 +115,23 @@ describe('infodoc', () => { { _id: 'c-info', _rev: 'c-r', type: 'info', doc_id: 'c' } ]; + // db.medic is intentionally left unstubbed: it throws if touched, guarding against any + // regression that reintroduces a medic lookup. sinon.stub(db.sentinel, 'allDocs') - .resolves({ rows: infoDocs.map(doc => ({ key: doc._id, error: 'not_found' }))}); - sinon.stub(db.medic, 'allDocs') .resolves({ rows: infoDocs.map(doc => ({ key: doc._id, doc }))}); - sinon.stub(db.medic, 'bulkDocs') - .resolves(); - return lib.bulkGet(changes).then(result => { assert.deepEqual(result, [ - { _id: 'a-info', type: 'info', doc_id: 'a', transitions: {some: 'a data'} }, - { _id: 'b-info', type: 'info', doc_id: 'b', transitions: {some: 'b data'} }, - { _id: 'c-info', type: 'info', doc_id: 'c', transitions: {some: 'c data'} } + { _id: 'a-info', _rev: 'a-r', type: 'info', doc_id: 'a', transitions: {some: 'a data'} }, + { _id: 'b-info', _rev: 'b-r', type: 'info', doc_id: 'b', transitions: {some: 'b data'} }, + { _id: 'c-info', _rev: 'c-r', type: 'info', doc_id: 'c', transitions: {some: 'c data'} } ]); assert.equal(db.sentinel.allDocs.callCount, 1); assert.deepEqual(db.sentinel.allDocs.args[0], [{ keys: ['a-info', 'b-info', 'c-info'], include_docs: true }]); - assert.equal(db.medic.allDocs.callCount, 1); - assert.deepEqual(db.medic.allDocs.args[0], [{ keys: ['a-info', 'b-info', 'c-info'], include_docs: true }]); }); }); - it( - 'should try to fill transition info from the medic doc if sentinel infodocs exist with no transition info', - () => { - const changes = [ - { id: 'a', doc: { _id: 'a', _rev: '1-abc', transitions: { some: 'a data' }}}, - { id: 'b', doc: { _id: 'b', _rev: '1-abc', transitions: { some: 'b data' }}}, - { id: 'c', doc: { _id: 'c', _rev: '1-abc', transitions: { some: 'c data' }}} - ]; - const infoDocs = [ - { _id: 'a-info', _rev: 'a-r', type: 'info', doc_id: 'a' }, - { _id: 'b-info', _rev: 'b-r', type: 'info', doc_id: 'b' }, - { _id: 'c-info', _rev: 'c-r', type: 'info', doc_id: 'c' } - ]; - - sinon.stub(db.sentinel, 'allDocs') - .resolves({ rows: infoDocs.map(doc => ({ key: doc._id, doc }))}); - sinon.stub(db.medic, 'allDocs') - .resolves({ rows: infoDocs.map(doc => ({ key: doc._id, error: 'not_found' }))}); - sinon.stub(db.medic, 'bulkDocs') - .resolves(); - - return lib.bulkGet(changes).then(result => { - assert.deepEqual(result, [ - { _id: 'a-info', _rev: 'a-r', type: 'info', doc_id: 'a', transitions: {some: 'a data'} }, - { _id: 'b-info', _rev: 'b-r', type: 'info', doc_id: 'b', transitions: {some: 'b data'} }, - { _id: 'c-info', _rev: 'c-r', type: 'info', doc_id: 'c', transitions: {some: 'c data'} } - ]); - - assert.equal(db.sentinel.allDocs.callCount, 1); - assert.deepEqual(db.sentinel.allDocs.args[0], [{ keys: ['a-info', 'b-info', 'c-info'], include_docs: true }]); - assert.equal(db.medic.allDocs.callCount, 1); - assert.deepEqual(db.medic.allDocs.args[0], [{ keys: ['a-info', 'b-info', 'c-info'], include_docs: true }]); - }); - } - ); - it('should generate infodocs with unknown dates for existing documents, if they do not already exist', () => { const changes = [ { id: 'a', doc: {_id: 'a', _rev: '1-abc' }}, @@ -182,8 +141,6 @@ describe('infodoc', () => { sinon.stub(db.sentinel, 'allDocs') .resolves({ rows: changes.map(doc => ({ key: `${doc.id}-info`, error: 'not_found' }))}); - sinon.stub(db.medic, 'allDocs') - .resolves({ rows: changes.map(doc => ({ key: `${doc.id}-info`, error: 'not_found' }))}); return lib.bulkGet(changes).then(result => { assert.deepEqual(result, [ @@ -203,8 +160,6 @@ describe('infodoc', () => { assert.equal(db.sentinel.allDocs.callCount, 1); assert.deepEqual(db.sentinel.allDocs.args[0], [{ keys: ['a-info', 'b-info', 'c-info'], include_docs: true }]); - assert.equal(db.medic.allDocs.callCount, 1); - assert.deepEqual(db.medic.allDocs.args[0], [{ keys: ['a-info', 'b-info', 'c-info'], include_docs: true }]); }); }); @@ -220,8 +175,6 @@ describe('infodoc', () => { sinon.stub(db.sentinel, 'allDocs') .resolves({ rows: changes.map(doc => ({ key: `${doc.id}-info`, error: 'not_found' }))}); - sinon.stub(db.medic, 'allDocs') - .resolves({ rows: changes.map(doc => ({ key: `${doc.id}-info`, error: 'not_found' }))}); const now = Date.now(); @@ -239,48 +192,20 @@ describe('infodoc', () => { }); }); - it('should merge medic infodoc into sentinel when sentinel has no transitions', () => { - const change = { - id: 'test', - doc: { _id: 'test', _rev: '1-abc', transitions: { from_doc: true } } - }; - - const sentinelInfoDoc = { - _id: 'test-info', - _rev: '1-s', - type: 'info', - doc_id: 'test', - initial_replication_date: 'new-date', - latest_replication_date: 'new-date' - }; + it('creates and persists an infodoc via get when none exists', () => { + const change = { id: 'x', doc: { _id: 'x', _rev: '1-abc', transitions: { t: 1 } } }; - const medicInfoDoc = { - _id: 'test-info', - _rev: '1-m', - type: 'info', - doc_id: 'test', - extra_field: 'from medic', - initial_replication_date: 'old-date', - latest_replication_date: 'old-date', - transitions: { old_transition: true } - }; - - sinon.stub(db.sentinel, 'allDocs').resolves({ - rows: [{ key: 'test-info', doc: sentinelInfoDoc }] - }); - sinon.stub(db.medic, 'allDocs').resolves({ - rows: [{ key: 'test-info', doc: medicInfoDoc }] - }); - sinon.stub(db.medic, 'bulkDocs').resolves(); - sinon.stub(db.sentinel, 'bulkDocs').resolves([{ ok: true, rev: '2-s' }]); + sinon.stub(db.sentinel, 'allDocs').resolves({ rows: [{ key: 'x-info', error: 'not_found' }] }); + const bulkDocs = sinon.stub(db.sentinel, 'bulkDocs').resolves([{ ok: true, id: 'x-info', rev: '1-x' }]); return lib.get(change).then(result => { - assert.equal(result.extra_field, 'from medic'); - assert.equal(result.initial_replication_date, 'old-date'); - assert.deepEqual(result.transitions, { old_transition: true }); - assert.isTrue(medicInfoDoc._deleted); - assert.equal(db.sentinel.bulkDocs.callCount, 1); - assert.equal(db.medic.bulkDocs.callCount, 1); + assert.deepEqual(result, { + _id: 'x-info', type: 'info', doc_id: 'x', + initial_replication_date: 'unknown', latest_replication_date: 'unknown', + transitions: { t: 1 }, _rev: '1-x' + }); + assert.equal(bulkDocs.callCount, 1); + assert.deepEqual(bulkDocs.args[0][0], [result]); }); }); @@ -303,21 +228,16 @@ describe('infodoc', () => { { key: 'e-info', error: 'deleted' }, { key: 'f-info', error: 'something' }, ]}); - sinon.stub(db.medic, 'allDocs') - .resolves({ rows: [ - { key: 'b-info', id: 'b-info', doc: { _id: 'b-info', _rev: 'b-r', doc_id: 'b' } }, - { key: 'c-info', error: 'some error' }, - { key: 'e-info', error: 'some error' }, - { key: 'f-info', id: 'f-info', doc: { _id: 'f-info', _rev: 'f-r', doc_id: 'f' } }, - ]}); - sinon.stub(db.medic, 'bulkDocs').resolves(); + // db.medic is left unstubbed: it throws if touched, guarding against a reintroduced lookup. return lib.bulkGet(changes).then(result => { assert.deepEqual(result, [ { _id: 'a-info', _rev: 'a-r', doc_id: 'a', transitions: {} }, { _id: 'd-info', _rev: 'd-r', doc_id: 'd', transitions: {} }, - { _id: 'b-info', doc_id: 'b', transitions: undefined }, - { _id: 'f-info', doc_id: 'f', transitions: undefined }, + { + _id: 'b-info', doc_id: 'b', initial_replication_date: 'unknown', + latest_replication_date: 'unknown', type: 'info', transitions: undefined + }, { _id: 'c-info', doc_id: 'c', initial_replication_date: 'unknown', latest_replication_date: 'unknown', type: 'info', transitions: undefined @@ -326,6 +246,10 @@ describe('infodoc', () => { _id: 'e-info', doc_id: 'e', initial_replication_date: 'unknown', latest_replication_date: 'unknown', type: 'info', transitions: undefined }, + { + _id: 'f-info', doc_id: 'f', initial_replication_date: 'unknown', + latest_replication_date: 'unknown', type: 'info', transitions: undefined + }, ]); assert.equal(db.sentinel.allDocs.callCount, 1); @@ -333,11 +257,6 @@ describe('infodoc', () => { db.sentinel.allDocs.args[0], [{ keys: ['a-info', 'b-info', 'c-info', 'd-info', 'e-info', 'f-info'], include_docs: true } ] ); - assert.equal(db.medic.allDocs.callCount, 1); - assert.deepEqual( - db.medic.allDocs.args[0], - [{ keys: ['b-info', 'c-info', 'e-info', 'f-info'], include_docs: true }] - ); }); }); }); @@ -604,6 +523,80 @@ describe('infodoc', () => { assert.deepEqual(db.sentinel.put.args[20], [{ ...info, transitions: change.info.transitions }]); }); }); + + it('clears transitions_started when clearStarted is set', () => { + const info = { _id: 'some-info', doc_id: 'some', transitions_started: '2026-01-01T00:00:00.000Z' }; + const change = { id: 'some', info: { transitions: { one: { ok: true } } } }; + sinon.stub(db.sentinel, 'get').resolves(info); + sinon.stub(db.sentinel, 'put').resolves(); + + return lib.saveTransitions(change, true).then(() => { + const saved = db.sentinel.put.args[0][0]; + // transitions are written and the mid-write marker removed; nothing else is touched + assert.deepEqual(saved, { + _id: 'some-info', + doc_id: 'some', + transitions: { one: { ok: true } }, + }); + }); + }); + }); + + describe('markTransitionsStarted / clearTransitionsStarted', () => { + it('markTransitionsStarted marks the infodoc mid-write with a timestamp', () => { + const info = { _id: 'some-info', doc_id: 'some' }; + sinon.stub(db.sentinel, 'get').resolves(info); + sinon.stub(db.sentinel, 'put').resolves(); + + return lib.markTransitionsStarted('some').then(() => { + assert.deepEqual(db.sentinel.get.args[0], ['some-info']); + const saved = db.sentinel.put.args[0][0]; + assert.isString(saved.transitions_started); + assert.isNotNaN(Date.parse(saved.transitions_started)); + // only the marker is added, no other fields are changed + assert.deepEqual(saved, { + _id: 'some-info', + doc_id: 'some', + transitions_started: saved.transitions_started, + }); + }); + }); + + it('clearTransitionsStarted removes the mid-write marker', () => { + const info = { _id: 'some-info', doc_id: 'some', transitions_started: '2026-01-01T00:00:00.000Z' }; + sinon.stub(db.sentinel, 'get').resolves(info); + sinon.stub(db.sentinel, 'put').resolves(); + + return lib.clearTransitionsStarted('some').then(() => { + const saved = db.sentinel.put.args[0][0]; + // only the marker is removed, no other fields are changed + assert.deepEqual(saved, { _id: 'some-info', doc_id: 'some' }); + }); + }); + + it('retries on 409 conflict indefinitely (no retry limit)', () => { + const info = { _id: 'some-info', doc_id: 'some' }; + sinon.stub(db.sentinel, 'get').resolves(info); + const put = sinon.stub(db.sentinel, 'put'); + // conflict on the first 100 attempts, succeed on the 101st - a retry limit below this would fail + for (let i = 0; i < 100; i++) { + put.onCall(i).rejects({ status: 409 }); + } + put.onCall(100).resolves(); + + return lib.markTransitionsStarted('some').then(() => { + assert.equal(db.sentinel.put.callCount, 101); + }); + }); + + it('throws non-409 errors', () => { + sinon.stub(db.sentinel, 'get').resolves({ _id: 'some-info', doc_id: 'some' }); + sinon.stub(db.sentinel, 'put').rejects({ status: 500 }); + + return lib.markTransitionsStarted('some') + .then(() => assert.fail('should have thrown')) + .catch(err => assert.equal(err.status, 500)); + }); }); describe('saveCompletedTasks', () => { diff --git a/shared-libs/transitions/src/transitions/index.js b/shared-libs/transitions/src/transitions/index.js index 4b9db7f101d..869cb8eb5a1 100644 --- a/shared-libs/transitions/src/transitions/index.js +++ b/shared-libs/transitions/src/transitions/index.js @@ -45,13 +45,33 @@ const AVAILABLE_TRANSITIONS = [ const transitions = []; let loadErrors = false; +const MAX_INFODOC_WAIT = 5; +const INFODOC_WAIT_INTERVAL = 100; + +const isInfoDocMidWrite = infoDoc => infoDoc?.transitions_started !== undefined; + +const getConsistentInfoDoc = async (change, retriesLeft) => { + const infoDoc = await infodoc.get(change); + if (!isInfoDocMidWrite(infoDoc) || retriesLeft <= 0) { + return infoDoc; + } + await new Promise(resolve => setTimeout(resolve, INFODOC_WAIT_INTERVAL)); + return getConsistentInfoDoc(change, retriesLeft - 1); +}; + // applies all loaded transitions over a change const processChange = (change, callback) => { lineage .fetchHydratedDoc(change.id) .then(doc => { change.doc = doc; - return infodoc.get(change).then(infoDoc => { + return getConsistentInfoDoc(change, MAX_INFODOC_WAIT).then(infoDoc => { + if (isInfoDocMidWrite(infoDoc)) { + logger.warn( + `transitions: infodoc for ${change.id} still mid-write after ${MAX_INFODOC_WAIT} retries, skipping` + ); + return callback(); + } change.info = infoDoc; change.initialProcessing = !infoDoc.transitions; // Remove transitions from doc since those @@ -107,10 +127,11 @@ const processDocs = docs => { // doc was not changed by any transition, so we save the original doc change.doc = docs.find(doc => doc._id === change.id); - saveDoc(change, (err, result) => { - callback(null, err || result); - }); - }); + saveDoc(change).then( + result => callback(null, result), + err => callback(null, err) + ); + }, { markStarted: true }); }); async.series(operations, (err, results) => { return err ? reject(err) : resolve(results); @@ -235,46 +256,61 @@ const canRun = ({ key, change, transition }) => { * did nothing and saving is unnecessary. If results has a true value in * it then a change was made. */ -const finalize = ({ change, results }, callback) => { +const finalize = ({ change, results, markStarted = false }, callback) => { + finalizeChange({ change, results, markStarted }) + .then(result => callback(null, result)) + .catch(err => { + logger.error(`error saving changes on doc ${change.id} seq ${change.seq}: %o`, err); + callback(err); + }); +}; + +const finalizeChange = async ({ change, results, markStarted }) => { logger.debug(`transition results: ${JSON.stringify(results)}`); - const changed = _.some(results, i => Boolean(i)); - if (!changed) { - logger.debug( - `nothing changed skipping saveDoc for doc ${change.id} seq ${change.seq}` - ); - // info.transitions is how we know if a doc has been processed by Sentinel before. Even if no transitions ran, - // we still want to save transitions, so we know it's been processed. - return Promise - .resolve() - .then(() => { - if (change.initialProcessing) { - return infodoc.saveTransitions(change); - } - }) - .then(() => callback()) - .catch(err => callback(err)); + if (!_.some(results, Boolean)) { + logger.debug(`nothing changed skipping saveDoc for doc ${change.id} seq ${change.seq}`); + // info.transitions is how we know Sentinel has processed a doc; record it even when nothing ran. + if (change.initialProcessing) { + await infodoc.saveTransitions(change); + } + return; } + logger.debug(`calling saveDoc on doc ${change.id} seq ${change.seq}`); + return markStarted ? saveForApi(change) : saveForSentinel(change); +}; - saveDoc(change, (err, result) => { - // todo: how to handle a failed save? for now just - // waiting until next change and try again. - if (err) { - logger.error(`error saving changes on doc ${change.id} seq ${change.seq}: %o`, err); - return callback(err); - } +// Sentinel processing: save the doc, then record transitions. Sentinel never writes the +// transitions_started marker; it only reads it (see getConsistentInfoDoc) to skip docs API is writing. +const saveForSentinel = async change => { + const result = await saveDoc(change); + logger.info(`saved changes on doc ${change.id} seq ${change.seq}`); + await infodoc.saveTransitions(change); + return result; +}; +// API processing: mark the infodoc mid-write (transitions_started) around the doc write so a concurrent +// sentinel read detects it and waits. The marker is cleared as part of the transitions write on +// success, or rolled back on any failure after it's set. +const saveForApi = async change => { + await infodoc.markTransitionsStarted(change.id); + try { + const result = await saveDoc(change); logger.info(`saved changes on doc ${change.id} seq ${change.seq}`); - infodoc.saveTransitions(change) - .then(() => callback(null, result)) - .catch(err => callback(err)); - }); + await infodoc.saveTransitions(change, true); + return result; + } catch (err) { + await infodoc + .clearTransitionsStarted(change.id) + .catch(clearErr => logger.error(`error clearing transitions_started on doc ${change.id}: %o`, clearErr)); + throw err; + } }; -const saveDoc = (change, callback) => { +const saveDoc = change => { lineage.minify(change.doc); - db.medic.put(change.doc, callback); + return db.medic.put(change.doc); }; /* @@ -335,7 +371,7 @@ const applyTransition = ({ key, change, transition, force }, callback) => { .then(changed => callback(null, changed)); // return the promise instead }; -const applyTransitions = (change, callback) => { +const applyTransitions = (change, callback, { markStarted = false } = {}) => { const operations = transitions .map(transition => { const opts = { @@ -353,7 +389,7 @@ const applyTransitions = (change, callback) => { * function. All we care about are results and whether we need to * save or not. */ - async.series(operations, (err, results) => finalize({ change, results }, callback)); + async.series(operations, (err, results) => finalize({ change, results, markStarted }, callback)); }; const availableTransitions = () => { diff --git a/shared-libs/transitions/test/integration/transitions.js b/shared-libs/transitions/test/integration/transitions.js index 30e4ed31366..5bf30a495e2 100644 --- a/shared-libs/transitions/test/integration/transitions.js +++ b/shared-libs/transitions/test/integration/transitions.js @@ -5,6 +5,7 @@ const chaiExclude = require('chai-exclude'); const db = require('../../src/db'); const config = require('../../src/config'); const infodoc = require('@medic/infodoc'); +const logger = require('@medic/logger'); const dataContext = require('../../src/data-context'); const { Contact } = require('@medic/cht-datasource'); const { DOC_TYPES, CONTACT_TYPES } = require('@medic/constants'); @@ -41,8 +42,10 @@ describe('functional transitions', () => { }, }); const infoDocSave = sinon.stub(infodoc, 'saveTransitions').resolves(); + sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); sinon.stub(db.medic, 'get').rejects({ status: 404 }); - const saveDoc = sinon.stub(db.medic, 'put').callsArgWith(1, null, { ok: true }); + const saveDoc = sinon.stub(db.medic, 'put').resolves({ ok: true }); transitions.loadTransitions(); const change1 = { @@ -97,8 +100,10 @@ describe('functional transitions', () => { }, }); - const saveDoc = sinon.stub(db.medic, 'put').callsArgWith(1, null, { ok: true }); + const saveDoc = sinon.stub(db.medic, 'put').resolves({ ok: true }); const infoDoc = sinon.stub(infodoc, 'saveTransitions').resolves(); + sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); transitions.loadTransitions(); const change1 = { @@ -164,8 +169,10 @@ describe('functional transitions', () => { }); configGet.withArgs('forms').returns({ V: { }}); - const saveDoc = sinon.stub(db.medic, 'put').callsArgWith(1, null, { ok: true }); + const saveDoc = sinon.stub(db.medic, 'put').resolves({ ok: true }); const infoDoc = sinon.stub(infodoc, 'saveTransitions').resolves(); + sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); transitions.loadTransitions(); const change1 = { @@ -264,7 +271,7 @@ describe('functional transitions', () => { transitions.loadTransitions(); transitions.processChange({ id: doc._id}, (err, result) => { - assert.isUndefined(err); + assert.isNull(err); assert.isUndefined(result); assert.equal(infodoc.get.callCount, 1); @@ -293,8 +300,10 @@ describe('functional transitions', () => { sinon.stub(infodoc, 'get').resolves(info); sinon.stub(infodoc, 'saveTransitions').resolves(); + sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); - sinon.stub(db.medic, 'put').callsArgWith(1, null, { ok: true }); + sinon.stub(db.medic, 'put').resolves({ ok: true }); sinon.spy(transitions, 'applyTransitions'); const doc = { @@ -351,7 +360,9 @@ describe('functional transitions', () => { sinon.stub(infodoc, 'get').resolves({}); sinon.stub(infodoc, 'saveTransitions').resolves(); - sinon.stub(db.medic, 'put').callsArgWith(1, { error: 'something' }); + sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); + sinon.stub(db.medic, 'put').rejects({ error: 'something' }); const doc = { _id: 'my_id', @@ -378,6 +389,76 @@ describe('functional transitions', () => { done(); }); }); + + // fake timers so the 100ms retry waits don't cost real time or flake near mocha's timeout; + // tickAsync advances the clock and flushes the awaited infodoc.get between each retry + const RETRY_INTERVAL = 100; + const MAX_RETRIES = 5; + const processChangeWithFakeTimers = async (change) => { + const clock = sinon.useFakeTimers(); + const processed = new Promise((resolve, reject) => { + transitions.processChange(change, (err, result) => (err ? reject(err) : resolve(result))); + }); + // one extra tick past MAX_RETRIES is harmless and guarantees the change settles either way + for (let i = 0; i <= MAX_RETRIES; i++) { + await clock.tickAsync(RETRY_INTERVAL); + } + return processed; + }; + + it('should wait for transitions_started to clear before processing the change', async () => { + configGet.withArgs('transitions').returns({ conditional_alerts: {} }); + configGet.withArgs('forms').returns({ V: { } }); + + // infodoc is mid-write (API is running transitions) on the first two reads, then clears + const get = sinon.stub(infodoc, 'get'); + get.onCall(0).resolves({ _id: 'my_id-info', transitions: {}, transitions_started: '2026-01-01T00:00:00.000Z' }); + get.onCall(1).resolves({ _id: 'my_id-info', transitions: {}, transitions_started: '2026-01-01T00:00:00.000Z' }); + get.onCall(2).resolves({ _id: 'my_id-info', transitions: {} }); + const saveTransitions = sinon.stub(infodoc, 'saveTransitions').resolves(); + const put = sinon.stub(db.medic, 'put').resolves({ ok: true }); + + const doc = { _id: 'my_id', _rev: '1-abc', reported_date: 1 }; + sinon.stub(transitions._lineage, 'fetchHydratedDoc').resolves(doc); + + transitions.loadTransitions(); + const result = await processChangeWithFakeTimers({ id: doc._id }); + + assert.isUndefined(result); + // initial read + 2 reads where the marker was still set + assert.equal(infodoc.get.callCount, 3); + // no transition matched, so nothing is saved, but the change was processed (not skipped) + assert.equal(saveTransitions.callCount, 0); + assert.equal(put.callCount, 0); + }); + + it('should skip processing the change when transitions_started never clears', async () => { + configGet.withArgs('transitions').returns({ conditional_alerts: {} }); + configGet.withArgs('forms').returns({ V: { } }); + + // infodoc stays mid-write forever + sinon.stub(infodoc, 'get') + .resolves({ _id: 'my_id-info', transitions: {}, transitions_started: '2026-01-01T00:00:00.000Z' }); + const saveTransitions = sinon.stub(infodoc, 'saveTransitions').resolves(); + const put = sinon.stub(db.medic, 'put').resolves({ ok: true }); + + const doc = { _id: 'my_id', _rev: '1-abc', reported_date: 1 }; + sinon.stub(transitions._lineage, 'fetchHydratedDoc').resolves(doc); + + transitions.loadTransitions(); + // stub after loadTransitions so we only capture the skip warning, not the "disabled transition" ones + const warn = sinon.stub(logger, 'warn'); + const result = await processChangeWithFakeTimers({ id: doc._id }); + + assert.isUndefined(result); + // initial read + 5 retries, then it gives up + assert.equal(infodoc.get.callCount, 6); + // the change is skipped: no transitions run and nothing is saved + assert.equal(saveTransitions.callCount, 0); + assert.equal(put.callCount, 0); + assert.equal(warn.callCount, 1); + assert.match(warn.args[0][0], /still mid-write after 5 retries, skipping/); + }); }); describe('processDocs', () => { @@ -522,7 +603,7 @@ describe('functional transitions', () => { sinon.stub(db.sentinel, 'get').callsFake(id => Promise.resolve({ id, doc_id: id.replace('-info', '') })); sinon.stub(db.sentinel, 'put').resolves(); - sinon.stub(db.medic, 'put').callsArgWith(1, null, { ok: true }); + sinon.stub(db.medic, 'put').resolves({ ok: true }); sinon.stub(db.medic, 'query') // update_clinics @@ -568,10 +649,25 @@ describe('functional transitions', () => { assert.deepEqualExcluding(savedDocs[0], originalDocs[0], ['_id', 'errors', 'contact', 'sent_by', 'tasks']); // first doc is updated by 3 transitions infodocSaves = db.sentinel.put.args.filter(args => args[0].doc_id === savedDocs[0]._id); - assert.equal(infodocSaves.length, 1); - assert.equal(infodocSaves[0][0].transitions.update_clinics.ok, true); - assert.equal(infodocSaves[0][0].transitions.update_sent_by.ok, true); - assert.equal(infodocSaves[0][0].transitions.conditional_alerts.ok, true); + assert.equal(infodocSaves.length, 2); + // the first write only marks the infodoc as mid-write, with no transitions yet + let startedSave = infodocSaves.find(args => args[0].transitions_started)[0]; + assert.isString(startedSave.transitions_started); + assert.deepEqual(startedSave, { + id: `${savedDocs[0]._id}-info`, + doc_id: savedDocs[0]._id, + transitions_started: startedSave.transitions_started, + }); + // the second write commits the transitions and removes the mid-write marker + let txnSave = infodocSaves.find(args => args[0].transitions)[0]; + assert.isUndefined(txnSave.transitions_started); + assert.sameMembers( + Object.keys(txnSave.transitions), + ['update_clinics', 'update_sent_by', 'conditional_alerts'] + ); + assert.equal(txnSave.transitions.update_clinics.ok, true); + assert.equal(txnSave.transitions.update_sent_by.ok, true); + assert.equal(txnSave.transitions.conditional_alerts.ok, true); assert.equal(savedDocs[1].id, 'has default response'); assert.equal(savedDocs[1]._id.length, 36); @@ -581,21 +677,43 @@ describe('functional transitions', () => { assert.equal(savedDocs[1].errors[0].code, 'sys.facility_not_found'); assert.deepEqualExcluding(savedDocs[1], originalDocs[1], ['_id', 'tasks', 'errors']); infodocSaves = db.sentinel.put.args.filter(args => args[0].doc_id === savedDocs[1]._id); - assert.equal(infodocSaves.length, 1); - assert.equal(infodocSaves[0][0].transitions.default_responses.ok, true); - assert.equal(infodocSaves[0][0].transitions.update_clinics.ok, true); + assert.equal(infodocSaves.length, 2); + startedSave = infodocSaves.find(args => args[0].transitions_started)[0]; + assert.isString(startedSave.transitions_started); + assert.deepEqual(startedSave, { + id: `${savedDocs[1]._id}-info`, + doc_id: savedDocs[1]._id, + transitions_started: startedSave.transitions_started, + }); + txnSave = infodocSaves.find(args => args[0].transitions)[0]; + assert.isUndefined(txnSave.transitions_started); + assert.sameMembers(Object.keys(txnSave.transitions), ['default_responses', 'update_clinics']); + assert.equal(txnSave.transitions.default_responses.ok, true); + assert.equal(txnSave.transitions.update_clinics.ok, true); assert.deepEqualExcluding(savedDocs[2], originalDocs[2], '_id'); infodocSaves = db.sentinel.put.args.filter(args => args[0].doc_id === savedDocs[2]._id); + // no transition changed this doc, so it is saved as-is with a single infodoc write (no marker) assert.equal(infodocSaves.length, 1); + assert.isUndefined(infodocSaves[0][0].transitions_started); assert.equal(savedDocs[3].id, 'random form with contact'); assert.equal(savedDocs[3].sent_by, 'Angela'); assert.deepEqualExcluding(savedDocs[3], originalDocs[3], 'sent_by'); infodocSaves = db.sentinel.put.args.filter(args => args[0].doc_id === savedDocs[3]._id); - assert.equal(infodocSaves.length, 1); - assert.equal(infodocSaves[0][0].transitions.default_responses.ok, true); - assert.equal(infodocSaves[0][0].transitions.update_sent_by.ok, true); + assert.equal(infodocSaves.length, 2); + startedSave = infodocSaves.find(args => args[0].transitions_started)[0]; + assert.isString(startedSave.transitions_started); + assert.deepEqual(startedSave, { + id: `${savedDocs[3]._id}-info`, + doc_id: savedDocs[3]._id, + transitions_started: startedSave.transitions_started, + }); + txnSave = infodocSaves.find(args => args[0].transitions)[0]; + assert.isUndefined(txnSave.transitions_started); + assert.sameMembers(Object.keys(txnSave.transitions), ['default_responses', 'update_sent_by']); + assert.equal(txnSave.transitions.default_responses.ok, true); + assert.equal(txnSave.transitions.update_sent_by.ok, true); assert.equal(savedDocs[4].id, 'will have errors'); assert.equal(savedDocs[4].sent_by, 'Angela'); @@ -609,11 +727,24 @@ describe('functional transitions', () => { assert.equal(savedDocs[4].tasks[1].messages[0].message, 'too much randomness'); assert.deepEqualExcluding(savedDocs[4], originalDocs[4], ['_id', 'sent_by', 'errors', 'tasks']); infodocSaves = db.sentinel.put.args.filter(args => args[0].doc_id === savedDocs[4]._id); - assert.equal(infodocSaves.length, 1); - assert.equal(infodocSaves[0][0].transitions.default_responses.ok, true); - assert.equal(infodocSaves[0][0].transitions.update_sent_by.ok, true); - assert.equal(infodocSaves[0][0].transitions.accept_patient_reports.ok, true); - assert.equal(infodocSaves[0][0].transitions.conditional_alerts.ok, true); + assert.equal(infodocSaves.length, 2); + startedSave = infodocSaves.find(args => args[0].transitions_started)[0]; + assert.isString(startedSave.transitions_started); + assert.deepEqual(startedSave, { + id: `${savedDocs[4]._id}-info`, + doc_id: savedDocs[4]._id, + transitions_started: startedSave.transitions_started, + }); + txnSave = infodocSaves.find(args => args[0].transitions)[0]; + assert.isUndefined(txnSave.transitions_started); + assert.sameMembers( + Object.keys(txnSave.transitions), + ['default_responses', 'update_sent_by', 'accept_patient_reports', 'conditional_alerts'] + ); + assert.equal(txnSave.transitions.default_responses.ok, true); + assert.equal(txnSave.transitions.update_sent_by.ok, true); + assert.equal(txnSave.transitions.accept_patient_reports.ok, true); + assert.equal(txnSave.transitions.conditional_alerts.ok, true); }); }); }); diff --git a/shared-libs/transitions/test/unit/finalize-transition.js b/shared-libs/transitions/test/unit/finalize-transition.js index b2751759066..1eb3e1ec183 100644 --- a/shared-libs/transitions/test/unit/finalize-transition.js +++ b/shared-libs/transitions/test/unit/finalize-transition.js @@ -22,41 +22,54 @@ describe('finalize transition', () => { ); }); - it('save is called if transition results have changes', done => { + it('save is called if transition results have changes (API branch marks transitions started)', done => { const doc = { _rev: '1' }; - const saveDoc = sinon.stub(db.medic, 'put').callsArgWith(1, null, { ok: true }); + const saveDoc = sinon.stub(db.medic, 'put').resolves({ ok: true, rev: '2' }); + const markTransitionsStarted = sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + const clearTransitionsStarted = sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); sinon.stub(infodoc, 'saveTransitions').resolves(); transitions.finalize( { - change: { doc: doc }, + change: { id: 'abc', doc: doc }, results: [null, null, true], + markStarted: true, }, (err, result) => { assert.equal(saveDoc.callCount, 1); assert(saveDoc.args[0][0]._rev); assert(!err); - assert.deepEqual(result, { ok: true }); + assert.deepEqual(result, { ok: true, rev: '2' }); + assert.equal(markTransitionsStarted.callCount, 1); + assert.deepEqual(markTransitionsStarted.args[0], ['abc']); assert.equal(infodoc.saveTransitions.callCount, 1); + assert.strictEqual(infodoc.saveTransitions.args[0][1], true); + assert.equal(clearTransitionsStarted.callCount, 0); done(); } ); }); - it('should callback with save errors', done => { + it('should callback with save errors and clear the mid-write marker (API branch)', done => { const doc = { _rev: '1' }; - const saveDoc = sinon.stub(db.medic, 'put').callsArgWith(1, { error: 'something' }); + const saveDoc = sinon.stub(db.medic, 'put').rejects({ error: 'something' }); + const markTransitionsStarted = sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + const clearTransitionsStarted = sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); sinon.stub(infodoc, 'saveTransitions').resolves(); transitions.finalize( { - change: { doc: doc }, + change: { id: 'abc', doc: doc }, results: [null, null, true], + markStarted: true, }, (err, result) => { assert.deepEqual(err, { error: 'something' }); assert.equal(saveDoc.callCount, 1); assert(saveDoc.args[0][0]._rev); assert(!result); + assert.equal(markTransitionsStarted.callCount, 1); assert.equal(infodoc.saveTransitions.callCount, 0); + assert.equal(clearTransitionsStarted.callCount, 1); + assert.deepEqual(clearTransitionsStarted.args[0], ['abc']); done(); } ); diff --git a/shared-libs/transitions/test/unit/process_docs.js b/shared-libs/transitions/test/unit/process_docs.js index 2f6e7493007..fe844862d67 100644 --- a/shared-libs/transitions/test/unit/process_docs.js +++ b/shared-libs/transitions/test/unit/process_docs.js @@ -99,8 +99,10 @@ describe('processDocs', () => { sinon.stub(infodoc, 'bulkGet').resolves(infoDocs); sinon.stub(infodoc, 'bulkUpdate').resolves(); sinon.stub(transitions, 'applyTransition'); - sinon.stub(db.medic, 'put').callsArgWith(1, null, { ok: true }); + sinon.stub(db.medic, 'put').resolves({ ok: true }); sinon.stub(infodoc, 'saveTransitions').resolves(); + sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); // first doc is updated by at least one transition transitions.applyTransition @@ -148,6 +150,8 @@ describe('processDocs', () => { chai.expect(infodoc.saveTransitions.callCount).to.equal(3); chai.expect(infodoc.saveTransitions.calledWithMatch({ id: '1' })).to.equal(true); + chai.expect(infodoc.markTransitionsStarted.callCount).to.equal(1); + chai.expect(infodoc.markTransitionsStarted.calledWith('1')).to.equal(true); }); }); @@ -173,11 +177,13 @@ describe('processDocs', () => { sinon.stub(infodoc, 'bulkUpdate').resolves(); sinon.stub(transitions, 'applyTransition'); sinon.stub(db.medic, 'put') - .withArgs(sinon.match({ _id: '1' })).callsArgWith(1, null, { ok: true }) - .withArgs(sinon.match({ _id: '2' })).callsArgWith(1, { error: 'error' }) - .withArgs(sinon.match({ _id: '3' })).callsArgWith(1, null, { ok: true }) - .withArgs(sinon.match({ _id: '4' })).callsArgWith(1, { error: 'error' }); + .withArgs(sinon.match({ _id: '1' })).resolves({ ok: true }) + .withArgs(sinon.match({ _id: '2' })).rejects({ error: 'error' }) + .withArgs(sinon.match({ _id: '3' })).resolves({ ok: true }) + .withArgs(sinon.match({ _id: '4' })).rejects({ error: 'error' }); sinon.stub(infodoc, 'saveTransitions').resolves(); + sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); // first doc is updated by at least one transition transitions.applyTransition @@ -231,6 +237,9 @@ describe('processDocs', () => { chai.expect(infodoc.saveTransitions.callCount).to.equal(3); chai.expect(infodoc.saveTransitions.calledWithMatch({ id: '1' })).to.equal(true); + chai.expect(infodoc.markTransitionsStarted.callCount).to.equal(2); + chai.expect(infodoc.clearTransitionsStarted.callCount).to.equal(1); + chai.expect(infodoc.clearTransitionsStarted.calledWith('2')).to.equal(true); }); }); }); diff --git a/tests/integration/infodocs/infodocs.spec.js b/tests/integration/infodocs/infodocs.spec.js index 7a7dc2e4309..636196a6c26 100644 --- a/tests/integration/infodocs/infodocs.spec.js +++ b/tests/integration/infodocs/infodocs.spec.js @@ -162,51 +162,6 @@ describe('infodocs', () => { assert.isOk(infodoc.latest_replication_date, 'expected a latest_replication_date'); assert.deepEqual(infodoc.transitions, { some: 'transition info' }); }); - - it('finds and migrates data from the medic infodoc', async () => { - // In legacy situations the transition info was on the doc, while other - // information was on the infodoc - const testDoc = { - data: 'data', - transitions: { - some: 'transition info' - } - }; - const legacyInfodoc = { - type: 'info', - some: 'legacy data', - initial_replication_date: 1000, - latest_replication_date: 2000 - }; - - await utils.toggleSentinelTransitions(); - const result = await utils.db.post(testDoc); - testDoc._rev = result.rev; - testDoc._id = result.id; - - legacyInfodoc._id = `${result.id}-info`; - legacyInfodoc.doc_id = result.id; - - await utils.db.put(legacyInfodoc); - await utils.setTransitionSeqToNow(); - await utils.toggleSentinelTransitions(); - - testDoc.data = 'data changed'; - await utils.saveDoc(testDoc); - - const [infodoc] = await delayedInfoDocsOf(testDoc._id); - - try { - await utils.db.get(legacyInfodoc._id); - assert.fail('doc should be deleted'); - } catch (err) { - assert.equal(err.status, 404); - } - assert.equal(infodoc.initial_replication_date, 1000); - assert.isOk(infodoc.latest_replication_date !== 2000); // updated - assert.deepEqual(infodoc.transitions, { some: 'transition info' }); - assert.equal(infodoc.some, 'legacy data'); - }); }); describe('transitions infos', () => {