feat: make server owner of tabs

This commit is contained in:
2026-10-20 16:15:14 +02:00
parent 5754ae742d
commit 93acfe15a9
26 changed files with 3654 additions and 389 deletions
+230 -18
View File
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
import 'package:sentry_flutter/sentry_flutter.dart';
import '../database/app_database.dart';
import '../database/stock_queries.dart';
import '../models/bar_tab.dart';
import '../models/payment_method.dart';
import '../models/remote_command.dart';
@@ -49,6 +50,13 @@ class RemoteSyncService {
/// Most outbox events sent per sync; a larger backlog drains over syncs.
static const maxEventsPerSync = 500;
/// Most stock adjustment outcomes sent per sync.
static const maxStockResultsPerSync = 500;
/// How long a handled stock adjustment is remembered after the server
/// acknowledged its outcome.
static const stockResultRetention = Duration(days: 30);
static const _pollingInterval = Duration(
seconds: kSyncPollingIntervalSeconds,
);
@@ -66,7 +74,7 @@ class RemoteSyncService {
bool _paused = false;
int _consecutiveFailures = 0;
StreamSubscription<List<BarTab>>? _tabChanges;
StreamSubscription<Set<String>>? _tabChanges;
Timer? _changeTimer;
DateTime? _firstPendingChange;
bool _syncQueued = false;
@@ -98,11 +106,8 @@ class RemoteSyncService {
void startPeriodicSync() {
_isRunning = true;
// The first emission is the current state, not a change
_tabChanges ??= barTabService
.watchOpenTabs()
.skip(1)
.listen((_) => _onTabsChanged());
unawaited(pruneStockAdjustmentResults());
_tabChanges ??= _watchNewOutboxEvents();
_scheduleNext(_pollingInterval);
}
@@ -126,6 +131,27 @@ class RemoteSyncService {
_paused = false;
}
/// Every local tab change queues an event, so a new event in the outbox
/// means a change to push. Watching the tabs instead would also fire on
/// the server state a sync writes, and sync again forever.
StreamSubscription<Set<String>> _watchNewOutboxEvents() {
Set<String>? known;
final ids = database.selectOnly(database.tabEvents)
..addColumns([database.tabEvents.id]);
return ids
.map((row) => row.read(database.tabEvents.id)!)
.watch()
.map((rows) => rows.toSet())
.listen((current) {
// The first emission is the current state, not a change
final isNew = known != null && !current.every(known!.contains);
known = current;
if (isNew) _onTabsChanged();
});
}
/// Pushes tab changes to the server soon after they happen instead of
/// waiting for the next poll. A burst of changes (several drinks rung up in
/// a row) becomes one sync, sent once things are quiet for [changeDebounce]
@@ -183,9 +209,11 @@ class RemoteSyncService {
Duration nextDelay;
try {
await _doSync(credentials);
final appliedStock = await _doSync(credentials);
_consecutiveFailures = 0;
nextDelay = _pollingInterval;
// Show the new stock on the dashboard within seconds. The follow-up
// skips the adjustments it already applied, so this doesn't loop.
nextDelay = appliedStock ? Duration.zero : _pollingInterval;
_emit(RemoteSyncEventType.syncCompleted);
} on UnauthorizedException {
debugPrint('RemoteSyncService: token revoked');
@@ -229,11 +257,20 @@ class RemoteSyncService {
return retryAfter ?? _backoff();
}
Future<void> _doSync(_Credentials credentials) async {
/// Returns whether any stock adjustment was applied, which only happens
/// with a server that doesn't own the stock.
Future<bool> _doSync(_Credentials credentials) async {
final state = await _getSyncState();
// Read before the snapshot, so the snapshot is never older than the events
final outbox = await _pendingEvents();
final openTabs = await barTabService.getOpenTabs();
// One read, so the snapshot and the stock match the events that are sent
final (outbox, openTabs, products, stockResults) = await database
.transaction(
() async => (
await _pendingEvents(),
await barTabService.getOpenTabs(),
await database.select(database.products).get(),
await _pendingStockResults(),
),
);
final response = await apiClient.sync(
token: credentials.token,
@@ -245,22 +282,60 @@ class RemoteSyncService {
for (final row in outbox)
jsonDecode(row.payload) as Map<String, dynamic>,
],
products: products.map(_toCatalogEntry).toList(),
stockAdjustmentResults: [
for (final row in stockResults)
jsonDecode(row.payload) as Map<String, dynamic>,
],
settlementsCursor: state?.settlementsCursor,
),
);
await _clearSentEvents(response);
await _markStockResultsReceived(response);
await barTabService.recordSettlements(response.settlements);
final serverTabs = response.tabs;
if (serverTabs != null) {
// After the acked events left the outbox: what is left still has to
// reach the server and is replayed on top
await barTabService.applyServerState(serverTabs);
}
// Same rebase for the stock: the server counts every tablet's sales
final serverStock = response.stock;
if (serverStock != null) await database.rebaseStock(serverStock);
for (final command in response.commands) {
await _processCommand(command, credentials);
}
final openNames = openTabs.map((tab) => tab.customerName).toSet();
for (final name in response.plannedTabNames) {
if (name.trim().isEmpty || !openNames.add(name)) continue;
await barTabService.createTab(customerName: name);
// A server that holds the tabs creates planned tabs itself; doing it
// here too would open them once per tablet
if (serverTabs == null) {
final openNames = openTabs.map((tab) => tab.customerName).toSet();
for (final name in response.plannedTabNames) {
if (name.trim().isEmpty || !openNames.add(name)) continue;
await barTabService.createTab(customerName: name);
}
}
await _updateSyncState(success: true, cursor: response.cursor);
// A server that owns the stock applies the dashboard's adjustments
// itself; doing it here too would apply a delivery once per tablet
var appliedStock = false;
if (serverStock == null) {
for (final adjustment in response.stockAdjustments) {
if (await _applyStockAdjustment(adjustment)) appliedStock = true;
}
}
await _updateSyncState(
success: true,
cursor: response.cursor,
settlementsCursor: response.settlementsCursor,
);
return appliedStock;
}
Future<List<TabEventRow>> _pendingEvents() {
@@ -295,6 +370,134 @@ class RemoteSyncService {
)..where((row) => row.id.isIn(ids))).go();
}
Future<List<StockAdjustmentResultRow>> _pendingStockResults() {
return (database.select(database.stockAdjustmentResults)
..where((row) => row.acknowledged.not())
..orderBy([
(row) => OrderingTerm.asc(row.createdAt),
(row) => OrderingTerm.asc(const CustomExpression<int>('rowid')),
])
..limit(maxStockResultsPerSync))
.get();
}
/// Received outcomes stay as the record that their adjustment was handled;
/// [pruneStockAdjustmentResults] removes them later.
Future<void> _markStockResultsReceived(SyncResponse response) async {
for (final rejected in response.rejectedStockAdjustmentResults) {
debugPrint(
'RemoteSyncService: stock result ${rejected.id} rejected: '
'${rejected.reason}',
);
}
final ids = {
...response.acknowledgedStockAdjustmentResultIds,
for (final rejected in response.rejectedStockAdjustmentResults)
if (rejected.id != null) rejected.id!,
};
if (ids.isEmpty) return;
await (database.update(
database.stockAdjustmentResults,
)..where((row) => row.id.isIn(ids))).write(
const StockAdjustmentResultsCompanion(acknowledged: Value(true)),
);
}
/// Applies [adjustment] unless it was handled before, and stores the
/// outcome in the same transaction, so a crash can neither apply it twice
/// nor lose it. Returns whether the stock changed.
Future<bool> _applyStockAdjustment(StockAdjustment adjustment) async {
final type = adjustment.type;
if (type == null) return false;
return database.transaction(() async {
final handled = await (database.select(
database.stockAdjustmentResults,
)..where((row) => row.id.equals(adjustment.id))).getSingleOrNull();
if (handled != null) return false;
final product = await (database.select(
database.products,
)..where((row) => row.id.equals(adjustment.productId))).getSingleOrNull();
final Map<String, dynamic> result;
if (product == null) {
result = _rejectedStockResult(adjustment, 'unknown_product');
} else if (type == StockAdjustmentType.add && product.infiniteStock) {
result = _rejectedStockResult(adjustment, 'stock_not_tracked');
} else {
final stockAfter = switch (type) {
StockAdjustmentType.add =>
product.stockQuantity + adjustment.quantity,
StockAdjustmentType.set => adjustment.quantity,
};
// A stock count on an untracked product starts tracking it
await (database.update(
database.products,
)..where((row) => row.id.equals(product.id))).write(
ProductsCompanion(
stockQuantity: Value(stockAfter),
infiniteStock: const Value(false),
),
);
result = {
'id': adjustment.id,
'status': 'applied',
'stockAfter': stockAfter,
};
}
await database
.into(database.stockAdjustmentResults)
.insert(
StockAdjustmentResultsCompanion.insert(
id: adjustment.id,
payload: jsonEncode(result),
createdAt: DateTime.now(),
),
);
return result['status'] == 'applied';
});
}
Map<String, dynamic> _rejectedStockResult(
StockAdjustment adjustment,
String reason,
) {
debugPrint(
'RemoteSyncService: stock adjustment ${adjustment.id} rejected: $reason',
);
return {'id': adjustment.id, 'status': 'rejected', 'reason': reason};
}
/// Forgets handled stock adjustments whose outcome the server received
/// more than [stockResultRetention] ago.
Future<void> pruneStockAdjustmentResults({DateTime? now}) async {
final cutoff = (now ?? DateTime.now()).subtract(stockResultRetention);
await (database.delete(database.stockAdjustmentResults)..where(
(row) => row.acknowledged & row.createdAt.isSmallerThanValue(cutoff),
))
.go();
}
/// Hidden products are included: they still exist. A server that owns the
/// stock ignores `stock`; it gets the counts as `stock_set` events.
Map<String, dynamic> _toCatalogEntry(ProductRow product) {
final category = product.category.trim();
return {
'id': product.id,
'name': product.name,
'category': category.isEmpty ? null : category,
'priceInCents': product.priceInCents,
'stock': product.infiniteStock ? null : product.stockQuantity,
};
}
SnapshotTab _toSnapshot(BarTab tab) {
return SnapshotTab(
id: tab.id,
@@ -404,7 +607,13 @@ class RemoteSyncService {
)..where((row) => row.deviceId.equals(_syncStateId))).getSingleOrNull();
}
Future<void> _updateSyncState({required bool success, String? cursor}) async {
/// Settlements are deduplicated by id, so a cursor lost in a crash only
/// makes the server send some of them again.
Future<void> _updateSyncState({
required bool success,
String? cursor,
String? settlementsCursor,
}) async {
final now = DateTime.now();
final existing = await _getSyncState();
@@ -414,6 +623,9 @@ class RemoteSyncService {
RemoteSyncStateCompanion.insert(
deviceId: _syncStateId,
serverCursor: Value(cursor ?? existing?.serverCursor),
settlementsCursor: Value(
settlementsCursor ?? existing?.settlementsCursor,
),
lastSuccessfulSyncAt: Value(
success ? now : existing?.lastSuccessfulSyncAt,
),