feat(kanban): migrate queued commands transactionally
req: sync/014
This commit is contained in:
@@ -232,7 +232,7 @@ async fn pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack(
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const open = indexedDB.open('hemx-kanban-v1', 1);
|
||||
const open = indexedDB.open('hemx-kanban-v1');
|
||||
open.onupgradeneeded = () => {
|
||||
const database = open.result;
|
||||
if (!database.objectStoreNames.contains('commands')) database.createObjectStore('commands', { keyPath: 'id' });
|
||||
@@ -297,7 +297,7 @@ async fn pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack(
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const open = indexedDB.open('hemx-kanban-v1', 1);
|
||||
const open = indexedDB.open('hemx-kanban-v1');
|
||||
open.onsuccess = () => {
|
||||
const request = open.result.transaction('commands', 'readonly').objectStore('commands').count();
|
||||
request.onsuccess = () => done(request.result);
|
||||
@@ -315,12 +315,12 @@ async fn pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack(
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const open = indexedDB.open('hemx-kanban-v1', 1);
|
||||
const open = indexedDB.open('hemx-kanban-v1');
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction('commands', 'readwrite');
|
||||
tx.objectStore('commands').add({
|
||||
id: 'sync-actor:1', schemaVersion: 1, actor: 'sync-actor', session: 'sync-session',
|
||||
causal: 2, kind: 'reorder_card', cardId: '2', eventKind: 'click', key: null,
|
||||
id: 'sync-actor:1', schemaVersion: 2, actor: 'sync-actor', session: 'sync-session',
|
||||
causal: 2, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
|
||||
});
|
||||
tx.oncomplete = () => done({ seeded: true });
|
||||
tx.onabort = () => done({ error: tx.error && tx.error.name });
|
||||
@@ -372,6 +372,164 @@ async fn pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack(
|
||||
result.and(quit)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_upgrade_preserves_queued_order_and_local_intent() -> WebDriverResult<()> {
|
||||
// test req: sync/004 req: sync/010 req: sync/014
|
||||
let app_port = available_port();
|
||||
let app_addr = format!("127.0.0.1:{app_port}");
|
||||
let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example"));
|
||||
app_command
|
||||
.env("HEMX_KANBAN_ADDR", &app_addr)
|
||||
.env("HEMX_KANBAN_SYNC_FAILURES", "3");
|
||||
let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT)
|
||||
.expect("start ready hemx-kanban");
|
||||
|
||||
let webdriver_port = available_port();
|
||||
let webdriver_addr = format!("127.0.0.1:{webdriver_port}");
|
||||
let mut webdriver = Command::new("geckodriver");
|
||||
webdriver.arg("--port").arg(webdriver_port.to_string());
|
||||
let _webdriver = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT)
|
||||
.expect("start ready geckodriver");
|
||||
let mut caps = DesiredCapabilities::firefox();
|
||||
caps.set_headless()?;
|
||||
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
|
||||
|
||||
let result = async {
|
||||
driver.goto(&format!("http://{app_addr}/")).await?;
|
||||
let seeded = driver
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const open = indexedDB.open('hemx-kanban-v1', 1);
|
||||
open.onupgradeneeded = () => {
|
||||
const database = open.result;
|
||||
database.createObjectStore('commands', { keyPath: 'id' });
|
||||
database.createObjectStore('meta');
|
||||
};
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction(['commands', 'meta'], 'readwrite');
|
||||
const commands = tx.objectStore('commands');
|
||||
for (const [causal, cardId, eventKind, key] of [[1, '1', 'click', null], [2, '2', 'keydown', 'Enter'], [3, '3', 'click', null]]) {
|
||||
commands.add({
|
||||
id: `upgrade:${causal}`, schemaVersion: 1, actor: 'upgrade', session: 'legacy-session',
|
||||
causal, kind: 'reorder_card', cardId, eventKind, key,
|
||||
});
|
||||
}
|
||||
tx.objectStore('meta').put(3, 'causal');
|
||||
tx.oncomplete = () => done({ version: open.result.version });
|
||||
tx.onabort = () => done({ error: tx.error && tx.error.name });
|
||||
};
|
||||
"#,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(seeded["version"], 1, "failed to seed legacy queue: {seeded}");
|
||||
|
||||
driver.goto(&format!("http://{app_addr}/sync-demo")).await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'offline'",
|
||||
)
|
||||
.await?;
|
||||
let migrated = driver
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const root = document.querySelector('[data-kanban-sync]');
|
||||
const open = indexedDB.open('hemx-kanban-v1');
|
||||
open.onsuccess = () => {
|
||||
const database = open.result;
|
||||
const tx = database.transaction(['commands', 'meta'], 'readonly');
|
||||
const commands = tx.objectStore('commands').getAll();
|
||||
const receipt = tx.objectStore('meta').get('commandSchemaMigration');
|
||||
tx.oncomplete = () => done({
|
||||
databaseVersion: database.version,
|
||||
commandSchema: root.getAttribute('data-sync-command-schema'),
|
||||
migrationFrom: root.getAttribute('data-sync-migration-from'),
|
||||
migrationTo: root.getAttribute('data-sync-migration-to'),
|
||||
migratedCount: root.getAttribute('data-sync-migrated-count'),
|
||||
pending: root.getAttribute('data-sync-pending-count'),
|
||||
receipt: receipt.result,
|
||||
commands: commands.result.sort((a, b) => a.causal - b.causal).map(({ id, schemaVersion, cardId, targetColumn, eventKind, key }) => ({ id, schemaVersion, cardId, targetColumn, eventKind, key })),
|
||||
});
|
||||
tx.onabort = () => done({ error: tx.error && tx.error.name });
|
||||
};
|
||||
"#,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(migrated["databaseVersion"], 2);
|
||||
assert_eq!(migrated["commandSchema"], "2");
|
||||
assert_eq!(migrated["migrationFrom"], "1");
|
||||
assert_eq!(migrated["migrationTo"], "2");
|
||||
assert_eq!(migrated["migratedCount"], "3");
|
||||
assert_eq!(migrated["pending"], "3");
|
||||
assert_eq!(
|
||||
migrated["receipt"],
|
||||
serde_json::json!({ "from": 1, "to": 2, "migrated": 3 })
|
||||
);
|
||||
assert_eq!(
|
||||
migrated["commands"],
|
||||
serde_json::json!([
|
||||
{ "id": "upgrade:1", "schemaVersion": 2, "cardId": "1", "targetColumn": "done", "eventKind": "click", "key": null },
|
||||
{ "id": "upgrade:2", "schemaVersion": 2, "cardId": "2", "targetColumn": "done", "eventKind": "keydown", "key": "Enter" },
|
||||
{ "id": "upgrade:3", "schemaVersion": 2, "cardId": "3", "targetColumn": "done", "eventKind": "click", "key": null }
|
||||
])
|
||||
);
|
||||
|
||||
driver.find(By::Css("[data-sync-retry]")).await?.click().await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"const root = document.querySelector('[data-kanban-sync]'); return root?.getAttribute('data-sync-phase') === 'backpressured' && root?.getAttribute('data-sync-pending-count') === '1'",
|
||||
)
|
||||
.await?;
|
||||
driver.find(By::Css("[data-sync-retry]")).await?.click().await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"const root = document.querySelector('[data-kanban-sync]'); return root?.getAttribute('data-sync-phase') === 'acknowledged' && root?.getAttribute('data-sync-pending-count') === '0'",
|
||||
)
|
||||
.await?;
|
||||
|
||||
driver
|
||||
.execute(
|
||||
r#"
|
||||
window.__upgradeReplay = [];
|
||||
const source = new EventSource('/sync/acknowledgements?after=0');
|
||||
source.addEventListener('acknowledgement', (event) => {
|
||||
window.__upgradeReplay.push({ id: event.lastEventId, body: JSON.parse(event.data) });
|
||||
if (window.__upgradeReplay.length === 3) source.close();
|
||||
});
|
||||
return true;
|
||||
"#,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
wait_until(&driver, "return window.__upgradeReplay.length === 3").await?;
|
||||
let replay = driver
|
||||
.execute("return window.__upgradeReplay", Vec::new())
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(
|
||||
replay,
|
||||
serde_json::json!([
|
||||
{ "id": "1", "body": { "commandId": "upgrade:1", "cardId": 1, "canonicalColumn": "done", "serverSequence": 1, "status": "accepted" } },
|
||||
{ "id": "2", "body": { "commandId": "upgrade:2", "cardId": 2, "canonicalColumn": "done", "serverSequence": 2, "status": "accepted" } },
|
||||
{ "id": "3", "body": { "commandId": "upgrade:3", "cardId": 3, "canonicalColumn": "done", "serverSequence": 3, "status": "accepted" } }
|
||||
])
|
||||
);
|
||||
assert_eq!(command_count(&driver).await?, 0);
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
let quit = driver.quit().await;
|
||||
result.and(quit)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mixed_queue_removes_accepted_prefix_and_retains_rejected_tail() -> WebDriverResult<()> {
|
||||
// test req: sync/009 req: sync/010
|
||||
@@ -398,7 +556,7 @@ async fn mixed_queue_removes_accepted_prefix_and_retains_rejected_tail() -> WebD
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const open = indexedDB.open('hemx-kanban-v1', 1);
|
||||
const open = indexedDB.open('hemx-kanban-v1');
|
||||
open.onupgradeneeded = () => {
|
||||
const database = open.result;
|
||||
if (!database.objectStoreNames.contains('commands')) database.createObjectStore('commands', { keyPath: 'id' });
|
||||
@@ -460,7 +618,7 @@ async fn mixed_queue_removes_accepted_prefix_and_retains_rejected_tail() -> WebD
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const open = indexedDB.open('hemx-kanban-v1', 1);
|
||||
const open = indexedDB.open('hemx-kanban-v1');
|
||||
open.onsuccess = () => {
|
||||
const request = open.result.transaction('commands', 'readonly').objectStore('commands').getAll();
|
||||
request.onsuccess = () => done(request.result.sort((a, b) => a.causal - b.causal).map(({ id, cardId }) => ({ id, cardId })));
|
||||
@@ -526,7 +684,7 @@ async fn upload_backpressure_keeps_pending_work_visible_and_recoverable() -> Web
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const open = indexedDB.open('hemx-kanban-v1', 1);
|
||||
const open = indexedDB.open('hemx-kanban-v1');
|
||||
open.onupgradeneeded = () => {
|
||||
const database = open.result;
|
||||
if (!database.objectStoreNames.contains('commands')) database.createObjectStore('commands', { keyPath: 'id' });
|
||||
@@ -637,7 +795,7 @@ async fn two_tabs_coordinate_single_uploader_and_takeover_without_duplicate_appl
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const open = indexedDB.open('hemx-kanban-v1', 1);
|
||||
const open = indexedDB.open('hemx-kanban-v1');
|
||||
open.onupgradeneeded = () => {
|
||||
const database = open.result;
|
||||
if (!database.objectStoreNames.contains('commands')) database.createObjectStore('commands', { keyPath: 'id' });
|
||||
@@ -808,7 +966,7 @@ async fn exhausted_offline_retries_keep_command_until_later_reconnect() -> WebDr
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const open = indexedDB.open('hemx-kanban-v1', 1);
|
||||
const open = indexedDB.open('hemx-kanban-v1');
|
||||
open.onupgradeneeded = () => {
|
||||
const database = open.result;
|
||||
if (!database.objectStoreNames.contains('commands')) database.createObjectStore('commands', { keyPath: 'id' });
|
||||
@@ -949,7 +1107,7 @@ async fn missing_history_rebase_converges_without_losing_local_intent() -> WebDr
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const open = indexedDB.open('hemx-kanban-v1', 1);
|
||||
const open = indexedDB.open('hemx-kanban-v1');
|
||||
open.onupgradeneeded = () => {
|
||||
const database = open.result;
|
||||
if (!database.objectStoreNames.contains('commands')) database.createObjectStore('commands', { keyPath: 'id' });
|
||||
@@ -1007,7 +1165,7 @@ async fn missing_history_rebase_converges_without_losing_local_intent() -> WebDr
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const open = indexedDB.open('hemx-kanban-v1', 1);
|
||||
const open = indexedDB.open('hemx-kanban-v1');
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction('meta', 'readonly');
|
||||
const meta = tx.objectStore('meta');
|
||||
@@ -1066,12 +1224,12 @@ async fn missing_history_rebase_converges_without_losing_local_intent() -> WebDr
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const open = indexedDB.open('hemx-kanban-v1', 1);
|
||||
const open = indexedDB.open('hemx-kanban-v1');
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction('commands', 'readwrite');
|
||||
tx.objectStore('commands').add({
|
||||
id: 'history:3', schemaVersion: 1, actor: 'history', session: 'history-session',
|
||||
causal: 3, kind: 'reorder_card', cardId: '2', eventKind: 'click', key: null,
|
||||
id: 'history:3', schemaVersion: 2, actor: 'history', session: 'history-session',
|
||||
causal: 3, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
|
||||
});
|
||||
tx.oncomplete = () => done({ seeded: true });
|
||||
tx.onabort = () => done({ error: tx.error && tx.error.name });
|
||||
@@ -1116,7 +1274,7 @@ async fn missing_history_rebase_converges_without_losing_local_intent() -> WebDr
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const open = indexedDB.open('hemx-kanban-v1', 1);
|
||||
const open = indexedDB.open('hemx-kanban-v1');
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction(['commands', 'meta'], 'readonly');
|
||||
const command = tx.objectStore('commands').get('history:3');
|
||||
@@ -1287,7 +1445,7 @@ async fn command_count(driver: &WebDriver) -> WebDriverResult<u64> {
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const open = indexedDB.open('hemx-kanban-v1', 1);
|
||||
const open = indexedDB.open('hemx-kanban-v1');
|
||||
open.onsuccess = () => {
|
||||
const request = open.result.transaction('commands', 'readonly').objectStore('commands').count();
|
||||
request.onsuccess = () => done(request.result);
|
||||
|
||||
Reference in New Issue
Block a user