fix: sync scans immediately and mark acknowledgements
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
Admin 2026-09-23 09:44:08 +00:00
parent 6b0da0c396
commit c858f4e252
4 changed files with 43 additions and 7 deletions

View File

@ -10,4 +10,12 @@ describe('sync contract', () => {
expect(source).toContain("window.addEventListener('online'") expect(source).toContain("window.addEventListener('online'")
expect(source).toContain("document.addEventListener('visibilitychange'") expect(source).toContain("document.addEventListener('visibilitychange'")
}) })
it('reacts immediately to local capture events and coalesces concurrent sync requests', async () => {
const source = await readFile(fileURLToPath(new URL('./sync.ts', import.meta.url)), 'utf8')
expect(source).toContain('FIELDLENS_SYNC_REQUEST_EVENT')
expect(source).toContain('syncRequested = true')
expect(source).toContain('while (syncRequested)')
})
}) })

View File

@ -5,6 +5,7 @@ import {
type SyncResponse, type SyncResponse,
} from '@fieldlens/contracts' } from '@fieldlens/contracts'
import { import {
FIELDLENS_SYNC_REQUEST_EVENT,
acknowledgeOutboxEvent, acknowledgeOutboxEvent,
failOutboxEvent, failOutboxEvent,
getInstallationId, getInstallationId,
@ -57,10 +58,17 @@ export async function ensureDeviceRegistered(): Promise<RegisteredDevice | null>
} }
let syncInFlight: Promise<void> | null = null let syncInFlight: Promise<void> | null = null
let syncRequested = false
export function syncNow() { export function syncNow() {
syncRequested = true
if (syncInFlight) return syncInFlight if (syncInFlight) return syncInFlight
syncInFlight = runSync().finally(() => { syncInFlight = null }) syncInFlight = (async () => {
do {
syncRequested = false
await runSync()
} while (syncRequested)
})().finally(() => { syncInFlight = null })
return syncInFlight return syncInFlight
} }
@ -103,12 +111,14 @@ export function installSyncTriggers() {
if (document.visibilityState === 'visible') attempt() if (document.visibilityState === 'visible') attempt()
} }
window.addEventListener('online', attempt) window.addEventListener('online', attempt)
window.addEventListener(FIELDLENS_SYNC_REQUEST_EVENT, attempt)
document.addEventListener('visibilitychange', onVisible) document.addEventListener('visibilitychange', onVisible)
const interval = window.setInterval(attempt, 30_000) const interval = window.setInterval(attempt, 30_000)
attempt() attempt()
return () => { return () => {
window.removeEventListener('online', attempt) window.removeEventListener('online', attempt)
window.removeEventListener(FIELDLENS_SYNC_REQUEST_EVENT, attempt)
document.removeEventListener('visibilitychange', onVisible) document.removeEventListener('visibilitychange', onVisible)
window.clearInterval(interval) window.clearInterval(interval)
} }

View File

@ -65,13 +65,14 @@ describe('scan persistence', () => {
}) })
it('marks only explicitly acknowledged events synced', async () => { it('marks only explicitly acknowledged events synced', async () => {
await recordScan('ONE', 'Code 128') const first = await recordScan('ONE', 'Code 128')
await recordScan('TWO', 'Code 128') await recordScan('TWO', 'Code 128')
const events = await database.outbox.toArray() const events = await database.outbox.toArray()
await acknowledgeOutboxEvent(events[0]!.id) await acknowledgeOutboxEvent(events[0]!.id)
const stats = await getOutboxStats() const stats = await getOutboxStats()
expect(stats.synced).toBe(1) expect(stats.synced).toBe(1)
expect(stats.pending).toBe(1) expect(stats.pending).toBe(1)
await expect(database.scans.get(first.id)).resolves.toMatchObject({ syncStatus: 'synced' })
}) })
it('queues a local pallet session without any remote dependency', async () => { it('queues a local pallet session without any remote dependency', async () => {

View File

@ -3,6 +3,7 @@ import type { CartonLabelMajorityField, CartonLabelScan } from './cartonLabelCon
import type { LabelFields } from './labelOcr' import type { LabelFields } from './labelOcr'
export const FIELDLENS_SCHEMA_VERSION = 1 as const export const FIELDLENS_SCHEMA_VERSION = 1 as const
export const FIELDLENS_SYNC_REQUEST_EVENT = 'fieldlens:sync-request' as const
export type ScanRecord = { export type ScanRecord = {
id: string id: string
@ -101,6 +102,11 @@ class FieldLensDatabase extends Dexie {
export const database = new FieldLensDatabase() export const database = new FieldLensDatabase()
function requestBackgroundSync() {
if (typeof window === 'undefined') return
window.dispatchEvent(new Event(FIELDLENS_SYNC_REQUEST_EVENT))
}
function outboxEvent( function outboxEvent(
aggregateId: string, aggregateId: string,
eventType: OutboxRecord['eventType'], eventType: OutboxRecord['eventType'],
@ -119,7 +125,7 @@ function outboxEvent(
} }
export async function recordScan(rawValue: string, format: string) { export async function recordScan(rawValue: string, format: string) {
return database.transaction('rw', database.scans, database.outbox, async () => { const record = await database.transaction('rw', database.scans, database.outbox, async () => {
const existing = await database.scans.where('rawValue').equals(rawValue).first() const existing = await database.scans.where('rawValue').equals(rawValue).first()
const nextRecord: ScanRecord = { const nextRecord: ScanRecord = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@ -141,6 +147,8 @@ export async function recordScan(rawValue: string, format: string) {
}, nextRecord.scannedAt)) }, nextRecord.scannedAt))
return nextRecord return nextRecord
}) })
requestBackgroundSync()
return record
} }
export async function startEanPalletSession(targetCount: number) { export async function startEanPalletSession(targetCount: number) {
@ -346,11 +354,20 @@ export async function getOutboxStats() {
} }
export async function acknowledgeOutboxEvent(id: string) { export async function acknowledgeOutboxEvent(id: string) {
await database.transaction('rw', database.outbox, database.scans, async () => {
const event = await database.outbox.get(id)
if (!event) return
await database.outbox.update(id, { await database.outbox.update(id, {
syncedAt: new Date().toISOString(), syncedAt: new Date().toISOString(),
lastError: undefined, lastError: undefined,
nextAttemptAt: undefined, nextAttemptAt: undefined,
}) })
if (event.eventType === 'scan.captured') {
await database.scans.update(event.aggregateId, { syncStatus: 'synced' })
}
})
} }
export async function failOutboxEvent(id: string, error: string) { export async function failOutboxEvent(id: string, error: string) {