From 9901e8547b2d0797cf04b7af031f3a4aab0fc33f Mon Sep 17 00:00:00 2001 From: localnerve Date: Mon, 23 Feb 2026 20:13:30 -0500 Subject: [PATCH 1/5] @2.10.0 - style updates --- src/application/client/scripts/sw/sw.data.js | 27 +++++++++---------- src/application/client/scripts/sw/sw.timer.js | 4 +-- src/application/client/scripts/sw/sw.utils.js | 2 +- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/application/client/scripts/sw/sw.data.js b/src/application/client/scripts/sw/sw.data.js index bdb8ca75..332b3799 100644 --- a/src/application/client/scripts/sw/sw.data.js +++ b/src/application/client/scripts/sw/sw.data.js @@ -120,7 +120,7 @@ async function replayRequestQueue ({ queue }) { debug('Replaying queue requests...', queue); if (!queue) { - throw new _private.WorkboxError('queue-replay-failed', {name: queueName}); + throw new _private.WorkboxError('queue-replay-failed', { name: queueName }); } const getRequests = []; @@ -145,7 +145,7 @@ async function replayRequestQueue ({ queue }) { queue.push(entry); } else { const { storeType, document, collections, op } = meta; - + if (Array.isArray(collections) && collections.length > 0) { for (const coll of collections) { if (isObj(coll)) { @@ -195,11 +195,11 @@ async function replayRequestQueue ({ queue }) { try { const meta = getEntry.metadata; reqKey = `${meta.op}-${meta.storeType}-${meta.document}-${meta.collections}`; - + if (completed[reqKey]) { continue; } - + await dataAPICall(getEntry.request, { asyncResponseHandler: getResponseHandler.bind(null, meta), metadata: meta, @@ -208,7 +208,7 @@ async function replayRequestQueue ({ queue }) { completed[reqKey] = true; } catch { debug('Failed to replay get request: ', getEntry.request.url); - + for (const get of getRequests) { const meta = getEntry.metadata; reqKey = `${meta.op}-${meta.storeType}-${meta.document}-${meta.collections}`; @@ -217,7 +217,7 @@ async function replayRequestQueue ({ queue }) { } } - throw new _private.WorkboxError('queue-replay-failed', {name: queueName}); + throw new _private.WorkboxError('queue-replay-failed', { name: queueName }); } } // for - get requests } @@ -269,7 +269,7 @@ async function dataAPICall (request, { let result = -1; let response = null; - + try { response = await fetch(request.clone(), { signal: abortController.signal @@ -362,10 +362,9 @@ export async function refreshData ({ storeType, document, collections }) { const resource = makeStoreTypeURLFragment(storeType); const baseUrl = `/api/data/${resource}`; - const path = document ? `/${document}${ - typeof collections === 'string' ? `/${collections}` - : collections?.length === 1 ? `/${collections[0]}` : '' - }`: ''; + const path = document ? `/${document}${typeof collections === 'string' ? `/${collections}` + : collections?.length === 1 ? `/${collections[0]}` : '' + }` : ''; let url = `${baseUrl}${path}`; if (document && collections?.length > 1) { @@ -389,7 +388,7 @@ export async function refreshData ({ storeType, document, collections }) { storeType, document, collections, - op : opGet + op: opGet } }); } @@ -459,7 +458,7 @@ async function upsertData ({ storeType, document, collections = null }) { */ async function deleteData ({ storeType, document, collections }) { debug(`deleteData, ${storeType}:${document}`, collections); - + if (!storeType || !document) { throw new Error('Bad input passed to deleteData'); } @@ -614,7 +613,7 @@ async function processBatchUpdates () { put: upsertData, delete: deleteData }; - + debug(`processBatchUpdates processing ${totalRecords} records...`); // For iterating in descending id+storeType+document+collection order diff --git a/src/application/client/scripts/sw/sw.timer.js b/src/application/client/scripts/sw/sw.timer.js index c00078c5..8087a4c4 100644 --- a/src/application/client/scripts/sw/sw.timer.js +++ b/src/application/client/scripts/sw/sw.timer.js @@ -112,7 +112,7 @@ function checkHeartbeat (timerName, resolution) { const clientCount = heartbeat[timerName].size; let lastTime = Number.MAX_SAFE_INTEGER; let inactiveCount = 0; - + // Get the shortest heartbeat time and track the client activity for (const beat of heartbeat[timerName].values()) { if (beat.time < lastTime) lastTime = beat.time; @@ -139,7 +139,7 @@ function serviceTimer (timerName) { const callback = timers[timerName].callback; callback(); - + stopHeartbeat(timerName); delete timers[timerName]; } else { diff --git a/src/application/client/scripts/sw/sw.utils.js b/src/application/client/scripts/sw/sw.utils.js index 4433eda5..54f9fc35 100644 --- a/src/application/client/scripts/sw/sw.utils.js +++ b/src/application/client/scripts/sw/sw.utils.js @@ -37,7 +37,7 @@ export async function sendMessage (meta, payload) { const message = meta ? { meta, payload } : payload; debug(`sendMessage (${freshClients.length})`, message); - + for (let i = 0; i < freshClients.length; i++) { freshClients[i].postMessage(message); } From fcc35b493448a801ae47a976b7c8b703ad61108c Mon Sep 17 00:00:00 2001 From: localnerve Date: Mon, 23 Feb 2026 20:13:53 -0500 Subject: [PATCH 2/5] @2.10.0 - style updates --- src/test/login.utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/login.utils.js b/src/test/login.utils.js index 05616c92..99b8bb0f 100644 --- a/src/test/login.utils.js +++ b/src/test/login.utils.js @@ -177,7 +177,7 @@ export async function manualLogin (baseUrl, page, redirect = true) { debug(`authzUrlTest call ${callCount++}`, url.origin, '===', process.env.AUTHZ_URL); return url.origin === process.env.AUTHZ_URL; }; - + await page.waitForURL(authzUrlTest, { timeout: serviceTimeout, waitUntil From e2d03f9603ece43ba7a68e363050d23e75b90ca7 Mon Sep 17 00:00:00 2001 From: localnerve Date: Mon, 23 Feb 2026 20:23:56 -0500 Subject: [PATCH 3/5] @2.10.0 - style updates --- eslint.config.js | 7 ++++++- src/application/client/scripts/sw/sw.data.js | 4 ++-- src/application/client/scripts/sw/sw.data.source.js | 6 +++--- src/test/fixture.test.js | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 5ba1d212..b0cf4c99 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -27,12 +27,17 @@ const commonRules = { ...js.configs.recommended.rules, 'no-console': 'warn', 'no-param-reassign': 'error', + 'space-before-function-paren': ['error', { + 'anonymous': 'always', + 'named': 'always', + 'asyncArrow': 'always' + }], indent: [2, 2, { SwitchCase: 1, MemberExpression: 1 }], quotes: [2, 'single'], - 'dot-notation': [2, {allowKeywords: true}], + 'dot-notation': [2, { allowKeywords: true }], 'linebreak-style': [ 'error', 'unix' diff --git a/src/application/client/scripts/sw/sw.data.js b/src/application/client/scripts/sw/sw.data.js index 332b3799..c6a520e0 100644 --- a/src/application/client/scripts/sw/sw.data.js +++ b/src/application/client/scripts/sw/sw.data.js @@ -364,7 +364,7 @@ export async function refreshData ({ storeType, document, collections }) { const baseUrl = `/api/data/${resource}`; const path = document ? `/${document}${typeof collections === 'string' ? `/${collections}` : collections?.length === 1 ? `/${collections[0]}` : '' - }` : ''; + }` : ''; let url = `${baseUrl}${path}`; if (document && collections?.length > 1) { @@ -692,7 +692,7 @@ async function processBatchUpdates () { properties = (new Map()).set(item.collection, item.propertyName ? [item.propertyName] : []); properties.hasProps = item.propertyName ? true : false; } else { // this is a document delete - properties = { set(){}, get(){}, hasProps: false }; + properties = { set () { }, get () { }, hasProps: false }; } } networkCallOrder.unshift(item.op); // add to head so newest will be last diff --git a/src/application/client/scripts/sw/sw.data.source.js b/src/application/client/scripts/sw/sw.data.source.js index 0c47f6fb..572debfb 100644 --- a/src/application/client/scripts/sw/sw.data.source.js +++ b/src/application/client/scripts/sw/sw.data.source.js @@ -65,7 +65,7 @@ export async function getDB () { export async function installDatabase () { /* eslint-disable no-unused-vars */ db = await openDB(dbname, schemaVersion, { - upgrade(db, oldVersion, newVersion, transaction, event) { + upgrade (db, oldVersion, newVersion, transaction, event) { // // MAIN STORES (app, user) @@ -207,10 +207,10 @@ export async function installDatabase () { } } }, - blocked(currentVersion, blockedVersion, event) { + blocked (currentVersion, blockedVersion, event) { blocked = true; }, - async blocking(currentVersion, blockedVersion, event) { + async blocking (currentVersion, blockedVersion, event) { db.close(); await sendMessage('database-update-required'); } diff --git a/src/test/fixture.test.js b/src/test/fixture.test.js index 23e2d563..32df80f0 100644 --- a/src/test/fixture.test.js +++ b/src/test/fixture.test.js @@ -39,7 +39,7 @@ test.describe('Fixture check', () => { debug('User request state', userState); }); - test('Audit Page fixtures', async({ adminPage, userPage, page }) => { + test('Audit Page fixtures', async ({ adminPage, userPage, page }) => { const adminState = await adminPage.context().storageState(); const userState = await userPage.context().storageState(); const publicState = await page.context().storageState(); From 33005f6d30cb0cd921d14b30715ce13bb67fba77 Mon Sep 17 00:00:00 2001 From: localnerve Date: Tue, 24 Feb 2026 11:10:31 -0500 Subject: [PATCH 4/5] @2.10.0 - prep for changes --- .../client/scripts/sw/sw.data.constants.js | 3 + src/test/data.utils.js | 181 ++++++++++++++++++ src/test/data/page.mutation.test.js | 134 +------------ src/test/login.utils.js | 2 +- 4 files changed, 192 insertions(+), 128 deletions(-) create mode 100644 src/test/data.utils.js diff --git a/src/application/client/scripts/sw/sw.data.constants.js b/src/application/client/scripts/sw/sw.data.constants.js index 3a580e37..d22573aa 100644 --- a/src/application/client/scripts/sw/sw.data.constants.js +++ b/src/application/client/scripts/sw/sw.data.constants.js @@ -40,4 +40,7 @@ export const offlineRetentionTime = 30; // 30 minutes, session time export const queueName = `${dbname}-requests-${apiVersion}`; export const STALE_BASE_LIFESPAN = 60000; // 1 minute, baseStoreType documents older than this are considered expired export const batchCollectionWindow = process?.env?.NODE_ENV !== 'production' ? 12000 : 12000; // eslint-disable-line -- assigned at bundle time +export const conflictBackoffBase = 100; // base delay in ms +export const conflictBackoffMax = 5000; // max delay cap in ms +export const conflictMaxRetries = 5; // max retry attempts before giving up export const mainStoreTypes = ['app', 'user']; \ No newline at end of file diff --git a/src/test/data.utils.js b/src/test/data.utils.js new file mode 100644 index 00000000..7c8bbc92 --- /dev/null +++ b/src/test/data.utils.js @@ -0,0 +1,181 @@ +/** + * Page data mutation utility functions. + * + * Jam-build, a web application practical reference. + * Copyright (c) 2025 Alex Grant (https://www.localnerve.com), LocalNerve LLC + * + * This file is part of Jam-build. + * Jam-build is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later version. + * Jam-build is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * You should have received a copy of the GNU Affero General Public License along with Jam-build. + * If not, see . + * Additional terms under GNU AGPL version 3 section 7: + * a) The reasonable legal notice of original copyright and author attribution must be preserved + * by including the string: "Copyright (c) 2025 Alex Grant (https://www.localnerve.com), LocalNerve LLC" + * in this material, copies, or source code of derived works. + */ +import { expect } from '#test/fixtures.js'; + +export const slowTimeoutAddition = 20000; + +/** + * Do mutations on a refernce to an editable-object control. + * Assumes input data presets from testdata.js + * + * By default: + * Update property1, property2 + * Delete property3, property4 + * Create property5 + * + * @param {EditableObjectControl} control - The editable-object control to operate on + * @param {Object} [mutations] - The creates, updates, and deletes to do + * @param {Array} [mutations.doCreates] - Array of [name, value] pairs to create + * @param {Array} [mutations.doUpdates] - Array of property names to update (values are always increment the lastchar) + * @param {Array} [mutations.doDeletes] - Array of property names to delete + * @param {Number} [mutations.deletePosition] - The position in the property array to start consecutive delets from + * @returns {Object} of updateProps, createProps, and deleteProps + */ +export async function doMutations (control, { + doCreates = [ ['property5', 'value55'] ], + doUpdates = ['property1', 'property2'], + doDeletes = ['property3', 'property4'], + deletePosition = 2 +} = {}) { + /** + * Updates + */ + let lastProp; + const updateProps = doUpdates.reduce((acc, cur) => { + acc[cur] = null; + return acc; + }, {}); + for (const propName of Object.keys(updateProps)) { + lastProp = control.getByLabel(propName); + + const value = await lastProp.inputValue(); + const newValue = `${value}${value.charAt(value.length - 1)}`; + updateProps[propName] = newValue; + + await lastProp.dblclick(); // set to edit mode + await lastProp.fill(newValue); + await lastProp.press('Enter'); + } + + // assist any visual debugging + await lastProp.scrollIntoViewIfNeeded(); + + // mutationQueue 67ms, plus + await new Promise(res => setTimeout(res, 167)); // increase this to visually debug + + /** + * Delete props + */ + const deleteProps = doDeletes; + for (const propName of deleteProps) { + const prop = control.getByLabel(propName); + // * not sure why I have to click before this, but I do. probably visibility in the control... + await prop.click(); + + const propLI = (await control.getByRole('listitem').all())[deletePosition]; + await propLI.getByTitle('Remove').click(); + } + + // mutationQueue 67ms, plus + await new Promise(res => setTimeout(res, 167)); // increase this to visually debug + + /** + * Create props + */ + const createProps = doCreates.reduce((acc, [name, value]) => { + acc[name] = value; + return acc; + }, {}); + for (const [newPropName, newPropValue] of Object.entries(createProps)) { + const newProp = control.getByLabel('New Property and Value'); + await newProp.fill(`${newPropName}:${newPropValue}`); + await newProp.press('Enter'); + } + + // mutationQueue 67ms, plus + await new Promise(res => setTimeout(res, 167)); // increase this to visually debug + + return { + updateProps, + createProps, + deleteProps + }; +} + +/** + * Quick test to see if a stale data message exists. + */ +export async function testMessageExists (page, expectMessageExists = false) { + // Check for an app message. + const message = page.locator('.pp-message'); + if (!expectMessageExists) { + await expect(message).toBeHidden(); + } else { + await expect(message).toBeVisible(); + } +} + +/** + * Verify the mutations from doMutations were successful at this moment. + */ +export async function testMutations (page, control, mutations, messageExists = false) { + await testMessageExists(page, messageExists); + + // Test updates + for (const [propName, propValue] of Object.entries(mutations.updateProps)) { + await expect(control.getByLabel(propName)).toHaveValue(propValue); + } + + // Test creates + for (const [propName, propValue] of Object.entries(mutations.createProps)) { + await expect(control.getByLabel(propName)).toHaveValue(propValue); + } + + // Test deletes + for (const propName of mutations.deleteProps) { + await expect(control.locator(`input[name="${propName}"]`)).toHaveCount(0); + } +} + +/** + * Force a batch terminus by navigating away and back. + * + * @param {Page} page - The page to navigate + * @param {String} away - aria-label label of the away anchor + * @param {Number} clickWait - The wait time after navigation clicks + */ +export async function forceBatchTerminusNav (page, away, clickWait) { + const activeLocator = page.locator('a[class="active"]'); + const activeAnchor = await activeLocator.nth(1); + const activeLabel = await activeAnchor.getAttribute('aria-label'); + + const awayLocator = page.locator(`a[aria-label="${away}"]`); + const awayAnchor = await awayLocator.nth(1); + const awayUrl = `${baseUrl}/${away.toLowerCase()}`; + + await awayAnchor.click(); + await page.waitForURL(awayUrl, { + timeout: 5000 + }); + await expect(page).toHaveURL(awayUrl); + + await new Promise(res => setTimeout(res, clickWait)); + + const backLocator = page.locator(`a[aria-label="${activeLabel}"]`); + const backAnchor = await backLocator.nth(1); + const backUrl = activeLabel == 'Home' ? baseUrl : `${baseUrl}/${activeLabel.toLowerCase()}`; + + await backAnchor.click(); + await page.waitForURL(backUrl, { + timeout: 5000 + }); + await expect(page).toHaveURL(backUrl); +} \ No newline at end of file diff --git a/src/test/data/page.mutation.test.js b/src/test/data/page.mutation.test.js index 53942990..0a7304f9 100644 --- a/src/test/data/page.mutation.test.js +++ b/src/test/data/page.mutation.test.js @@ -32,137 +32,18 @@ import { deleteTestDataUser } from '#test/testdata.js'; import { startJS, stopJS, createMap, createReport } from '#test/coverage.js'; +import { + doMutations, + testMessageExists, + testMutations, + slowTimeoutAddition +} from '#test/data.utils.js'; test.describe('mutation tests', () => { let baseUrl; let map; let needLogout; - const slowTimeoutAddition = 20000; - - /** - * Do mutations on a refernce to an editable-object control. - * Assumes input data presets from testdata.js - * - * By default: - * Update property1, property2 - * Delete property3, property4 - * Create property5 - * - * @param {EditableObjectControl} control - The editable-object control to operate on - * @param {Object} [mutations] - The creates, updates, and deletes to do - * @param {Array} [mutations.doCreates] - Array of [name, value] pairs to create - * @param {Array} [mutations.doUpdates] - Array of property names to update (values are always increment the lastchar) - * @param {Array} [mutations.doDeletes] - Array of property names to delete - * @param {Number} [mutations.deletePosition] - The position in the property array to start consecutive delets from - * @returns {Object} of updateProps, createProps, and deleteProps - */ - async function doMutations (control, { - doCreates = [ ['property5', 'value55'] ], - doUpdates = ['property1', 'property2'], - doDeletes = ['property3', 'property4'], - deletePosition = 2 - } = {}) { - /** - * Updates - */ - let lastProp; - const updateProps = doUpdates.reduce((acc, cur) => { - acc[cur] = null; - return acc; - }, {}); - for (const propName of Object.keys(updateProps)) { - lastProp = control.getByLabel(propName); - - const value = await lastProp.inputValue(); - const newValue = `${value}${value.charAt(value.length - 1)}`; - updateProps[propName] = newValue; - - await lastProp.dblclick(); // set to edit mode - await lastProp.fill(newValue); - await lastProp.press('Enter'); - } - - // assist any visual debugging - await lastProp.scrollIntoViewIfNeeded(); - - // mutationQueue 67ms, plus - await new Promise(res => setTimeout(res, 167)); // increase this to visually debug - - /** - * Delete props - */ - const deleteProps = doDeletes; - for (const propName of deleteProps) { - const prop = control.getByLabel(propName); - // * not sure why I have to click before this, but I do. probably visibility in the control... - await prop.click(); - - const propLI = (await control.getByRole('listitem').all())[deletePosition]; - await propLI.getByTitle('Remove').click(); - } - - // mutationQueue 67ms, plus - await new Promise(res => setTimeout(res, 167)); // increase this to visually debug - - /** - * Create props - */ - const createProps = doCreates.reduce((acc, [name, value]) => { - acc[name] = value; - return acc; - }, {}); - for (const [newPropName, newPropValue] of Object.entries(createProps)) { - const newProp = control.getByLabel('New Property and Value'); - await newProp.fill(`${newPropName}:${newPropValue}`); - await newProp.press('Enter'); - } - - // mutationQueue 67ms, plus - await new Promise(res => setTimeout(res, 167)); // increase this to visually debug - - return { - updateProps, - createProps, - deleteProps - }; - } - - /** - * Quick test to see if a stale data message exists. - */ - async function testMessageExists (page, expectMessageExists = false) { - // Check for an app message. - const message = page.locator('.pp-message'); - if (!expectMessageExists) { - await expect(message).toBeHidden(); - } else { - await expect(message).toBeVisible(); - } - } - - /** - * Verify the mutations from doMutations were successful at this moment. - */ - async function testMutations (page, control, mutations, messageExists = false) { - await testMessageExists(page, messageExists); - - // Test updates - for (const [propName, propValue] of Object.entries(mutations.updateProps)) { - await expect(control.getByLabel(propName)).toHaveValue(propValue); - } - - // Test creates - for (const [propName, propValue] of Object.entries(mutations.createProps)) { - await expect(control.getByLabel(propName)).toHaveValue(propValue); - } - - // Test deletes - for (const propName of mutations.deleteProps) { - await expect(control.locator(`input[name="${propName}"]`)).toHaveCount(0); - } - } - test.beforeAll(() => { baseUrl = process.env.BASE_URL; map = createMap(); @@ -416,8 +297,7 @@ test.describe('mutation tests', () => { test('simple version conflict', async ({ browserName, browser }, testInfo) => { test.setTimeout(testInfo.timeout + slowTimeoutAddition); - const notChrome = browser.browserType().name() !== 'chromium'; - const clickWait = process.env.CI || notChrome ? 400 : 300; // eslint-disable-line playwright/no-conditional-in-test + const clickWait = 400; const context1 = await browser.newContext(); const page1 = await context1.newPage(); diff --git a/src/test/login.utils.js b/src/test/login.utils.js index 99b8bb0f..40a4099c 100644 --- a/src/test/login.utils.js +++ b/src/test/login.utils.js @@ -147,7 +147,7 @@ export async function manualLogin (baseUrl, page, redirect = true) { await startPage(baseUrl, page); const notChrome = page.context().browser().browserType().name() !== 'chromium'; - const loginClickWait = process.env.CI && notChrome ? 1000 : 100; + const loginClickWait = process.env.CI && notChrome ? 1000 : 200; const waitUntil = 'domcontentloaded'; debug('AUTHZ_URL', process.env.AUTHZ_URL); From dfc5ba29dc9a93564384d9d4e8692c53493e3362 Mon Sep 17 00:00:00 2001 From: localnerve Date: Fri, 27 Feb 2026 22:47:23 -0500 Subject: [PATCH 5/5] @2.10.0 - exponential backoff to resolve conflict thrashing --- package-lock.json | 297 +------------ package.json | 9 +- playwright.config.js | 8 +- .../client/scripts/sw/sw.data.conflicts.js | 165 ++++++- .../client/scripts/sw/sw.data.constants.js | 7 +- .../client/scripts/sw/sw.data.helpers.js | 15 +- src/application/client/scripts/sw/sw.data.js | 2 + src/application/client/scripts/sw/sw.timer.js | 3 +- src/test/data.utils.js | 5 +- src/test/data/page.mutation.conflict.test.js | 412 ++++++++++++++++++ 10 files changed, 612 insertions(+), 311 deletions(-) create mode 100644 src/test/data/page.mutation.conflict.test.js diff --git a/package-lock.json b/package-lock.json index 0e7e6011..1603a46d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jam-build", - "version": "2.9.3", + "version": "2.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jam-build", - "version": "2.9.3", + "version": "2.10.0", "license": "AGPL-3.0-or-later", "dependencies": { "@localnerve/authorizer-js": "^1.0.5", @@ -4809,39 +4809,6 @@ "@opentelemetry/semantic-conventions": "^1.34.0" } }, - "node_modules/@sentry/node/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sentry/node/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@sentry/node/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@sentry/opentelemetry": { "version": "9.47.1", "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-9.47.1.tgz", @@ -4983,13 +4950,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/minimatch": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", - "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/mysql": { "version": "2.15.26", "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz", @@ -5213,9 +5173,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -5473,16 +5433,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/arrify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", - "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -6615,13 +6565,6 @@ "node": ">= 0.8.0" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/configstore": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz", @@ -7394,77 +7337,6 @@ "node": ">=16" } }, - "node_modules/doiuse/node_modules/array-differ": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-3.0.0.tgz", - "integrity": "sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/doiuse/node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/doiuse/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/doiuse/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/doiuse/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/doiuse/node_modules/multimatch": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-5.0.0.tgz", - "integrity": "sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/minimatch": "^3.0.3", - "array-differ": "^3.0.0", - "array-union": "^2.1.0", - "arrify": "^2.0.1", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/doiuse/node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -8516,43 +8388,16 @@ } }, "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.5.tgz", + "integrity": "sha512-ct/ckWBV/9Dg3MlvCXsLcSUyoWwv9mCKqlhLNB2DAuXR/NZolSXlQqP5dyy6guWlPXBhodZyZ5lGPQcbQDxrEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "minimatch": "^5.0.1" - } - }, - "node_modules/filelist/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" + "minimatch": "^10.2.1" }, "engines": { - "node": ">=10" + "node": "20 || >=22" } }, "node_modules/fill-range": { @@ -11481,16 +11326,16 @@ } }, "node_modules/minimatch": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz", - "integrity": "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -11599,56 +11444,23 @@ "license": "MIT" }, "node_modules/multimatch": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-7.0.0.tgz", - "integrity": "sha512-SYU3HBAdF4psHEL/+jXDKHO95/m5P2RvboHT2Y0WtTttvJLP4H/2WS9WlQPFvF6C8d6SpLw8vjCnQOnVIVOSJQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-8.0.0.tgz", + "integrity": "sha512-0D10M2/MnEyvoog7tmozlpSqL3HEU1evxUFa3v1dsKYmBDFSP1dLSX4CH2rNjpQ+4Fps8GKmUkCwiKryaKqd9A==", "dev": true, "license": "MIT", "dependencies": { "array-differ": "^4.0.0", "array-union": "^3.0.1", - "minimatch": "^9.0.3" + "minimatch": "^10.2.2" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/multimatch/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/multimatch/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/multimatch/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mute-stdout": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stdout/-/mute-stdout-2.0.0.tgz", @@ -13137,36 +12949,6 @@ "minimatch": "^5.1.0" } }, - "node_modules/readdir-glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -15482,53 +15264,20 @@ "license": "MIT" }, "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", "dev": true, "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", - "minimatch": "^9.0.4" + "minimatch": "^10.2.2" }, "engines": { "node": ">=18" } }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/testcontainers": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-11.12.0.tgz", diff --git a/package.json b/package.json index 5a3e5676..e6a11cf2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jam-build", - "version": "2.9.3", + "version": "2.10.0", "description": "An adventurous, scalable, fullstack web application reference project", "main": "index.js", "type": "module", @@ -45,6 +45,7 @@ "test:local:api:debug": "npm run test:env:local -- DEBUG=api*,test* playwright test --project=api --max-failures=1 --workers=1 --timeout=600000 --headed", "test:local:data": "npm run test:env:local -- playwright test --project=data --max-failures=1", "test:local:data:debug": "npm run test:env:local -- DEBUG=api*,test* playwright test --project=data-debug --max-failures=1 --workers=1 --timeout=600000 --headed", + "test:local:data:conflict:debug": "npm run test:env:local -- DEBUG=api*,test* playwright test --project=data-conflict-debug --max-failures=1 --workers=1 --timeout=600000 --headed", "test:local:debug": "npm run test:env:local -- DEBUG=api*,test* playwright test --project=Chromium --max-failures=1 --workers=1 --timeout=600000 --headed", "test:local:ff": "npm run test:env:local -- playwright test --project=Firefox", "test:local:ff:debug": "npm run test:env:local -- playwright test --project=Firefox --max-failures=1 --workers=1 --timeout=600000 --headed", @@ -171,9 +172,11 @@ }, "overrides": { "sourcemap-codec": "npm:@jridgewell/sourcemap-codec", - "glob": "^13.0.5" + "glob": "^13.0.5", + "multimatch": "^8.0.0", + "minimatch": "^10.2.2" }, "engines": { "node": ">=24.8 <25" } -} +} \ No newline at end of file diff --git a/playwright.config.js b/playwright.config.js index cc013f1f..3fd65eba 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -27,11 +27,11 @@ const desktopViewport = { const puppeteerOptions = process.env.CI ? { args: ['--no-sandbox', '--disable-setuid-sandbox'] -}: {}; +} : {}; const workerRestriction = process.env.CI ? { workers: 1 -}: {}; +} : {}; const slowMo = parseInt((process.env.SLOWMO || '0').toString(), 10); @@ -153,6 +153,10 @@ export default defineConfig({ name: 'data-debug', testMatch: 'data/page.mutation.test.js', workers: 1 + }, { + name: 'data-conflict-debug', + testMatch: 'data/page.mutation.conflict.test.js', + workers: 1 }, { name: 'Chromium', use: { diff --git a/src/application/client/scripts/sw/sw.data.conflicts.js b/src/application/client/scripts/sw/sw.data.conflicts.js index 74f0e549..a9694154 100644 --- a/src/application/client/scripts/sw/sw.data.conflicts.js +++ b/src/application/client/scripts/sw/sw.data.conflicts.js @@ -23,11 +23,16 @@ import { hasOwnProperty, isNullish, isObj } from '#client-utils/javascript.js'; import { getStoreTypeScope } from '#client-utils/storeType.js'; import { baseStoreType, + conflictBackoffBase, + conflictBackoffMax, + conflictMaxRetries, conflictStoreType, + nominalTimerInterval, dbname, versionStoreType } from './sw.data.constants.js'; import { debug, sendMessage } from './sw.utils.js'; +import { startTimer } from './sw.timer.js'; import { getDB, makeStoreName } from './sw.data.source.js'; const jsonDiffPatch = jsonDiffPatchLib.create({ omitRemovedValues: true }); @@ -103,7 +108,7 @@ function threeWayMerge (base, remote, local) { } return newValue; }; - + // Handle diffs, always choose local over remote for (const path of Object.keys(diffs)) { const { remote, local, type } = diffs[path]; @@ -129,6 +134,59 @@ function threeWayMerge (base, remote, local) { return mergedObject; } +/** + * Compute a timer resolution that targets a median value. + * Never give a resolution below a minimum floor value. + * Favor overshooting over undershooting. + */ +function computeTimerResolution (delay) { + const target = nominalTimerInterval; + const floor = Math.ceil(nominalTimerInterval * 0.6); + + if (delay <= floor) return floor; + + // Use Math.ceil to ensure we don't under-count intervals + // Example: 1300 / 500 = 2.6 -> 3 intervals + const intervalCount = Math.ceil(delay / target); + + // Use Math.ceil again to ensure the interval duration + // slightly overshoots the target delay rather than undershooting. + // Example: 1300 / 3 = 433.33 -> 434ms + const resolution = Math.ceil(delay / intervalCount); + + // Ensure we respect the CPU floor + return Math.max(resolution, floor); +} + +/** + * Complete processVersionConflicts by restarting the batch loop, update the UI on success. + * The loop restart contained here is done on a timer during conflict thrashing. + * + * @param {String} instanceId - The instanceId of the processVersionConflicts this belongs to + * @param {Object} message - The message notification data from the merged newData objects written to the local object stores + * @param {Function} processBatchUpdates - The processBatchUpdates function + */ +async function completeProcessVersionConflicts (instanceId, message, processBatchUpdates) { + const result = await processBatchUpdates(); + + if (result === 0) { // complete success + // Notify the app the data was updated + for (const [, payload] of Object.entries(message)) { + await sendMessage('database-data-update', { + ...payload, + message: { + text: 'The data was synchronized with the latest version', + class: 'info' + } + }); + } + + debug('processVersionConflicts sent client messages', message); + } + + debug(`[${instanceId}] Ending processVersionConflicts...`); +} + /** * Read the versionConflict objectStore and process the version conflicts: * @@ -150,7 +208,12 @@ export async function processVersionConflicts ({ processBatchUpdates, addToBatch }) { + const instanceId = Math.random().toString(36).substring(7); + debug(`[${instanceId}] Starting processVersionConflicts...`); + const conflictStoreName = makeStoreName(conflictStoreType); + const versionStoreName = makeStoreName(versionStoreType); + const db = await getDB(); const totalRecords = await db.count(conflictStoreName); @@ -161,8 +224,61 @@ export async function processVersionConflicts ({ debug(`processVersionConflicts processing ${totalRecords} records from ${conflictStoreName}...`); - const conflictDocs = await db.transaction(conflictStoreName).store.index('version'); + // Read retryCount from version_documents for all involved docs to find the max + let maxRetryCount = 0; + const allConflictValues = await db.getAll(conflictStoreName); + const conflictKeys = allConflictValues.map(val => [val.storeType, val.document_name]); + const versionRecords = await Promise.all( + conflictKeys.map(key => db.get(versionStoreName, key)) + ); + for (const versionRecord of versionRecords) { + if (versionRecord?.retryCount > maxRetryCount) { + maxRetryCount = versionRecord.retryCount; + } + } + + // Check max retries BEFORE doing any merge work to avoid orphans and unnecessary CPU + if (maxRetryCount >= conflictMaxRetries) { + debug(`processVersionConflicts max retries (${conflictMaxRetries}) exceeded`); + + const uniqueStoreTypes = new Set(); + const unqiueConflictValues = []; + + // Delete all the conflict records (cleanup) and collect uniqueStoreTypes + for (const val of allConflictValues) { + if (!uniqueStoreTypes.has(val.storeType)) { + uniqueStoreTypes.add(val.storeType); + unqiueConflictValues.push(val); + } + await db.delete(conflictStoreName, [val.storeType, val.document_name, val.collection_name]); + } + debug(`deleted ${allConflictValues.length} conflict records (max retries exceeded)`); + + // Notify for each distinct storeType conflict, only show message on last one + const lastIndex = unqiueConflictValues.length - 1; + let message = false; + for (let i = 0; i < unqiueConflictValues.length; i++) { + const val = unqiueConflictValues[i]; + if (i == lastIndex) { + message = { + text: 'A data conflict could not be resolved automatically. You are viewing local data.', + class: 'error' + }; + } + await sendMessage('database-data-update', { + dbname, + storeName: makeStoreName(val.storeType), + storeType: val.storeType, + scope: getStoreTypeScope(val.storeType), + keys: [[val.document_name, val.collection_name]], // representative key + message + }); + } + return; // fail + } + + const conflictDocs = await db.transaction(conflictStoreName).store.index('version'); debug('conflictDocs version index records: ', await conflictDocs.count()); // Read all conflict doc records, latest version first @@ -183,7 +299,7 @@ export async function processVersionConflicts ({ } = cursor.value; // eslint-disable-next-line compat/compat - const version = typeof BigInt(42) === 'bigint' ? BigInt(new_version) : +new_version; + const version = typeof BigInt(42) === 'bigint' ? BigInt(new_version) : +new_version; // Build base properties if they don't exist versions[storeType] = versions[storeType] ?? {}; @@ -216,10 +332,10 @@ export async function processVersionConflicts ({ for (const storeType of Object.keys(remoteData)) { const storeName = makeStoreName(storeType); const scope = getStoreTypeScope(storeType); - + for (const doc of Object.keys(remoteData[storeType])) { const records = await db.getAllFromIndex(storeName, 'document', [scope, doc]); - + for (const rec of records) { localData[storeType] = localData[storeType] ?? {}; localData[storeType][rec.document_name] = @@ -305,15 +421,15 @@ export async function processVersionConflicts ({ debug('processVersionConflicts wrote new records contained in the keys: ', message); - // Update the version objectStore with the new versions for the docs - const versionStoreName = makeStoreName(versionStoreType); + // Update the version objectStore with the new versions and incremented retryCount for (const storeType of Object.keys(versions)) { for (const [document, version] of Object.entries(versions[storeType])) { if (document && version > 0) { await db.put(versionStoreName, { storeType, document, - version: `${version}` + version: `${version}`, + retryCount: maxRetryCount + 1 }); } } @@ -378,18 +494,25 @@ export async function processVersionConflicts ({ debug(`deleted ${conflictDeletes} conflict records before re-processing`); - await processBatchUpdates(); - - // Notify the app the data was updated - for (const [, payload] of Object.entries(message)) { - await sendMessage('database-data-update', { - ...payload, - message: { - text: 'The data was synchronized with the latest version', - class: 'info' - } - }); + if (maxRetryCount > 0) { + // Exponential backoff with jitter + const backoffDelay = Math.min( + conflictBackoffBase * Math.pow(2, maxRetryCount), + conflictBackoffMax + ); + const jitter = Math.random() * backoffDelay; + const delay = backoffDelay + jitter; + + debug(`processVersionConflicts backoff delay: ${delay.toFixed(0)}ms (retry attempt ${maxRetryCount})`); + + const resolution = computeTimerResolution(delay); + startTimer( + delay, + 'backoff-batch-timer', + completeProcessVersionConflicts.bind(null, instanceId, message, processBatchUpdates), + resolution + ); + } else { + await completeProcessVersionConflicts(instanceId, message, processBatchUpdates); } - - debug('processVersionConflicts sent client messages', message); } \ No newline at end of file diff --git a/src/application/client/scripts/sw/sw.data.constants.js b/src/application/client/scripts/sw/sw.data.constants.js index d22573aa..57235d20 100644 --- a/src/application/client/scripts/sw/sw.data.constants.js +++ b/src/application/client/scripts/sw/sw.data.constants.js @@ -34,13 +34,14 @@ export const conflictStoreType = 'conflict'; export const dbname = 'jam_build'; export const baseStoreType = 'base'; export const fetchTimeout = 4500; +export const nominalTimerInterval = 500; export const E_REPLAY = 0x062de3cc; export const E_CONFLICT = 0x32c79766; export const offlineRetentionTime = 30; // 30 minutes, session time export const queueName = `${dbname}-requests-${apiVersion}`; export const STALE_BASE_LIFESPAN = 60000; // 1 minute, baseStoreType documents older than this are considered expired export const batchCollectionWindow = process?.env?.NODE_ENV !== 'production' ? 12000 : 12000; // eslint-disable-line -- assigned at bundle time -export const conflictBackoffBase = 100; // base delay in ms -export const conflictBackoffMax = 5000; // max delay cap in ms -export const conflictMaxRetries = 5; // max retry attempts before giving up +export const conflictBackoffBase = 100; // base delay in ms +export const conflictBackoffMax = 8000; // max delay cap in ms, [2^(conflictMaxRetries-1)]*conflictBackoffBase + jitterMs +export const conflictMaxRetries = 7; // max retry attempts before giving up, [2^(conflictMaxRetries-1)]*conflictBackoffBase is the base delay export const mainStoreTypes = ['app', 'user']; \ No newline at end of file diff --git a/src/application/client/scripts/sw/sw.data.helpers.js b/src/application/client/scripts/sw/sw.data.helpers.js index 89e24edb..a896d670 100644 --- a/src/application/client/scripts/sw/sw.data.helpers.js +++ b/src/application/client/scripts/sw/sw.data.helpers.js @@ -99,7 +99,8 @@ async function storeMutationResult (storeType, document, result) { await db.put(versionStoreName, { storeType, document, - version: result.newVersion + version: result.newVersion, + retryCount: 0 }); } @@ -160,7 +161,8 @@ export async function storeData (storeType, data) { await db.put(versionStoreName, { storeType, document: doc_name, - version: doc.__version + version: doc.__version, + retryCount: 0 }); delete doc.__version; @@ -242,7 +244,8 @@ export async function loadData (storeType, document, collections = null) { await db.put(versionStoreName, { storeType, document, - version: record.version + version: record.version, + retryCount: 0 }); } result.version = record.version; @@ -329,9 +332,9 @@ export async function clearBaseStoreRecords (storeType, document, collection = ' const baseRecords = await db.transaction(baseStoreName, 'readwrite').store.index('collection'); for await (const cursor of baseRecords.iterate([storeType, document, collection])) { const item = cursor.value; - + item.reference -= 1; - + if (item.reference <= 0) { deleteCount++; await cursor.delete(); @@ -360,7 +363,7 @@ async function _mayUpdate ({ storeType, document, op, collection }, clearOnly = if (!storeType || !document) { throw new Error('Bad input passed to mayUpdate'); } - + /* eslint-disable no-param-reassign */ collection = collection ?? ''; /* eslint-enable no-param-reassign */ diff --git a/src/application/client/scripts/sw/sw.data.js b/src/application/client/scripts/sw/sw.data.js index c6a520e0..4aeb7cb9 100644 --- a/src/application/client/scripts/sw/sw.data.js +++ b/src/application/client/scripts/sw/sw.data.js @@ -794,6 +794,8 @@ async function processBatchUpdates () { logoutData(storeType, !storeTypeReplay.get(storeType), !next); } } + + return networkResult; } /** diff --git a/src/application/client/scripts/sw/sw.timer.js b/src/application/client/scripts/sw/sw.timer.js index 8087a4c4..911c3b4f 100644 --- a/src/application/client/scripts/sw/sw.timer.js +++ b/src/application/client/scripts/sw/sw.timer.js @@ -20,6 +20,7 @@ * by including the string: "Copyright (c) 2025 Alex Grant (https://www.localnerve.com), LocalNerve LLC" * in this material, copies, or source code of derived works. */ +import { nominalTimerInterval } from './sw.data.constants.js'; import { debug, sendMessage } from './sw.utils.js'; const timers = Object.create(null); @@ -170,7 +171,7 @@ export function serviceAllTimers () { * @param {Function} callback - The callback, * @param {Number} [resolution] - The timer resolution, defaults to 500 ms */ -export function startTimer (duration, timerName, callback, resolution = 500) { +export function startTimer (duration, timerName, callback, resolution = nominalTimerInterval) { if (timers[timerName]) { clearInterval(timers[timerName].intervalId); } else { diff --git a/src/test/data.utils.js b/src/test/data.utils.js index 7c8bbc92..1b8f0b86 100644 --- a/src/test/data.utils.js +++ b/src/test/data.utils.js @@ -150,9 +150,10 @@ export async function testMutations (page, control, mutations, messageExists = f * * @param {Page} page - The page to navigate * @param {String} away - aria-label label of the away anchor + * @param {String} baseUrl - The baseUrl * @param {Number} clickWait - The wait time after navigation clicks */ -export async function forceBatchTerminusNav (page, away, clickWait) { +export async function forceBatchTerminusNav (page, away, baseUrl, clickWait) { const activeLocator = page.locator('a[class="active"]'); const activeAnchor = await activeLocator.nth(1); const activeLabel = await activeAnchor.getAttribute('aria-label'); @@ -178,4 +179,6 @@ export async function forceBatchTerminusNav (page, away, clickWait) { timeout: 5000 }); await expect(page).toHaveURL(backUrl); + + await new Promise(res => setTimeout(res, clickWait)); } \ No newline at end of file diff --git a/src/test/data/page.mutation.conflict.test.js b/src/test/data/page.mutation.conflict.test.js new file mode 100644 index 00000000..1c581b62 --- /dev/null +++ b/src/test/data/page.mutation.conflict.test.js @@ -0,0 +1,412 @@ +/** + * Page data mutation conflict tests. + * Multi-page tests proving exponential backoff with jitter + * resolves the batch-loop-conflict problem. + * + * Jam-build, a web application practical reference. + * Copyright (c) 2025 Alex Grant (https://www.localnerve.com), LocalNerve LLC + * + * This file is part of Jam-build. + * Jam-build is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later version. + * Jam-build is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * You should have received a copy of the GNU Affero General Public License along with Jam-build. + * If not, see . + * Additional terms under GNU AGPL version 3 section 7: + * a) The reasonable legal notice of original copyright and author attribution must be preserved + * by including the string: "Copyright (c) 2025 Alex Grant (https://www.localnerve.com), LocalNerve LLC" + * in this material, copies, or source code of derived works. + */ +import { test, expect } from '#test/fixtures.js'; +import { + manualLogin, + manualLogout, + serviceTimeout +} from '#test/login.utils.js'; +import { + createTestDataApp, + createTestDataUser, + deleteTestDataApp, + deleteTestDataUser +} from '#test/testdata.js'; +import { startJS, stopJS, createMap, createReport } from '#test/coverage.js'; +import { + doMutations, + testMutations, + slowTimeoutAddition, + forceBatchTerminusNav +} from '#test/data.utils.js'; + +test.describe('conflict resolution tests', () => { + let baseUrl; + let map; + let needLogout; + + const clickWait = 400; + + test.beforeAll(() => { + baseUrl = process.env.BASE_URL; + map = createMap(); + }); + + test.beforeEach(async ({ browserName, page, adminRequest, userRequest }) => { + test.setTimeout(serviceTimeout); + await startJS(browserName, page); + await createTestDataApp(baseUrl, adminRequest); + await createTestDataUser(baseUrl, userRequest); + await manualLogin(baseUrl, page); + needLogout = true; + }); + + test.afterEach(async ({ browserName, page, adminRequest, userRequest }) => { + if (needLogout) { + test.setTimeout(serviceTimeout); + await manualLogout(baseUrl, page); + } + await deleteTestDataApp(baseUrl, adminRequest); + await deleteTestDataUser(baseUrl, userRequest); + await stopJS(browserName, page, map); + }); + + /* eslint-disable-next-line no-empty-pattern */ + test.afterAll(async ({ }, testInfo) => { + await createReport(map, testInfo); + }); + + // This is essentially 'simple version conflict' + test('concurrent conflict resolution', async ({ browserName, browser }, testInfo) => { + test.setTimeout(testInfo.timeout + slowTimeoutAddition); + + const context1 = await browser.newContext(); + const page1 = await context1.newPage(); + await startJS(browserName, page1); + await manualLogin(baseUrl, page1); + await new Promise(res => setTimeout(res, clickWait)); + + const userStateControl1 = page1.locator('#user-home-state'); + const mutations1 = await doMutations(userStateControl1); + await testMutations(page1, userStateControl1, mutations1); + + const expected1 = { + property1: 'value11', + property2: 'value22', + property5: 'value55' + }; + let object1 = await page1.evaluate(() => document.getElementById('user-home-state').object); // eslint-disable-line no-undef + expect(object1).toEqual(expected1); + + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + await startJS(browserName, page2); + await manualLogin(baseUrl, page2); + await new Promise(res => setTimeout(res, clickWait)); + + const userStateControl2 = page2.locator('#user-home-state'); + await expect(userStateControl2.getByLabel('property3')).toBeVisible({ timeout: 5000 }); + const mutations2 = await doMutations(userStateControl2, { + doUpdates: ['property2', 'property3'], + doCreates: [['property6', 'value66']], + doDeletes: ['property1'], + deletePosition: 0 + }); + await testMutations(page2, userStateControl2, mutations2); + + const expected2 = { + property2: 'value22', + property3: 'value33', + property4: 'value4', + property6: 'value66' + }; + let object2 = await page2.evaluate(() => document.getElementById('user-home-state').object); // eslint-disable-line no-undef + expect(object2).toEqual(expected2); + + // Force page 1 to batch (no conflict yet, it's first) + await forceBatchTerminusNav(page1, 'About', baseUrl, clickWait); + + // Force page 2 to batch (will conflict with page 1's changes, triggers conflict) + await forceBatchTerminusNav(page2, 'About', baseUrl, clickWait); + + // The merge result incorporating both: local (page2) preferred when conflicting with remote (page1) + const mergeResult = { + property2: 'value22', + property3: 'value33', + property5: 'value55', + property6: 'value66' + }; + object2 = await page2.evaluate(() => document.getElementById('user-home-state').object); // eslint-disable-line no-undef + expect(object2).toEqual(mergeResult); + + // Force page 1 to reconcile by refreshing + await forceBatchTerminusNav(page1, 'About', baseUrl, clickWait); + object1 = await page1.evaluate(() => document.getElementById('user-home-state').object); // eslint-disable-line no-undef + expect(object1).toEqual(mergeResult); + + await stopJS(browserName, page2, map); + await stopJS(browserName, page1, map); + context2.close(); + context1.close(); + }); + + test('cascading conflict with backoff', async ({ browserName, browser }, testInfo) => { + test.setTimeout(testInfo.timeout + slowTimeoutAddition); + + // Page A: login and mutate property1, property2 + const contextA = await browser.newContext(); + const pageA = await contextA.newPage(); + await startJS(browserName, pageA); + await manualLogin(baseUrl, pageA); + await new Promise(res => setTimeout(res, clickWait)); + + const controlA = pageA.locator('#user-home-state'); + const mutationsA = await doMutations(controlA, { + doUpdates: ['property1'], + doCreates: [['propertyA', 'valueA']], + doDeletes: [], + deletePosition: 0 + }); + await testMutations(pageA, controlA, mutationsA); + + // Page B: login and mutate property2, property3 + const contextB = await browser.newContext(); + const pageB = await contextB.newPage(); + await startJS(browserName, pageB); + await manualLogin(baseUrl, pageB); + await new Promise(res => setTimeout(res, clickWait)); + + const controlB = pageB.locator('#user-home-state'); + const mutationsB = await doMutations(controlB, { + doUpdates: ['property2'], + doCreates: [['propertyB', 'valueB']], + doDeletes: [], + deletePosition: 0 + }); + await testMutations(pageB, controlB, mutationsB); + + // Page C: login and mutate property3, property4 + const contextC = await browser.newContext(); + const pageC = await contextC.newPage(); + await startJS(browserName, pageC); + await manualLogin(baseUrl, pageC); + await new Promise(res => setTimeout(res, clickWait)); + + const controlC = pageC.locator('#user-home-state'); + const mutationsC = await doMutations(controlC, { + doUpdates: ['property3'], + doCreates: [['propertyC', 'valueC']], + doDeletes: [], + deletePosition: 0 + }); + await testMutations(pageC, controlC, mutationsC); + + // Fire batch terminus in rapid succession on all three pages + await forceBatchTerminusNav(pageA, 'About', baseUrl, clickWait); + await forceBatchTerminusNav(pageB, 'About', baseUrl, clickWait); + await forceBatchTerminusNav(pageC, 'About', baseUrl, clickWait); + + // Allow backoff delays to settle + await new Promise(res => setTimeout(res, 2000)); + + // Refresh all pages to get final state + await forceBatchTerminusNav(pageA, 'About', baseUrl, clickWait); + await forceBatchTerminusNav(pageB, 'About', baseUrl, clickWait); + await forceBatchTerminusNav(pageC, 'About', baseUrl, clickWait); + + // All pages need to converge to the same state + const objectA = await pageA.evaluate(() => document.getElementById('user-home-state').object); // eslint-disable-line no-undef + const objectB = await pageB.evaluate(() => document.getElementById('user-home-state').object); // eslint-disable-line no-undef + const objectC = await pageC.evaluate(() => document.getElementById('user-home-state').object); // eslint-disable-line no-undef + + // All three pages must agree on the final state + expect(objectA).toEqual(objectB); + expect(objectB).toEqual(objectC); + + // The merged state must contain all the new properties (none conflicted) + expect(objectA).toHaveProperty('propertyA', 'valueA'); + expect(objectA).toHaveProperty('propertyB', 'valueB'); + expect(objectA).toHaveProperty('propertyC', 'valueC'); + + await stopJS(browserName, pageC, map); + await stopJS(browserName, pageB, map); + await stopJS(browserName, pageA, map); + contextC.close(); + contextB.close(); + contextA.close(); + }); + + // eslint-disable-next-line playwright/no-skipped-test + test.skip('conflict resolution after backoff delays', async ({ browserName, browser }, testInfo) => { + test.setTimeout(testInfo.timeout + slowTimeoutAddition); + + const notChrome = browser.browserType().name() !== 'chromium'; + const clickWait = process.env.CI || notChrome ? 400 : 300; // eslint-disable-line playwright/no-conditional-in-test + + // Page 1: login and mutate + const context1 = await browser.newContext(); + const page1 = await context1.newPage(); + await startJS(browserName, page1); + await manualLogin(baseUrl, page1); + await new Promise(res => setTimeout(res, clickWait)); + + const userStateControl1 = page1.locator('#user-home-state'); + await doMutations(userStateControl1, { + doUpdates: ['property1', 'property2'], + doCreates: [['property7', 'value77']], + doDeletes: ['property3'], + deletePosition: 2 + }); + + // Page 2: login and mutate overlapping + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + await startJS(browserName, page2); + await manualLogin(baseUrl, page2); + await new Promise(res => setTimeout(res, clickWait)); + + const userStateControl2 = page2.locator('#user-home-state'); + await expect(userStateControl2.getByLabel('property3')).toBeVisible({ timeout: 30000 }); + await doMutations(userStateControl2, { + doUpdates: ['property2', 'property3'], + doCreates: [['property8', 'value88']], + doDeletes: ['property4'], + deletePosition: 2 + }); + + // Force page 1 first, then page 2 (page 2 will conflict) + await forceBatchTerminusNav(page1); + await forceBatchTerminusNav(page2); + + // Allow backoff to settle + await new Promise(res => setTimeout(res, 2000)); + + // Refresh both to final state + await forceBatchTerminusNav(page1); + + const object1 = await page1.evaluate(() => document.getElementById('user-home-state').object); // eslint-disable-line no-undef + const object2 = await page2.evaluate(() => document.getElementById('user-home-state').object); // eslint-disable-line no-undef + + // Both pages must have the merged state including creates from both + expect(object2).toHaveProperty('property7', 'value77'); + expect(object2).toHaveProperty('property8', 'value88'); + + // property2 conflict: last writer (page2) wins via local-preferred merge + expect(object2.property2).toEqual('value22'); + + // Page 1 should eventually see same result after refresh + expect(object1).toHaveProperty('property7', 'value77'); + expect(object1).toHaveProperty('property8', 'value88'); + + await stopJS(browserName, page2, map); + await stopJS(browserName, page1, map); + context2.close(); + context1.close(); + }); + + // eslint-disable-next-line playwright/no-skipped-test + test.skip('max retries exceeded shows error message', async ({ browserName, browser }, testInfo) => { + testInfo.skip(browser.browserType().name() !== 'chromium', + 'Route interception for service worker requests requires chromium'); + expect(process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS).toBeTruthy(); + + test.setTimeout(testInfo.timeout + slowTimeoutAddition); + + const clickWait = 300; + + // Page 1: login and mutate to create a version on the server + const context1 = await browser.newContext(); + const page1 = await context1.newPage(); + await startJS(browserName, page1); + await manualLogin(baseUrl, page1); + await new Promise(res => setTimeout(res, clickWait)); + + const userStateControl1 = page1.locator('#user-home-state'); + await doMutations(userStateControl1, { + doUpdates: ['property1'], + doCreates: [], + doDeletes: [], + deletePosition: 0 + }); + + // Commit page 1's changes + await forceBatchTerminusNav(page1); + + // Page 2: login (will have stale version), set up interception + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + await startJS(browserName, page2); + await manualLogin(baseUrl, page2); + await new Promise(res => setTimeout(res, clickWait)); + + // Listen for the error message from the service worker + /* eslint-disable no-undef */ + await page2.evaluate(() => { + window.__conflictError = null; + navigator.serviceWorker.addEventListener('message', event => { + const payload = event?.data?.payload; + if (payload?.message?.class === 'error' && + payload?.message?.text?.includes('could not be resolved')) { + window.__conflictError = payload.message.text; + } + }); + }); + /* eslint-enable no-undef */ + + // Intercept POST /api/data/user/* to always return versionError + // This forces repeated conflicts that will exhaust the retry limit + await context2.route('**/api/data/user/**', async route => { + const request = route.request(); + if (request.method() === 'POST') { + await route.fulfill({ + status: 409, + contentType: 'application/json', + body: JSON.stringify({ + ok: false, + versionError: true, + message: 'Version conflict' + }) + }); + } else { + await route.continue(); + } + }); + + // Make mutations on page 2 + const userStateControl2 = page2.locator('#user-home-state'); + await expect(userStateControl2.getByLabel('property3')).toBeVisible({ timeout: 30000 }); + await doMutations(userStateControl2, { + doUpdates: ['property2'], + doCreates: [], + doDeletes: [], + deletePosition: 0 + }); + + // Force batch on page 2 - every POST will be rejected with versionError, + // triggering repeated conflict resolution until max retries exceeded + await forceBatchTerminusNav(page2); + + // Wait for backoff iterations and max retries to exhaust + // With base=100ms, max=5000ms, 5 retries: worst case ~20s total + await new Promise(res => setTimeout(res, 25000)); + + // Check that the error message was received via service worker message + const errorText = await page2.evaluate(() => window.__conflictError); // eslint-disable-line no-undef + expect(errorText).toBeTruthy(); + expect(errorText).toContain('could not be resolved automatically'); + expect(errorText).toContain('viewing local data'); + + // Also check for the visible error message element + const appMessage = page2.locator('#app-message'); + const messageText = await appMessage.innerText(); + expect(messageText).toContain('could not be resolved automatically'); + + // Cleanup route interception + await context2.unroute('**/api/data/user/**'); + + await stopJS(browserName, page2, map); + await stopJS(browserName, page1, map); + context2.close(); + context1.close(); + }); +});