From 299f821330bbc959264383dacc25d4e302215ce5 Mon Sep 17 00:00:00 2001 From: Seenu K Date: Sat, 3 Oct 2026 06:58:39 +0530 Subject: [PATCH 1/9] Fix what the audit found in settling up, leaving, sign-in and rates Settling up - The UPI ID handed to a payment app is always the payee's own: a typed one is kept with the payee it was typed for and only read back for them. - Amounts take a comma or a dot as the decimal point and refuse grouping, so "12,50" is no longer 1,250; very long amounts are refused instead of breaking the screen. - Nobody pays themselves, and the payee picker clears on screen when the payer becomes the payee. - With "Suggest the fewest payments" off, each pair's own debt is suggested, netted both ways, and explained from the expenses they shared. The expense editor and Settle up keep their state in Riverpod notifiers holding an immutable form, seeded once and listened to after, so a sync cannot wipe what is being typed and the logic is tested without widgets. Leaving a group - Leaving needs you settled up, as removing somebody else does. - Somebody who left comes back with a link, to the same place; nobody else can bring an account holder back. - No write may change what a departed member owes, except to settle it. - Left groups move to the archived list, read-only, and stop syncing once the server has confirmed it. Sign-in - Attaching an email address or a Google account to an account that has one needs a sign-in from the last ten minutes, as deleting it does. Rates and storage - The daily rate job keeps the last year complete, fetching any week-long gap in one range request; a rate over a week older than a day no longer answers for it, on the server or the device. - Multi-row inserts are split to fit 100 bound values a statement, so an expense among 26 or more people saves instead of being refused forever. Groups are listed by their newest activity. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUEWUK8dHE137xzXX3zSBu --- CHANGELOG.md | 15 + docs/architecture.md | 6 +- docs/openapi.json | 46 +- lib/application/entry_form.dart | 519 ++++++++++++ lib/application/ledger_providers.dart | 26 +- lib/application/local_providers.dart | 1 + lib/application/session_providers.dart | 6 +- lib/application/settlement_form.dart | 212 +++++ lib/application/sync_providers.dart | 1 + lib/data/fx/drift_fx_repository.dart | 9 +- lib/data/push/background_handler.dart | 1 + .../repositories/drift_group_repository.dart | 89 +- lib/data/sync/outbox_queue.dart | 9 + lib/data/sync/sync_engine.dart | 53 +- lib/domain/balance/pairwise.dart | 97 +++ lib/domain/balance/transfer_basis.dart | 52 ++ lib/domain/decimal_text.dart | 50 ++ lib/domain/entry_draft.dart | 17 + lib/domain/fx/fx_quote.dart | 7 + lib/domain/money_format.dart | 32 +- lib/presentation/amount_input.dart | 27 + .../screens/archived_groups_screen.dart | 42 +- .../screens/entry_editor_screen.dart | 760 ++++-------------- .../screens/group_list_screen.dart | 18 +- .../screens/group_settings_screen.dart | 63 +- lib/presentation/screens/join_screen.dart | 59 +- .../screens/settle_up_screen.dart | 262 +++--- lib/presentation/widgets/balances_panel.dart | 52 +- .../widgets/link_account_prompt.dart | 3 +- .../lib/src/model/link_preview.dart | 11 +- .../lib/src/model/link_preview.g.dart | 3 + server/src/api/account.ts | 7 +- server/src/chunked.ts | 20 +- server/src/context.ts | 12 + server/src/do/fx/index.ts | 136 +++- server/src/do/group/balances.ts | 9 + server/src/do/group/changes.ts | 3 +- server/src/do/group/invites.ts | 33 +- server/src/do/group/ledger.ts | 64 +- server/src/do/group/roster.ts | 31 +- server/src/fx/providers.ts | 32 + server/src/identity/routes.ts | 29 +- server/src/scheduled.ts | 7 +- server/src/schemas/ledger.ts | 2 + server/test/fx.test.ts | 80 ++ server/test/ledger.test.ts | 26 + server/test/reauth.test.ts | 53 ++ server/test/roster.test.ts | 114 ++- test/application/forms_test.dart | 271 +++++++ test/data/fx_repository_test.dart | 23 + test/data/live_backend.dart | 3 +- test/data/repositories_test.dart | 61 +- test/data/sync_test.dart | 17 +- test/domain/balance_test.dart | 139 ++++ test/domain/currency_test.dart | 31 +- test/presentation/guest_account_test.dart | 1 + test/presentation/settle_up_test.dart | 180 +++++ test/presentation/sync_refresh_test.dart | 16 +- 58 files changed, 2995 insertions(+), 953 deletions(-) create mode 100644 lib/application/entry_form.dart create mode 100644 lib/application/settlement_form.dart create mode 100644 lib/domain/balance/pairwise.dart create mode 100644 lib/domain/balance/transfer_basis.dart create mode 100644 lib/domain/decimal_text.dart create mode 100644 lib/presentation/amount_input.dart create mode 100644 test/application/forms_test.dart create mode 100644 test/presentation/settle_up_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 750dcff..93700f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ What changed in each version of the app, newest first. The format is - On the web, OpenSplit tells you when an update is ready, and restarts into it when you choose. - Groups and people have pictures: initials, an emoji or an icon, on a colour you choose. Tap a picture to change it. - Each group has a cover drawn from what it spends on, so a trip fills with flights and dinners and a flat with bills. +- With "Suggest the fewest payments" off, balances show what each person owes each person they shared expenses with, each with its own Settle button. +- Somebody who left a group can come back with an invite link, to the same place they had. ### Changed - Sign-in code emails have a new look to match the app. @@ -23,6 +25,11 @@ What changed in each version of the app, newest first. The format is - Amounts use the app's own typeface, with figures that line up. - Lists are grouped the Material 3 Expressive way, as connected rows rather than separate cards, and icon buttons change shape as you press them. - The sign-in code email sets its code in the app's own typeface. +- Groups are listed by their newest activity rather than when they were made. +- You need to be settled up before leaving a group, as you already did to remove somebody else. +- Groups you have left move to the archived list, read-only, and stop syncing. +- Amounts can be typed with a comma as the decimal point. Thousands separators are no longer accepted while typing. +- Exchange-rate estimates only use a rate from within a week of the expense's day, and the server keeps a full year of rates to draw on. ### Fixed - A group where you're owed one currency and owe another now says both on the groups list, instead of calling it all owed to you. @@ -30,6 +37,14 @@ What changed in each version of the app, newest first. The format is - A name you give while creating your first group now shows on your profile straight away. - Reloading the web app no longer flashes "This site can't be reached" first. - Signing out stops notifications for that account on the device. +- Changing who is being paid in Settle up now changes the UPI ID handed to your payment app too, instead of keeping the previous person's. +- An amount typed as 12,50 is no longer read as 1,250. +- A very long amount no longer breaks the Settle up screen. +- You can no longer record a payment from someone to themselves. +- Changing the email address or Google account an account signs in with now needs a recent sign-in, like deleting the account does. +- Nobody can add a new expense that charges somebody who has left a group. +- An expense split among 26 or more people now saves; before, the server refused it and the app kept retrying. +- In Settle up, making the person being paid the payer now clears "Who is being paid" on screen too, not just behind it. ## [2.0.0] - 2026-10-01 ### Changed diff --git a/docs/architecture.md b/docs/architecture.md index 919a114..421ea33 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -327,7 +327,11 @@ tokens. ## Cron: two schedules -`0 4 * * *` refreshes exchange rates. `0 5 * * *` collects abandoned guest +`0 4 * * *` refreshes exchange rates, then keeps the last year of them +complete: any stretch of more than a week with no publication (all of it, on a +new deployment) is fetched whole in one range request. A rate more than a week +older than a day does not answer for that day, on the server or the device. +`0 5 * * *` collects abandoned guest accounts (over ninety days old, in no group, no session used in ninety days) and finishes any account deletion a request started and did not complete. diff --git a/docs/openapi.json b/docs/openapi.json index 953b2c4..3da39f5 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1541,6 +1541,9 @@ }, "isMember": { "type": "boolean" + }, + "hasLeft": { + "type": "boolean" } }, "required": [ @@ -1553,7 +1556,8 @@ "isRedeemed", "isExpired", "isRevoked", - "isMember" + "isMember", + "hasLeft" ] }, "LinkKind": { @@ -2193,6 +2197,16 @@ } } }, + "403": { + "description": "This account has an address, and its sign-in is too old to change who it belongs to. Confirm it is you first: `POST /identity/reauth`, then `POST /identity/reauth/verify`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "409": { "description": "That Google account already has an account of its own, and allowSignIn was not set.", "content": { @@ -2253,6 +2267,16 @@ } } } + }, + "403": { + "description": "This account has an address, and its sign-in is too old to change who it belongs to. Confirm it is you first: `POST /identity/reauth`, then `POST /identity/reauth/verify`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } } @@ -2294,6 +2318,16 @@ } } }, + "403": { + "description": "This account has an address, and its sign-in is too old to change who it belongs to. Confirm it is you first: `POST /identity/reauth`, then `POST /identity/reauth/verify`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Too many attempts. Wait a minute and try again.", "content": { @@ -2364,6 +2398,16 @@ } } }, + "403": { + "description": "This account has an address, and its sign-in is too old to change who it belongs to. Confirm it is you first: `POST /identity/reauth`, then `POST /identity/reauth/verify`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Too many attempts. Wait a minute and try again.", "content": { diff --git a/lib/application/entry_form.dart b/lib/application/entry_form.dart new file mode 100644 index 0000000..7cfb38d --- /dev/null +++ b/lib/application/entry_form.dart @@ -0,0 +1,519 @@ +/// The expense editor's state: what has been typed and chosen, and the one +/// way each of those changes. +library; + +import 'dart:async'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:opensplit_api/opensplit_api.dart' show SplitKind; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../data/local/database.dart'; +import '../domain/calendar_date.dart'; +import '../domain/category_guess.dart'; +import '../domain/decimal_text.dart'; +import '../domain/entry_draft.dart'; +import '../domain/fx/fx_quote.dart'; +import '../domain/models/entry.dart'; +import '../domain/money_format.dart'; +import '../domain/split/allocation.dart'; +import '../domain/split/splitter.dart'; +import 'ledger_providers.dart'; +import 'local_providers.dart'; +import 'sync_providers.dart'; + +part 'entry_form.freezed.dart'; +part 'entry_form.g.dart'; + +/// Everything in the expense editor, exactly as entered. +/// +/// Amounts stay the text that was typed: whether that text is an amount +/// depends on the currency, which can change after it was typed. What the +/// form adds up to is derived from this, never stored beside it. +@freezed +abstract class EntryFormState with _$EntryFormState { + const factory EntryFormState({ + /// The expense as it was when the editor opened; null for a new one. + /// Saving refuses if it has changed since. + Entry? editing, + required String currencyCode, + @Default('') String amount, + @Default('') String description, + String? categoryId, + + /// Whether the category was picked by hand, or came with the expense + /// being edited. Until then it follows the description. + @Default(false) bool categoryChosen, + + /// The day it happened, at UTC midnight. + required DateTime day, + + /// When on [day] it happened, and the zone it happened in: both or + /// neither. + DateTime? occurredAt, + String? timeZone, + @Default(SplitKind.equal) SplitKind splitKind, + @Default({}) Set participants, + + /// Whole shares per person, for a split by shares. One when unset. + @Default({}) Map shareCounts, + + /// Typed per person, for a split by amounts or percentages. + @Default({}) Map exactAmounts, + @Default({}) Map percents, + @Default({}) Set payers, + @Default(false) bool severalPayers, + + /// Typed per payer, when several people paid. + @Default({}) Map paidAmounts, + @Default(false) bool saving, + String? error, + }) = _EntryFormState; + + const EntryFormState._(); + + /// A new expense: everyone splits it and the person adding it paid. The + /// overwhelmingly common case, so the fast path is two fields and Save. + factory EntryFormState.blank(GroupLedger ledger, {required DateTime now}) { + final me = ledger.me?.id ?? ledger.members.firstOrNull?.id; + return EntryFormState( + currencyCode: ledger.group.defaultCurrency, + day: calendarDay(now), + // It happened now, here, unless the time is taken off. + occurredAt: now.toUtc(), + participants: {for (final member in ledger.members) member.id}, + payers: {?me}, + ); + } + + /// [entry] as it stands, to be edited. + factory EntryFormState.of(Entry entry, Currency? currency) { + String typed(int minor) => currency?.formatPlain(minor) ?? ''; + return EntryFormState( + editing: entry, + currencyCode: entry.row.currency, + amount: typed(entry.row.amountMinor), + description: entry.row.description, + categoryId: entry.row.categoryId, + categoryChosen: entry.row.categoryId != null, + day: entry.row.entryDate, + occurredAt: entry.row.occurredAt, + timeZone: entry.row.timeZone, + splitKind: entry.row.splitKind, + participants: {for (final share in entry.shares) share.memberId}, + shareCounts: { + for (final share in entry.shares) + if (share.weightMicros case final weight?) + share.memberId: (weight / weightScale).round(), + }, + exactAmounts: { + for (final share in entry.shares) + share.memberId: typed(share.amountMinor), + }, + percents: { + for (final share in entry.shares) + if (share.weightMicros case final weight?) + share.memberId: formatPercentMicros(weight), + }, + payers: {for (final payer in entry.payers) payer.memberId}, + severalPayers: entry.payers.length > 1, + paidAmounts: { + for (final payer in entry.payers) + payer.memberId: typed(payer.amountMinor), + }, + ); + } + + bool get isEditing => editing != null; + + /// The total in [currency], once what was typed reads as one. + int? totalIn(Currency currency) { + final total = currency.parseToMinor(amount); + return total != null && total > 0 ? total : null; + } + + int shareCountOf(String memberId) => shareCounts[memberId] ?? 1; + + /// What each person ends up owing, for splits that can be worked out before + /// anything else is typed. Null for the rest, or until there is a total. + Map? preview(Currency currency) { + final total = totalIn(currency); + if (total == null || participants.isEmpty) return null; + final spec = switch (splitKind) { + SplitKind.equal => EqualSplit(participants.toList()), + SplitKind.shares => SharesSplit({ + for (final id in participants) id: shareCountOf(id), + }), + _ => null, + }; + if (spec == null) return null; + try { + return { + for (final share in spec.resolve(total)) + share.memberId: share.amountMinor, + }; + } on SplitException { + return null; + } + } + + /// The expense this form describes in [groupId], or what is still missing + /// from it. + DraftCheck check( + Currency currency, { + required String groupId, + FxQuote? fx, + String? deviceZone, + }) { + final total = totalIn(currency); + if (total == null) { + return DraftIncomplete( + currency.exponent == 0 + ? 'Enter a whole amount.' + : 'Enter an amount with at most ${currency.exponent} decimal ' + 'places.', + ); + } + final split = _split(currency); + if (split == null) { + return const DraftIncomplete( + 'Check the split — some amounts are missing.', + ); + } + final paid = _paid(currency, total); + if (paid == null) { + return const DraftIncomplete( + 'Check who paid — some amounts are missing.', + ); + } + + // A time with no zone yet (a new expense) is this device's. Saving never + // waits to learn which that is: with no zone known, only the day is kept. + final zone = timeZone ?? deviceZone; + final moment = occurredAt; + final timed = moment != null && zone != null; + return DraftReady( + EntryDraft( + groupId: groupId, + currency: currency.code, + amountMinor: total, + description: description.trim(), + categoryId: categoryId, + split: split, + payerAmounts: paid, + entryDate: day, + occurredAt: timed ? moment : null, + timeZone: timed ? zone : null, + // A fact about the transaction, captured once. + fxRate: fx?.rate, + fxSource: fx == null ? null : '${fx.source}@${calendarDate(fx.date)}', + ), + ); + } + + SplitSpec? _split(Currency currency) { + final ids = participants.toList(); + if (ids.isEmpty) return null; + + Map? each( + int? Function(String text) parse, + Map typed, + ) { + final values = {}; + for (final id in ids) { + final value = parse(typed[id] ?? ''); + if (value == null) return null; + values[id] = value; + } + return values; + } + + return switch (splitKind) { + SplitKind.equal => EqualSplit(ids), + SplitKind.shares => SharesSplit({ + for (final id in ids) id: shareCountOf(id), + }), + SplitKind.exact => switch (each(currency.parseToMinor, exactAmounts)) { + final amounts? => ExactSplit(amounts), + null => null, + }, + SplitKind.percent => switch (each(parsePercentMicros, percents)) { + final percents? => PercentSplit(percents), + null => null, + }, + // A split rule from a newer server: saving would have to guess it. + SplitKind.unknownDefaultOpenApi => null, + }; + } + + Map? _paid(Currency currency, int total) { + if (!severalPayers) { + final only = payers.firstOrNull; + return only == null ? null : {only: total}; + } + final amounts = {}; + for (final id in payers) { + final amount = currency.parseToMinor(paidAmounts[id] ?? ''); + if (amount == null || amount <= 0) return null; + amounts[id] = amount; + } + return amounts.isEmpty ? null : amounts; + } +} + +/// The expense editor for one group: a new expense, or [entryId] being +/// edited. +/// +/// Seeded once, as soon as the group and the currencies have arrived. It +/// listens to them rather than watching, so a sync landing while the editor +/// is open never resets what has been typed; saving is what checks the +/// expense has not moved underneath it. +@riverpod +class EntryForm extends _$EntryForm { + /// Dates and currencies a rate has already been asked for. + final _ratesAsked = {}; + + @override + Future build(String groupId, {String? entryId}) { + final seeded = Completer(); + void seed() { + if (seeded.isCompleted) return; + final ledger = ref.read(groupLedgerProvider(groupId)); + final currencies = ref.read(currenciesProvider).value; + if (ledger == null || currencies == null) return; + + if (entryId == null) { + seeded.complete(EntryFormState.blank(ledger, now: DateTime.now())); + return; + } + final entry = ledger.entries.where((e) => e.id == entryId).firstOrNull; + if (entry != null) { + seeded.complete( + EntryFormState.of(entry, currencies[entry.row.currency]), + ); + } + } + + ref.listen(groupLedgerProvider(groupId), (_, _) => seed()); + ref.listen(currenciesProvider, (_, _) => seed()); + seed(); + return seeded.future.then((form) { + unawaited(_askForRateIfMissing(form)); + return form; + }); + } + + EntryFormState get _form => state.requireValue; + + void _change(EntryFormState Function(EntryFormState form) change) => + state = AsyncData(change(_form).copyWith(error: null)); + + void setAmount(String text) => _change((form) => form.copyWith(amount: text)); + + /// The category follows the description until one is picked. A guess that + /// stops matching is taken back, so typing "Uber" and then correcting it to + /// "Usha's gift" doesn't leave the expense a taxi ride. + void setDescription(String text) => _change((form) { + if (form.categoryChosen) return form.copyWith(description: text); + final icon = guessCategoryIcon(text); + final categories = ref.read(categoriesProvider).value ?? const []; + return form.copyWith( + description: text, + categoryId: categories.where((c) => c.icon == icon).firstOrNull?.id, + ); + }); + + void chooseCategory(String? id) => + _change((form) => form.copyWith(categoryId: id, categoryChosen: true)); + + void chooseCurrency(String code) { + _change((form) => form.copyWith(currencyCode: code)); + unawaited(_askForRateIfMissing(_form)); + } + + /// Another day: the time no longer applies, since it is not known. + void chooseDay(DateTime day) { + final picked = calendarDay(day); + if (picked == _form.day) return; + _change( + (form) => form.copyWith(day: picked, occurredAt: null, timeZone: null), + ); + unawaited(_askForRateIfMissing(_form)); + } + + /// A time picked on this device's clock happened in this device's [zone]. + void chooseTime({ + required int hour, + required int minute, + required String zone, + }) => _change((form) { + final day = form.day; + return form.copyWith( + occurredAt: DateTime(day.year, day.month, day.day, hour, minute).toUtc(), + timeZone: zone, + ); + }); + + void clearTime() => + _change((form) => form.copyWith(occurredAt: null, timeZone: null)); + + void chooseSplitKind(SplitKind kind) => + _change((form) => form.copyWith(splitKind: kind)); + + void setInSplit(String memberId, {required bool included}) => _change( + (form) => form.copyWith( + participants: included + ? {...form.participants, memberId} + : ({...form.participants}..remove(memberId)), + ), + ); + + /// One more or one fewer share for [memberId], never below none. + void nudgeShares(String memberId, int by) => _change( + (form) => form.copyWith( + shareCounts: { + ...form.shareCounts, + memberId: (form.shareCountOf(memberId) + by).clamp(0, 1 << 30), + }, + ), + ); + + void setExactAmount(String memberId, String text) => _change( + (form) => + form.copyWith(exactAmounts: {...form.exactAmounts, memberId: text}), + ); + + void setPercent(String memberId, String text) => _change( + (form) => form.copyWith(percents: {...form.percents, memberId: text}), + ); + + /// The one person who paid it all. + void choosePayer(String memberId) => + _change((form) => form.copyWith(payers: {memberId})); + + void setPaying(String memberId, {required bool paying}) => _change( + (form) => form.copyWith( + payers: paying + ? {...form.payers, memberId} + : ({...form.payers}..remove(memberId)), + ), + ); + + /// Back to one payer keeps the first of several. + void setSeveralPayers(bool several) => _change( + (form) => form.copyWith( + severalPayers: several, + payers: several ? form.payers : {?form.payers.firstOrNull}, + ), + ); + + void setPaidAmount(String memberId, String text) => _change( + (form) => form.copyWith(paidAmounts: {...form.paidAmounts, memberId: text}), + ); + + /// Saves the expense, and says whether it was saved. When it was not, the + /// state says why. + Future save() async { + final ledger = ref.read(groupLedgerProvider(groupId)); + final form = _form; + final currency = ref.read(currenciesProvider).value?[form.currencyCode]; + if (ledger == null || currency == null || form.saving) return false; + + final fx = await _quote(form, ledger.group.defaultCurrency); + if (!ref.mounted) return false; + final check = form.check( + currency, + groupId: groupId, + fx: fx, + deviceZone: ref.read(deviceZoneProvider).value, + ); + final EntryDraft draft; + switch (check) { + case DraftIncomplete(:final reason): + state = AsyncData(form.copyWith(error: reason)); + return false; + case DraftReady(draft: final ready): + draft = ready; + } + + state = AsyncData(form.copyWith(saving: true, error: null)); + final repository = ref.read(entryRepositoryProvider); + try { + if (form.editing case final editing?) { + // Who is editing, which need not be who created it: that difference + // is most of what makes a feed line worth reading. + await repository.update( + editing.id, + draft, + actorId: ledger.me?.id, + expected: editing, + ); + } else { + await repository.create( + draft, + createdBy: ledger.me?.id ?? draft.payerAmounts.keys.first, + ); + } + return true; + } on SplitException catch (error) { + // The domain refused it, so nothing was written. Its messages are + // written for people, so they are shown as they are. + _fail(error.message); + return false; + } catch (error) { + _fail('$error'); + return false; + } + } + + /// Deletes the expense being edited, and says whether it was deleted. + Future delete() async { + final editing = _form.editing; + if (editing == null) return false; + try { + await ref + .read(entryRepositoryProvider) + .delete( + editing.id, + actorId: ref.read(groupLedgerProvider(groupId))?.me?.id, + expected: editing, + ); + return true; + } catch (error) { + _fail('$error'); + return false; + } + } + + void _fail(String reason) { + if (ref.mounted) { + state = AsyncData(_form.copyWith(saving: false, error: reason)); + } + } + + /// The rate on the expense's own day, not today's. + Future _quote(EntryFormState form, String target) async => + form.currencyCode == target + ? null + : ref + .read(fxRepositoryProvider) + .quote(base: form.currencyCode, quote: target, asOf: form.day); + + /// Nothing on this device prices this day, so the server is asked. The rate + /// lands on a later sync, and the expense saves without one meanwhile. + Future _askForRateIfMissing(EntryFormState form) async { + final target = ref + .read(groupLedgerProvider(groupId)) + ?.group + .defaultCurrency; + if (target == null || form.currencyCode == target) return; + if (await _quote(form, target) != null) return; + if (!_ratesAsked.add('${calendarDate(form.day)}|${form.currencyCode}')) { + return; + } + await ref + .read(syncEngineProvider) + ?.shared + .requestBackfill(form.day, form.currencyCode); + } +} diff --git a/lib/application/ledger_providers.dart b/lib/application/ledger_providers.dart index 96017fb..7628eec 100644 --- a/lib/application/ledger_providers.dart +++ b/lib/application/ledger_providers.dart @@ -3,10 +3,12 @@ import 'package:opensplit_api/opensplit_api.dart' show AvatarKind; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../data/local/database.dart'; +import '../data/repositories/drift_group_repository.dart'; import '../domain/analytics/analytics_query.dart'; import '../domain/avatar.dart'; import '../domain/balance/balance_fold.dart'; import '../domain/balance/member_balance.dart'; +import '../domain/balance/pairwise.dart'; import '../domain/balance/simplify.dart'; import '../domain/fx/estimated_total.dart'; import '../domain/member_identity.dart'; @@ -85,10 +87,11 @@ Stream> currencies(Ref ref) => ref .watchAll() .map((list) => {for (final c in list) c.code: c}); +/// Every group on this device, the most recently active first, and whether +/// this account has left each. @riverpod -Stream> groups(Ref ref, {bool includeArchived = false}) => ref - .watch(groupRepositoryProvider) - .watchGroups(includeArchived: includeArchived); +Stream> groupListings(Ref ref) => + ref.watch(groupRepositoryProvider).watchListings(); @riverpod Stream group(Ref ref, String groupId) => @@ -133,8 +136,9 @@ class GroupLedger { /// Net position per member per currency. Members who are settled are absent. final List balances; - /// The suggested payments that would settle the group, per currency. Empty - /// when the group has simplification switched off. + /// The suggested payments that would settle the group, per currency: the + /// fewest that do it when the group simplifies its debts, otherwise each + /// pair's own debt to each other. final List transfers; /// This device's member in the group, if it has one. @@ -168,6 +172,14 @@ class GroupLedger { return null; } + /// Who a payment can be recorded between: everybody here, and anybody who + /// left with a balance still open, which only settling can close. + List get settleable => [ + ...members, + for (final member in pastMembers) + if (!isSettledUp(member.id)) member, + ]; + /// Whether [memberId] is square with the group in every currency. bool isSettledUp(String memberId) => !balances.any((balance) => balance.memberId == memberId); @@ -233,7 +245,9 @@ GroupLedger? groupLedger(Ref ref, String groupId) { ], entries: entryList, balances: balances, - transfers: group.simplifyDebts ? simplifyDebts(balances) : const [], + transfers: group.simplifyDebts + ? simplifyDebts(balances) + : pairwiseDebts(entryList), me: memberList.where((m) => m.profileId == accountId).firstOrNull, profiles: profiles, brokenEntries: broken, diff --git a/lib/application/local_providers.dart b/lib/application/local_providers.dart index 25a3545..7016a18 100644 --- a/lib/application/local_providers.dart +++ b/lib/application/local_providers.dart @@ -69,6 +69,7 @@ Stream> failedWrites(Ref ref) => DriftGroupRepository groupRepository(Ref ref) => DriftGroupRepository( ref.watch(appDatabaseProvider), outbox: ref.watch(outboxQueueProvider), + accountId: ref.watch(currentAccountIdProvider), ); @Riverpod(keepAlive: true) diff --git a/lib/application/session_providers.dart b/lib/application/session_providers.dart index 0d7451c..f2518bb 100644 --- a/lib/application/session_providers.dart +++ b/lib/application/session_providers.dart @@ -112,11 +112,7 @@ class AccountController extends _$AccountController { /// How many groups a guest signing in to an existing account brings along. Future groupsToHandOver() async => - (await ref - .read(groupRepositoryProvider) - .watchGroups(includeArchived: true) - .first) - .length; + (await ref.read(groupRepositoryProvider).watchListings().first).length; AuthService _auth() { final auth = ref.read(authServiceProvider); diff --git a/lib/application/settlement_form.dart b/lib/application/settlement_form.dart new file mode 100644 index 0000000..741a07c --- /dev/null +++ b/lib/application/settlement_form.dart @@ -0,0 +1,212 @@ +/// Settle up's state: who pays whom, how much, and where the money goes. +library; + +import 'dart:async'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../data/local/database.dart'; +import '../domain/calendar_date.dart'; +import '../domain/entry_draft.dart'; +import '../domain/money_format.dart'; +import 'ledger_providers.dart'; +import 'local_providers.dart'; + +part 'settlement_form.freezed.dart'; +part 'settlement_form.g.dart'; + +/// A UPI ID somebody typed, and the person it was typed for. +typedef TypedHandle = ({String payeeId, String handle}); + +/// Everything on the settle up screen, exactly as entered. +@freezed +abstract class SettlementFormState with _$SettlementFormState { + const factory SettlementFormState({ + String? fromId, + String? toId, + required String currencyCode, + @Default('') String amount, + + /// A UPI ID typed over the payee's own. It is only ever read back for + /// the payee it was typed for, so choosing somebody else can never hand + /// a payment app the last person's handle. + TypedHandle? typedHandle, + @Default(false) bool saving, + String? error, + }) = _SettlementFormState; + + const SettlementFormState._(); + + /// Where the money goes: whatever was typed for this payee, or else the + /// handle the group knows them by. + String? handleIn(GroupLedger ledger) { + final to = toId; + if (to == null) return null; + if (typedHandle case ( + payeeId: final payee, + :final handle, + ) when payee == to) { + return handle; + } + return knownHandleIn(ledger); + } + + /// The payee's handle as the group knows it. + String? knownHandleIn(GroupLedger ledger) { + final payee = toId == null ? null : ledger.memberById(toId!); + return payee == null ? null : ledger.upiOf(payee); + } + + /// The payment this form describes in [groupId], made [at] in [zone], or + /// what is still missing from it. + DraftCheck check( + Currency currency, { + required String groupId, + required DateTime at, + String? zone, + }) { + final (from, to) = (fromId, toId); + if (from == null || to == null || from == to) { + return const DraftIncomplete('Choose who is paying whom.'); + } + final amountMinor = currency.parseToMinor(amount); + if (amountMinor == null) { + return DraftIncomplete( + currency.exponent == 0 + ? 'Enter a whole amount.' + : 'Enter an amount with at most ${currency.exponent} decimal ' + 'places.', + ); + } + if (amountMinor <= 0) { + return const DraftIncomplete('Enter an amount greater than zero.'); + } + return DraftReady( + EntryDraft.settlement( + groupId: groupId, + currency: currency.code, + amountMinor: amountMinor, + fromMemberId: from, + toMemberId: to, + entryDate: calendarDay(at), + // With no zone known yet, only the day is kept. + occurredAt: zone == null ? null : at.toUtc(), + timeZone: zone, + ), + ); + } +} + +/// Settling up in one group, optionally prefilled with a suggested payment. +/// +/// Seeded once, as soon as the group and the currencies have arrived, and +/// listened to rather than watched after that, as the expense editor is. +@riverpod +class SettlementForm extends _$SettlementForm { + @override + Future build( + String groupId, { + String? fromId, + String? toId, + int? amountMinor, + String? currency, + }) { + final seeded = Completer(); + void seed() { + if (seeded.isCompleted) return; + final ledger = ref.read(groupLedgerProvider(groupId)); + final currencies = ref.read(currenciesProvider).value; + if (ledger == null || currencies == null) return; + + final from = fromId ?? ledger.me?.id; + final code = currency ?? ledger.group.defaultCurrency; + final amount = amountMinor; + seeded.complete( + SettlementFormState( + fromId: from, + toId: toId == from ? null : toId, + currencyCode: code, + amount: amount == null + ? '' + : currencies[code]?.formatPlain(amount) ?? '', + ), + ); + } + + ref.listen(groupLedgerProvider(groupId), (_, _) => seed()); + ref.listen(currenciesProvider, (_, _) => seed()); + seed(); + return seeded.future; + } + + SettlementFormState get _form => state.requireValue; + + void _change(SettlementFormState Function(SettlementFormState form) change) => + state = AsyncData(change(_form).copyWith(error: null)); + + /// Paying yourself is not a payment, so choosing the payee as the payer + /// leaves nobody being paid. + void choosePayer(String? id) => _change( + (form) => form.copyWith( + fromId: id, + toId: id != null && id == form.toId ? null : form.toId, + ), + ); + + void choosePayee(String? id) => _change((form) => form.copyWith(toId: id)); + + void chooseCurrency(String code) => + _change((form) => form.copyWith(currencyCode: code)); + + void setAmount(String text) => _change((form) => form.copyWith(amount: text)); + + /// A UPI ID typed for whoever is being paid now. + void setHandle(String text) => _change((form) { + final to = form.toId; + return to == null + ? form + : form.copyWith(typedHandle: (payeeId: to, handle: text)); + }); + + /// Records the payment, and says whether it was recorded. When it was not, + /// the state says why. + Future record() async { + final form = _form; + final ledger = ref.read(groupLedgerProvider(groupId)); + final currency = ref.read(currenciesProvider).value?[form.currencyCode]; + if (ledger == null || currency == null || form.saving) return false; + + // Paid now, here. Saving never waits to learn where "here" is. + final check = form.check( + currency, + groupId: groupId, + at: DateTime.now(), + zone: ref.read(deviceZoneProvider).value, + ); + final EntryDraft draft; + switch (check) { + case DraftIncomplete(:final reason): + state = AsyncData(form.copyWith(error: reason)); + return false; + case DraftReady(draft: final ready): + draft = ready; + } + + state = AsyncData(form.copyWith(saving: true, error: null)); + try { + await ref + .read(entryRepositoryProvider) + .create( + draft, + createdBy: ledger.me?.id ?? draft.payerAmounts.keys.first, + ); + return true; + } catch (error) { + if (ref.mounted) { + state = AsyncData(_form.copyWith(saving: false, error: '$error')); + } + return false; + } + } +} diff --git a/lib/application/sync_providers.dart b/lib/application/sync_providers.dart index 618f980..2b6c389 100644 --- a/lib/application/sync_providers.dart +++ b/lib/application/sync_providers.dart @@ -16,6 +16,7 @@ SyncEngine? syncEngine(Ref ref) { db: ref.watch(appDatabaseProvider), client: client, outbox: ref.watch(outboxQueueProvider), + accountId: ref.watch(currentAccountIdProvider), ); ref.onDispose(engine.dispose); return engine; diff --git a/lib/data/fx/drift_fx_repository.dart b/lib/data/fx/drift_fx_repository.dart index ff5c628..752a843 100644 --- a/lib/data/fx/drift_fx_repository.dart +++ b/lib/data/fx/drift_fx_repository.dart @@ -10,6 +10,10 @@ class DriftFxRepository { final AppDatabase _db; + static String _daysBefore(String day) => calendarDate( + parseCalendarDate(day).subtract(const Duration(days: rateStaleAfterDays)), + ); + /// The rate for converting [base] into [quote] as it stood on [asOf]. Future quote({ required String base, @@ -49,13 +53,14 @@ class DriftFxRepository { ); } - /// The most recent publication for a currency on or before [day]. + /// The most recent publication for a currency that answers for [day]: on + /// it, or no more than [rateStaleAfterDays] before. Future _rateOn(String currency, String day) => (_db.select(_db.fxRates) ..where( (t) => t.currency.equals(currency) & - t.asOf.isSmallerOrEqualValue(day), + t.asOf.isBetweenValues(_daysBefore(day), day), ) ..orderBy([(t) => OrderingTerm.desc(t.asOf)]) ..limit(1)) diff --git a/lib/data/push/background_handler.dart b/lib/data/push/background_handler.dart index eeebebd..39bfc3d 100644 --- a/lib/data/push/background_handler.dart +++ b/lib/data/push/background_handler.dart @@ -72,6 +72,7 @@ Future handleBackgroundEntryMessage(RemoteMessage message) async { // no cookie jar on Android. client: buildApiClient(baseUrl: apiBaseUrl, token: session.token), outbox: outbox, + accountId: profileId, ); // Sync first. The notification describes what is now on the device, not diff --git a/lib/data/repositories/drift_group_repository.dart b/lib/data/repositories/drift_group_repository.dart index 91da73c..8b81376 100644 --- a/lib/data/repositories/drift_group_repository.dart +++ b/lib/data/repositories/drift_group_repository.dart @@ -7,11 +7,34 @@ import '../local/database.dart'; import '../local/tables.dart'; import '../sync/outbox_queue.dart'; +/// A group as a list of groups shows it. +class GroupListing { + const GroupListing({ + required this.group, + required this.hasLeft, + required this.lastActivityAt, + }); + + final Group group; + + /// Whether this account left the group, or was removed from it. Read from + /// the account's own member row, which the server confirms, rather than + /// from anything this device decided. + final bool hasLeft; + + /// The newest line in the group's activity feed, or when it was made. + final DateTime lastActivityAt; + + /// In the main list: neither put away nor left. + bool get isCurrent => !group.isArchived && !hasLeft; +} + /// Local-first group and membership storage. final class DriftGroupRepository { DriftGroupRepository( this._db, { this.outbox, + this.accountId, Uuid? uuid, DateTime Function()? clock, }) : _uuid = uuid ?? const Uuid(), @@ -19,19 +42,53 @@ final class DriftGroupRepository { final AppDatabase _db; + /// The account this ledger belongs to, which is how a group it has left is + /// told apart. Null in a purely local build, which has no accounts. + final String? accountId; + /// Null in a purely local build, where there is nothing to sync to. final OutboxQueue? outbox; final Uuid _uuid; final DateTime Function() _clock; - /// Groups this user belongs to, newest activity first. - Stream> watchGroups({bool includeArchived = false}) { - final query = _db.select(_db.groups) - ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]); - if (!includeArchived) { - query.where((t) => t.archivedAt.isNull()); - } - return query.watch().map((rows) => [for (final row in rows) row]); + /// Every group on this device, the most recently active first: what a + /// list of groups is ordered by, and where it is put. + Stream> watchListings() { + final groups = _db.groups; + final newestLine = subqueryExpression( + _db.selectOnly(_db.groupEvents) + ..addColumns([_db.groupEvents.createdAt.max()]) + ..where(_db.groupEvents.groupId.equalsExp(groups.id)), + ); + final account = accountId; + final Expression hasLeft = account == null + ? const Constant(false) + : existsQuery( + _db.selectOnly(_db.members) + ..addColumns([_db.members.id]) + ..where( + _db.members.groupId.equalsExp(groups.id) & + _db.members.profileId.equals(account) & + _db.members.leftAt.isNotNull(), + ), + ); + + final query = _db.select(groups).addColumns([newestLine, hasLeft]); + return query.watch().map((rows) { + final listings = [ + for (final row in rows) + GroupListing( + group: row.readTable(groups), + hasLeft: row.read(hasLeft) ?? false, + lastActivityAt: + row.read(newestLine) ?? row.readTable(groups).createdAt, + ), + ]; + // Ordered here rather than in SQL, where an aggregate over a date and + // the date column itself are stored differently and would not compare. + return listings + ..sort((a, b) => b.lastActivityAt.compareTo(a.lastActivityAt)); + }); } Stream watchGroup(String groupId) => (_db.select( @@ -140,19 +197,13 @@ final class DriftGroupRepository { }); } - /// Leaves a group: marks your own member row as having left, and archives the - /// group on this device. - Future leaveGroup({ - required String groupId, - required String memberId, - }) async { - final now = _clock(); + /// Leaves a group by marking your own member row as having left. The group + /// stays on this device, read-only, among the groups put away. The server + /// refuses this while you owe or are owed anything in it. + Future leaveGroup({required String memberId}) async { await _db.transaction(() async { await (_db.update(_db.members)..where((t) => t.id.equals(memberId))) - .write(MembersCompanion(leftAt: Value(now))); - await (_db.update(_db.groups)..where((t) => t.id.equals(groupId))).write( - GroupsCompanion(archivedAt: Value(now)), - ); + .write(MembersCompanion(leftAt: Value(_clock()))); await outbox?.enqueue(OutboxTarget.member, memberId); }); } diff --git a/lib/data/sync/outbox_queue.dart b/lib/data/sync/outbox_queue.dart index ae5ccc1..937e5ed 100644 --- a/lib/data/sync/outbox_queue.dart +++ b/lib/data/sync/outbox_queue.dart @@ -119,6 +119,15 @@ class OutboxQueue { return row == null ? null : row.nextAttemptAt ?? _clock(); } + /// Rows of [target] changed here that the server has not taken yet: + /// queued, backing off, or refused. + Future> unsentIds(OutboxTarget target) async => { + for (final row in await (_db.select( + _db.outbox, + )..where((t) => t.target.equalsValue(target))).get()) + row.targetId, + }; + /// Whether leaving this account would strand an edit held only here. Future hasUnresolvedWrites() async { final pending = await (_db.select(_db.outbox)..limit(1)).get(); diff --git a/lib/data/sync/sync_engine.dart b/lib/data/sync/sync_engine.dart index e77cb04..4886585 100644 --- a/lib/data/sync/sync_engine.dart +++ b/lib/data/sync/sync_engine.dart @@ -62,6 +62,7 @@ class SyncEngine { required this.db, required this.client, required this.outbox, + this.accountId, SyncGate? gate, DateTime Function()? clock, this.pageSize = 200, @@ -81,6 +82,10 @@ class SyncEngine { final AppDatabase db; final api.OpensplitApi client; final OutboxQueue outbox; + + /// The account this ledger belongs to, which is how a group it has left is + /// recognised. Null only where there is no account to ask about. + final String? accountId; final DateTime Function() _clock; final SyncGate _gate; final int pageSize; @@ -111,14 +116,52 @@ class SyncEngine { Future _call(Future> request) => fetch(request).timeout(requestTimeout); - /// The server's groups for this account, plus local ones it has not seen. - /// Throws when the server cannot be asked: the local list alone cannot show - /// that an account has no other groups. + /// The server's groups for this account, plus local ones it has not seen, + /// less those this account has left for good. Throws when the server cannot + /// be asked: the local list alone cannot show that an account has no other + /// groups. Future> discoverGroups() async { final local = await db.select(db.groups).get(); final remote = await _call(client.getSyncApi().listGroups()); - return {for (final row in local) row.id, ...remote.groupIds}.toList() - ..sort(); + final left = await _leftForGood(); + return { + for (final row in local) + if (!left.contains(row.id)) row.id, + ...remote.groupIds, + }.toList()..sort(); + } + + /// Groups this account has left or been removed from, and this device has + /// already read that back from the server. A pull of one can only ever come + /// back empty, so asking again on every sync is a request for nothing. An + /// invite link brings the group back through [ListGroups] once it is joined. + Future> _leftForGood() async { + final account = accountId; + if (account == null) return const {}; + + final departed = + await (db.select(db.members)..where( + (t) => + t.profileId.equals(account) & + t.leftAt.isNotNull() & + t.seq.isNotNull(), + )) + .get(); + if (departed.isEmpty) return const {}; + + final cursors = { + for (final cursor in await db.select(db.groupCursors).get()) + cursor.groupId: cursor.seq, + }; + // Unsent, the leave is this device's word alone, and the server may still + // refuse it. + final unsent = await outbox.unsentIds(OutboxTarget.member); + return { + for (final member in departed) + if (!unsent.contains(member.id) && + (cursors[member.groupId] ?? 0) >= member.seq!) + member.groupId, + }; } Future syncGroup(String groupId) => diff --git a/lib/domain/balance/pairwise.dart b/lib/domain/balance/pairwise.dart new file mode 100644 index 0000000..5320352 --- /dev/null +++ b/lib/domain/balance/pairwise.dart @@ -0,0 +1,97 @@ +import '../models/entry.dart'; +import '../split/allocation.dart'; +import 'simplify.dart'; + +/// What one expense leaves each person owing each payer, keyed by +/// `(debtor, creditor)`. +/// +/// Every share is owed to the payers in proportion to what each of them paid. +/// Shares are taken one at a time, in member order, each divided by the same +/// largest-remainder rule the split uses against what every payer is still +/// owed. So it all comes out in whole minor units, each share is owed in full, +/// and each payer is paid back exactly what they paid, with no unit lost to +/// rounding. Nobody owes themselves. A deleted expense owes nothing, and nor +/// does one that does not add up, which the group screen flags instead. +Map<(String, String), int> debtsWithin(Entry entry) { + final debts = <(String, String), int>{}; + if (entry.isDeleted || !entry.isBalanced) return debts; + + final outstanding = { + for (final payer in entry.payers) payer.memberId: payer.amountMinor, + }; + final shares = [...entry.shares] + ..sort((a, b) => a.memberId.compareTo(b.memberId)); + + for (final share in shares) { + if (share.amountMinor == 0) continue; + final owed = allocateLargestRemainder( + totalMinor: share.amountMinor, + parties: [ + for (final MapEntry(key: payer, value: left) in outstanding.entries) + (memberId: payer, weightMicros: left), + ], + seed: '${entry.id}:${share.memberId}', + ); + for (final MapEntry(key: payer, value: amount) in owed.entries) { + outstanding[payer] = outstanding[payer]! - amount; + if (payer == share.memberId || amount == 0) continue; + final pair = (share.memberId, payer); + debts[pair] = (debts[pair] ?? 0) + amount; + } + } + return debts; +} + +/// What [debtor] owes [creditor] on account of [entry], less what [creditor] +/// owes [debtor] on it. Negative when the expense ran the other way. +int pairDebtWithin( + Entry entry, { + required String debtor, + required String creditor, +}) { + final debts = debtsWithin(entry); + return (debts[(debtor, creditor)] ?? 0) - (debts[(creditor, debtor)] ?? 0); +} + +/// Who owes whom, pair by pair, when a group does not simplify its debts. +/// +/// The debts of every expense between each two people are netted against +/// each other, per currency, and nothing more: a payment is only ever +/// suggested between two people who actually shared an expense. Settling +/// every one of them leaves everybody square, exactly as the fewest payments +/// from [simplifyDebts] would, in more steps. +List pairwiseDebts(Iterable entries) { + final net = <(String, String, String), int>{}; + for (final entry in entries) { + for (final MapEntry(key: (debtor, creditor), value: amount) in debtsWithin( + entry, + ).entries) { + // One key per unordered pair, signed: positive when the first named + // owes the second. + final forward = debtor.compareTo(creditor) < 0; + final key = forward + ? (entry.row.currency, debtor, creditor) + : (entry.row.currency, creditor, debtor); + net[key] = (net[key] ?? 0) + (forward ? amount : -amount); + } + } + + final transfers = [ + for (final MapEntry(key: (currency, first, second), value: amount) + in net.entries) + if (amount != 0) + Transfer( + fromMemberId: amount > 0 ? first : second, + toMemberId: amount > 0 ? second : first, + currency: currency, + amountMinor: amount.abs(), + ), + ]; + return transfers..sort( + (a, b) => [ + a.currency.compareTo(b.currency), + a.fromMemberId.compareTo(b.fromMemberId), + a.toMemberId.compareTo(b.toMemberId), + ].firstWhere((order) => order != 0, orElse: () => 0), + ); +} diff --git a/lib/domain/balance/transfer_basis.dart b/lib/domain/balance/transfer_basis.dart new file mode 100644 index 0000000..6784e9c --- /dev/null +++ b/lib/domain/balance/transfer_basis.dart @@ -0,0 +1,52 @@ +import '../models/entry.dart'; +import 'pairwise.dart'; +import 'simplify.dart'; + +/// One expense behind a suggested payment, and how far it moved what that +/// payment settles: positive when it put the payer in credit. +typedef Contribution = ({Entry entry, int delta}); + +/// The expenses behind [transfer], and the figure they come to. +/// +/// When the group simplifies its debts, a payment settles the payer's whole +/// position, so every expense in its currency that moved the payer counts. +/// Otherwise it settles one pair's debt, and only what passed between those +/// two counts. Either way [net] is the sum of the contributions, from the +/// payer's side, so a person can check the one against the other. +({List contributions, int net}) basisOf( + Transfer transfer, + Iterable entries, { + required bool simplified, +}) { + final contributions = [ + for (final entry in entries) + if (!entry.isDeleted && entry.row.currency == transfer.currency) + ( + entry: entry, + delta: simplified + ? _positionMovedBy(entry, transfer.fromMemberId) + : -pairDebtWithin( + entry, + debtor: transfer.fromMemberId, + creditor: transfer.toMemberId, + ), + ), + ]..removeWhere((contribution) => contribution.delta == 0); + + return ( + contributions: contributions, + net: contributions.fold(0, (sum, item) => sum + item.delta), + ); +} + +/// What [memberId] paid towards [entry], less their share of it. +int _positionMovedBy(Entry entry, String memberId) { + var delta = 0; + for (final payer in entry.payers) { + if (payer.memberId == memberId) delta += payer.amountMinor; + } + for (final share in entry.shares) { + if (share.memberId == memberId) delta -= share.amountMinor; + } + return delta; +} diff --git a/lib/domain/decimal_text.dart b/lib/domain/decimal_text.dart new file mode 100644 index 0000000..2c32b93 --- /dev/null +++ b/lib/domain/decimal_text.dart @@ -0,0 +1,50 @@ +/// Decimal numbers as people type them into a field. +library; + +import 'split/allocation.dart'; + +/// A non-negative decimal as typed, with either `.` or `,` as its point: a +/// phone's number keyboard shows whichever the device's region uses. The +/// whole part is group 1 and the fraction group 2, empty when absent. +/// +/// Matches every prefix of a valid number too, including the empty string, +/// so it can guard a field keystroke by keystroke. +RegExp decimalInputPattern({ + required int wholeDigits, + required int fractionDigits, +}) => RegExp( + fractionDigits == 0 + ? '^(\\d{0,$wholeDigits})()\$' + : '^(\\d{0,$wholeDigits})(?:[.,](\\d{0,$fractionDigits}))?\$', +); + +/// A percentage as typed: up to three whole digits, and six decimals, the +/// precision a share's weight is stored to. +final percentInputPattern = decimalInputPattern( + wholeDigits: 3, + fractionDigits: 6, +); + +/// A typed percentage in millionths, or null for anything that is not one. +int? parsePercentMicros(String text) { + final match = percentInputPattern.firstMatch(text.trim()); + if (match == null) return null; + final whole = match.group(1) ?? ''; + final fraction = match.group(2) ?? ''; + if (whole.isEmpty && fraction.isEmpty) return null; + return (whole.isEmpty ? 0 : int.parse(whole)) * weightScale + + (fraction.isEmpty ? 0 : int.parse(fraction.padRight(6, '0'))); +} + +/// A percentage in millionths as somebody would type it: `12.5`, not +/// `12.500000`. +String formatPercentMicros(int micros) { + final whole = micros ~/ weightScale; + final fraction = micros % weightScale; + if (fraction == 0) return '$whole'; + final decimals = fraction + .toString() + .padLeft(6, '0') + .replaceAll(RegExp(r'0+$'), ''); + return '$whole.$decimals'; +} diff --git a/lib/domain/entry_draft.dart b/lib/domain/entry_draft.dart index 9970a4f..b2178cb 100644 --- a/lib/domain/entry_draft.dart +++ b/lib/domain/entry_draft.dart @@ -79,6 +79,23 @@ class EntryDraft { final String? fxSource; } +/// Whether a form describes an entry yet. +sealed class DraftCheck { + const DraftCheck(); +} + +/// It does. +final class DraftReady extends DraftCheck { + const DraftReady(this.draft); + final EntryDraft draft; +} + +/// Not yet, and why, in words for the person filling it in. +final class DraftIncomplete extends DraftCheck { + const DraftIncomplete(this.reason); + final String reason; +} + /// Turns a [draft] into a balanced [Entry]. Entry composeEntry( EntryDraft draft, { diff --git a/lib/domain/fx/fx_quote.dart b/lib/domain/fx/fx_quote.dart index a2536b8..54c7d2a 100644 --- a/lib/domain/fx/fx_quote.dart +++ b/lib/domain/fx/fx_quote.dart @@ -2,6 +2,13 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'fx_quote.freezed.dart'; +/// How many days old a rate may be and still answer for a day. A week covers +/// every weekend and every ECB holiday; older than that, the rate describes +/// some other time. The server holds the same rule as `STALE_AFTER_DAYS` in +/// `server/src/do/fx/index.ts`, so the days it fills are the days this asks +/// for. +const rateStaleAfterDays = 7; + /// A rate for converting [base] into [quote], as published on [date]. @freezed abstract class FxQuote with _$FxQuote { diff --git a/lib/domain/money_format.dart b/lib/domain/money_format.dart index d5c2e69..0bb4f84 100644 --- a/lib/domain/money_format.dart +++ b/lib/domain/money_format.dart @@ -1,4 +1,5 @@ import '../data/local/database.dart'; +import 'decimal_text.dart'; /// Currencies conventionally grouped in the Indian system — the last three /// digits, then pairs: 1,23,45,678 rather than 12,345,678. @@ -90,24 +91,35 @@ extension CurrencyAmounts on Currency { } /// Parses user input in major units into minor units. + /// + /// The decimal point may be `.` or `,`, whichever the keyboard offered. + /// Grouping separators are not accepted, since `1,250` would otherwise mean + /// one thing with a dot keyboard and another with a comma keyboard. Null for + /// anything else, including more decimals than this currency has or more + /// digits than [maxAmountDigits]. int? parseToMinor(String input) { - final trimmed = input.trim().replaceAll(',', ''); - if (trimmed.isEmpty) return null; - - final match = RegExp(r'^(-)?(\d*)(?:\.(\d*))?$').firstMatch(trimmed); + final match = amountInputPattern(exponent).firstMatch(input.trim()); if (match == null) return null; - final sign = match.group(1) == null ? 1 : -1; - final majorText = match.group(2) ?? ''; - final fractionText = match.group(3) ?? ''; + final majorText = match.group(1) ?? ''; + final fractionText = match.group(2) ?? ''; if (majorText.isEmpty && fractionText.isEmpty) return null; - if (fractionText.length > exponent) return null; final major = majorText.isEmpty ? 0 : int.parse(majorText); final fraction = fractionText.isEmpty ? 0 : int.parse(fractionText.padRight(exponent, '0')); - - return sign * (major * minorPerMajor + fraction); + return major * minorPerMajor + fraction; } } + +/// Whole units an amount may have: a trillion, far past any shared bill, and +/// short enough that the minor units stay exact as a JavaScript number on the +/// web as well as in a 64-bit integer. +const maxAmountDigits = 12; + +/// An amount as typed: digits, then optionally `.` or `,` and up to +/// [exponent] more. Matches every prefix of a valid amount too, including the +/// empty string, so it can guard a field keystroke by keystroke. +RegExp amountInputPattern(int exponent) => + decimalInputPattern(wholeDigits: maxAmountDigits, fractionDigits: exponent); diff --git a/lib/presentation/amount_input.dart b/lib/presentation/amount_input.dart new file mode 100644 index 0000000..20ee937 --- /dev/null +++ b/lib/presentation/amount_input.dart @@ -0,0 +1,27 @@ +import 'package:flutter/services.dart'; + +import '../data/local/database.dart'; +import '../domain/money_format.dart'; + +/// Keeps a number field to something its parser reads: digits, one decimal +/// point, and no more digits either side than the value allows. +/// +/// The point may be typed as `.` or `,`, because a phone's number keyboard +/// shows whichever the device's region uses. A keystroke that would make the +/// text unreadable is refused, so the field never holds something that +/// silently means another number. +class DecimalInputFormatter extends TextInputFormatter { + DecimalInputFormatter(this._shape); + + /// An amount in [currency], as [CurrencyAmounts.parseToMinor] reads it. + DecimalInputFormatter.amount(Currency? currency) + : _shape = amountInputPattern(currency?.exponent ?? 2); + + final RegExp _shape; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) => _shape.hasMatch(newValue.text) ? newValue : oldValue; +} diff --git a/lib/presentation/screens/archived_groups_screen.dart b/lib/presentation/screens/archived_groups_screen.dart index 8c7b146..f7c413a 100644 --- a/lib/presentation/screens/archived_groups_screen.dart +++ b/lib/presentation/screens/archived_groups_screen.dart @@ -5,22 +5,22 @@ import 'package:intl/intl.dart'; import '../../application/ledger_providers.dart'; import '../../application/local_providers.dart'; -import '../../data/local/database.dart'; +import '../../data/repositories/drift_group_repository.dart'; import '../navigation.dart'; import '../widgets/empty_state.dart'; import '../widgets/page_body.dart'; -/// Groups that have been put away, and the way back. +/// Groups that have been put away or left, and the way back. class ArchivedGroupsScreen extends ConsumerWidget { const ArchivedGroupsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final groups = ref.watch(groupsProvider(includeArchived: true)).value; + final listings = ref.watch(groupListingsProvider).value; final archived = [ - for (final group in groups ?? const []) - if (group.isArchived) group, - ]..sort((a, b) => b.archivedAt!.compareTo(a.archivedAt!)); + for (final listing in listings ?? const []) + if (!listing.isCurrent) listing, + ]; return Scaffold( appBar: AppBar( @@ -36,7 +36,7 @@ class ArchivedGroupsScreen extends ConsumerWidget { separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, index) => index == 0 ? const _Explanation() - : _ArchivedTile(group: archived[index - 1]), + : _ArchivedTile(listing: archived[index - 1]), ), ), ); @@ -51,7 +51,9 @@ class _Explanation extends StatelessWidget { padding: const EdgeInsets.only(bottom: 8), child: Text( 'These are out of the way, not gone. Everything in them still adds up, ' - 'and adding an expense brings one back by itself.', + 'and adding an expense brings one back by itself. Groups you left are ' + 'here too, read-only: a link from somebody still in one brings you ' + 'back.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), @@ -60,13 +62,14 @@ class _Explanation extends StatelessWidget { } class _ArchivedTile extends ConsumerWidget { - const _ArchivedTile({required this.group}); + const _ArchivedTile({required this.listing}); - final Group group; + final GroupListing listing; @override Widget build(BuildContext context, WidgetRef ref) { final scheme = Theme.of(context).colorScheme; + final group = listing.group; return Card.outlined( child: ListTile( @@ -80,14 +83,19 @@ class _ArchivedTile extends ConsumerWidget { ), title: Text(group.name), subtitle: Text( - 'Archived ${DateFormat.yMMMd().format(group.archivedAt!.toLocal())}', - ), - trailing: TextButton( - onPressed: () => ref - .read(groupRepositoryProvider) - .setArchived(group.id, archived: false), - child: const Text('Restore'), + listing.hasLeft + // A link from somebody still in it is the way back, not a button. + ? 'You left · read-only' + : 'Archived ${DateFormat.yMMMd().format(group.archivedAt!.toLocal())}', ), + trailing: listing.hasLeft + ? null + : TextButton( + onPressed: () => ref + .read(groupRepositoryProvider) + .setArchived(group.id, archived: false), + child: const Text('Restore'), + ), ), ); } diff --git a/lib/presentation/screens/entry_editor_screen.dart b/lib/presentation/screens/entry_editor_screen.dart index fc726c1..c60a236 100644 --- a/lib/presentation/screens/entry_editor_screen.dart +++ b/lib/presentation/screens/entry_editor_screen.dart @@ -1,33 +1,30 @@ -import 'dart:async'; - import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:opensplit_api/opensplit_api.dart' show SplitKind; +import '../../application/entry_form.dart'; import '../../application/ledger_providers.dart'; import '../../application/local_providers.dart'; import '../../application/sync_providers.dart'; import '../../data/local/database.dart'; import '../../domain/activity/activity_text.dart'; -import '../../domain/calendar_date.dart'; -import '../../domain/category_guess.dart'; -import '../../domain/entry_draft.dart'; -import '../../domain/fx/fx_quote.dart'; -import '../../domain/models/entry.dart'; +import '../../domain/decimal_text.dart'; import '../../domain/models/group_event.dart'; import '../../domain/money_format.dart'; -import '../../domain/split/allocation.dart'; -import '../../domain/split/splitter.dart'; +import '../amount_input.dart'; import '../feedback.dart'; import '../navigation.dart'; import '../theme.dart'; -import '../widgets/category_icon.dart'; import '../widgets/avatar_view.dart'; +import '../widgets/category_icon.dart'; import '../widgets/page_body.dart'; /// Creates or edits an expense. -class EntryEditorScreen extends ConsumerStatefulWidget { +/// +/// Holds no state of its own: what has been typed lives in [EntryForm], and +/// every field reports a change there rather than keeping a copy here. +class EntryEditorScreen extends ConsumerWidget { const EntryEditorScreen({super.key, required this.groupId, this.entryId}); final String groupId; @@ -36,371 +33,43 @@ class EntryEditorScreen extends ConsumerStatefulWidget { bool get isEditing => entryId != null; @override - ConsumerState createState() => _EntryEditorScreenState(); -} - -class _EntryEditorScreenState extends ConsumerState { - final _description = TextEditingController(); - final _amount = TextEditingController(); - - /// Per-member text input for exact amounts, percentages and payer amounts. - final _exact = {}; - final _percent = {}; - final _payerAmounts = {}; - - String? _currencyCode; - String? _categoryId; - - /// Whether the category was picked by hand, or came with the expense being - /// edited. Until then it follows the description. - bool _categoryChosen = false; - - /// The day it happened, and when on that day if known, with the zone it - /// happened in. Times are shown and picked on this device's clock. - late DateTime _date; - DateTime? _occurredAt; - String? _zone; - SplitKind _splitKind = SplitKind.equal; - - /// Who is in the split, who paid, and the relative weights. - /// - /// Replaced rather than mutated, so only this state object changes them. - Set _participants = {}; - Map _shares = {}; - Set _payers = {}; - - bool _multiplePayers = false; - bool _loaded = false; - Entry? _editingSnapshot; - bool _saving = false; - String? _error; - - @override - void initState() { - super.initState(); - final now = DateTime.now(); - _date = now; - _occurredAt = now.toUtc(); - // Seeding the form is initialisation from asynchronous data, so it listens - // instead of running inside build. - ref.listenManual( - groupLedgerProvider(widget.groupId), - (_, _) => _seedWhenReady(), - fireImmediately: true, - ); - ref.listenManual(currenciesProvider, (_, _) => _seedWhenReady()); - } - - @override - void dispose() { - _description.dispose(); - _amount.dispose(); - for (final c in _exact.values) { - c.dispose(); - } - for (final c in _percent.values) { - c.dispose(); - } - for (final c in _payerAmounts.values) { - c.dispose(); - } - super.dispose(); - } - - TextEditingController _controllerFor( - Map map, - String id, - ) => map.putIfAbsent(id, TextEditingController.new); - - /// Keeps the category in step with [description] until one is chosen. - /// - /// A guess that stops matching is taken back, so typing "Uber" and then - /// correcting it to "Usha's gift" doesn't leave the expense a taxi ride. - void _guessCategory(String description) { - if (_categoryChosen) return; - final icon = guessCategoryIcon(description); - final categories = ref.read(categoriesProvider).value ?? const []; - final guess = categories.where((c) => c.icon == icon).firstOrNull?.id; - if (guess != _categoryId) setState(() => _categoryId = guess); - } - - /// Seeds the form as soon as everything it needs has arrived. - void _seedWhenReady() { - if (_loaded) return; - - final ledger = ref.read(groupLedgerProvider(widget.groupId)); - final currencies = ref.read(currenciesProvider).value; - if (ledger == null || currencies == null) return; - - final existing = widget.isEditing - ? ledger.entries.where((e) => e.id == widget.entryId).firstOrNull - : null; - if (widget.isEditing && existing == null) return; - - _load(ledger, existing, currencies); - } - - void _load(GroupLedger ledger, Entry? existing, Map cx) { - _loaded = true; - _editingSnapshot = existing; - - _currencyCode = existing?.row.currency ?? ledger.group.defaultCurrency; - - if (existing == null) { - // Everyone splits, the person adding it paid. The overwhelmingly common - // case, pre-filled so the fast path is two fields and a button. - _participants = {for (final member in ledger.members) member.id}; - final me = ledger.me?.id ?? ledger.members.firstOrNull?.id; - if (me != null) _payers = {me}; - return; - } - - _description.text = existing.row.description; - _categoryId = existing.row.categoryId; - _categoryChosen = _categoryId != null; - _date = existing.row.entryDate; - _occurredAt = existing.row.occurredAt; - _zone = existing.row.timeZone; - _splitKind = existing.row.splitKind; - final currency = cx[existing.row.currency]; - if (currency != null) { - _amount.text = currency.formatPlain(existing.row.amountMinor); - } - - _participants = {for (final share in existing.shares) share.memberId}; - _payers = {for (final payer in existing.payers) payer.memberId}; - _multiplePayers = existing.payers.length > 1; - - _shares = {}; - for (final share in existing.shares) { - if (currency != null) { - _controllerFor(_exact, share.memberId).text = currency.formatPlain( - share.amountMinor, - ); - } - final weight = share.weightMicros; - if (weight != null) { - _shares[share.memberId] = (weight / weightScale).round(); - _controllerFor(_percent, share.memberId).text = _microsToText(weight); - } - } - for (final payer in existing.payers) { - if (currency != null) { - _controllerFor(_payerAmounts, payer.memberId).text = currency - .formatPlain(payer.amountMinor); - } - } - } - - static String _microsToText(int micros) { - final whole = micros ~/ weightScale; - final frac = micros % weightScale; - if (frac == 0) return '$whole'; - final decimals = frac - .toString() - .padLeft(6, '0') - .replaceAll(RegExp(r'0+$'), ''); - return '$whole.$decimals'; - } - - /// Parses a decimal string into units of 10^-6, the precision the weight - /// column stores. - static int? _textToMicros(String text) { - final trimmed = text.trim(); - if (trimmed.isEmpty) return null; - final match = RegExp(r'^(\d*)(?:\.(\d{0,6}))?$').firstMatch(trimmed); - if (match == null) return null; - final whole = match.group(1) ?? ''; - final frac = match.group(2) ?? ''; - if (whole.isEmpty && frac.isEmpty) return null; - final wholeValue = whole.isEmpty ? 0 : int.parse(whole); - final fracValue = frac.isEmpty ? 0 : int.parse(frac.padRight(6, '0')); - return wholeValue * weightScale + fracValue; - } - - SplitSpec? _buildSplit(Currency currency, int totalMinor) { - final ids = _participants.toList(); - if (ids.isEmpty) return null; - - switch (_splitKind) { - case SplitKind.equal: - return EqualSplit(ids); - - case SplitKind.exact: - final amounts = {}; - for (final id in ids) { - final parsed = currency.parseToMinor(_exact[id]?.text ?? ''); - if (parsed == null) return null; - amounts[id] = parsed; - } - return ExactSplit(amounts); - - case SplitKind.shares: - return SharesSplit({for (final id in ids) id: _shares[id] ?? 1}); - - case SplitKind.percent: - final percents = {}; - for (final id in ids) { - final parsed = _textToMicros(_percent[id]?.text ?? ''); - if (parsed == null) return null; - percents[id] = parsed; - } - return PercentSplit(percents); - - // A split rule from a newer server: saving would have to guess it. - case SplitKind.unknownDefaultOpenApi: - return null; - } - } - - Map? _buildPayers(Currency currency, int totalMinor) { - if (!_multiplePayers) { - final only = _payers.firstOrNull; - return only == null ? null : {only: totalMinor}; - } - final amounts = {}; - for (final id in _payers) { - final parsed = currency.parseToMinor(_payerAmounts[id]?.text ?? ''); - if (parsed == null || parsed <= 0) return null; - amounts[id] = parsed; - } - return amounts.isEmpty ? null : amounts; - } - - Future _save(GroupLedger ledger, Currency currency, FxQuote? fx) async { - setState(() => _error = null); - - final totalMinor = currency.parseToMinor(_amount.text); - if (totalMinor == null || totalMinor <= 0) { - setState( - () => _error = currency.exponent == 0 - ? 'Enter a whole amount.' - : 'Enter an amount with at most ${currency.exponent} decimal ' - 'places.', - ); - return; - } - - final split = _buildSplit(currency, totalMinor); - final payers = _buildPayers(currency, totalMinor); - if (split == null) { - setState(() => _error = 'Check the split — some amounts are missing.'); - return; - } - if (payers == null) { - setState(() => _error = 'Check who paid — some amounts are missing.'); - return; - } - - final moment = _moment(); - final draft = EntryDraft( - groupId: widget.groupId, - currency: currency.code, - amountMinor: totalMinor, - description: _description.text.trim(), - categoryId: _categoryId, - split: split, - payerAmounts: payers, - entryDate: calendarDay(_date), - occurredAt: moment?.at, - timeZone: moment?.zone, - // A fact about the transaction, captured once. - fxRate: fx?.rate, - fxSource: fx == null ? null : '${fx.source}@${calendarDate(fx.date)}', - ); + Widget build(BuildContext context, WidgetRef ref) { + final ledger = ref.watch(groupLedgerProvider(groupId)); + final form = ref.watch(entryFormProvider(groupId, entryId: entryId)).value; - setState(() => _saving = true); - try { - final repository = ref.read(entryRepositoryProvider); - if (widget.isEditing) { - // Who is editing, which need not be who created it — that difference is - // most of what makes a feed line worth reading. - await repository.update( - widget.entryId!, - draft, - actorId: ledger.me?.id, - expected: _editingSnapshot, - ); - } else { - await repository.create( - draft, - createdBy: ledger.me?.id ?? payers.keys.first, - ); - } - if (!mounted) return; - goBack(context, '/g/${widget.groupId}'); - } on SplitException catch (e) { - // The domain refused it, so nothing was written. Its messages are written - // for people, so they are shown as-is. - if (mounted) setState(() => _error = e.message); - } catch (error) { - if (mounted) setState(() => _error = '$error'); - } finally { - if (mounted) setState(() => _saving = false); + if (ledger == null || form == null) { + return Scaffold(appBar: AppBar(leading: const BackButton())); } - } - - /// The moment to save. A time with no zone yet (a new expense) is this - /// device's. Saving never waits to learn which that is: with no zone known - /// yet, only the day is kept. - ({DateTime at, String zone})? _moment() { - final at = _occurredAt; - final zone = _zone ?? ref.read(deviceZoneProvider).value; - return at == null || zone == null ? null : (at: at, zone: zone); - } - - Future _pickDate() async { - final picked = await showDatePicker( - context: context, - initialDate: _date, - firstDate: DateTime(2000), - lastDate: DateTime.now().add(const Duration(days: 365)), - ); - if (picked == null || DateUtils.isSameDay(picked, _date)) return; - setState(() { - _date = picked; - _occurredAt = null; - _zone = null; - }); - } - - /// A time picked on this device's clock happened in this device's zone. - Future _pickTime(String device) async { - final picked = await showTimePicker( - context: context, - initialTime: TimeOfDay.fromDateTime( - _occurredAt?.toLocal() ?? DateTime.now(), + return _Editor( + ledger: ledger, + form: form, + controller: ref.read( + entryFormProvider(groupId, entryId: entryId).notifier, ), ); - if (picked == null || !mounted) return; - setState(() { - _occurredAt = DateTime( - _date.year, - _date.month, - _date.day, - picked.hour, - picked.minute, - ).toUtc(); - _zone = device; - }); } +} - /// Dates already asked for, so a rebuild does not re-ask. - final _requestedRates = {}; +class _Editor extends ConsumerWidget { + const _Editor({ + required this.ledger, + required this.form, + required this.controller, + }); + + final GroupLedger ledger; + final EntryFormState form; + final EntryForm controller; - void _requestRate(DateTime asOf, String currency) { - final day = calendarDate(asOf); - if (!_requestedRates.add('$day|$currency')) return; + String get _groupPath => '/g/${ledger.group.id}'; - // Not awaited: this must not delay a frame or a save. - ref - .read(syncEngineProvider) - ?.shared - .requestBackfill(asOf, currency) - .ignore(); + Future _save(BuildContext context) async { + if (await controller.save() && context.mounted) { + goBack(context, _groupPath); + } } - Future _delete() async { + Future _delete(BuildContext context) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( @@ -425,72 +94,61 @@ class _EntryEditorScreenState extends ConsumerState { ), ); if (!(confirmed ?? false)) return; - - try { - await ref - .read(entryRepositoryProvider) - .delete( - widget.entryId!, - actorId: ref.read(groupLedgerProvider(widget.groupId))?.me?.id, - expected: _editingSnapshot, - ); - if (mounted) goBack(context, '/g/${widget.groupId}'); - } catch (error) { - if (mounted) setState(() => _error = '$error'); + if (await controller.delete() && context.mounted) { + goBack(context, _groupPath); } } + Future _pickDay(BuildContext context) async { + final day = form.day; + final picked = await showDatePicker( + context: context, + initialDate: DateTime(day.year, day.month, day.day), + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) controller.chooseDay(picked); + } + + /// A time picked on this device's clock happened in this device's zone. + Future _pickTime(BuildContext context, String device) async { + final picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime( + form.occurredAt?.toLocal() ?? DateTime.now(), + ), + ); + if (picked == null) return; + controller.chooseTime( + hour: picked.hour, + minute: picked.minute, + zone: device, + ); + } + @override - Widget build(BuildContext context) { - final ledger = ref.watch(groupLedgerProvider(widget.groupId)); + Widget build(BuildContext context, WidgetRef ref) { final device = ref.watch(deviceZoneProvider).value; final currencies = ref.watch(currenciesProvider).value ?? const {}; - - if (ledger == null) { - return Scaffold(appBar: AppBar(leading: const BackButton())); - } - - final currency = currencies[_currencyCode]; - final totalMinor = currency?.parseToMinor(_amount.text); - - // The rate as it stood on the ENTRY's date, not today's. - final target = ledger.group.defaultCurrency; - FxQuote? fx; - if (currency != null && currency.code != target) { - final day = DateTime.utc(_date.year, _date.month, _date.day); - final quote = fxQuoteProvider(currency.code, target, day); - fx = ref.watch(quote).value; - - // Nothing local can price this date, so ask the server: the rate lands on - // a later sync and the entry saves without a snapshot in the meantime, - // which the schema allows. - final code = currency.code; - ref.listen(quote, (_, next) { - if (next.isLoading || next.value != null) return; - _requestRate(day, code); - }); - } - + final currency = currencies[form.currencyCode]; final textTheme = Theme.of(context).textTheme; - final ready = !_saving && currency != null; + final ready = !form.saving && currency != null; return Scaffold( appBar: AppBar( - leading: CloseButton( - onPressed: () => goBack(context, '/g/${widget.groupId}'), - ), - title: Text(widget.isEditing ? 'Edit expense' : 'Add expense'), + leading: CloseButton(onPressed: () => goBack(context, _groupPath)), + title: Text(form.isEditing ? 'Edit expense' : 'Add expense'), actions: [ - if (widget.isEditing) + if (form.isEditing) IconButton( tooltip: 'Delete', - onPressed: _delete, + onPressed: () => _delete(context), icon: const Icon(Icons.delete_outline), ), Padding( padding: const EdgeInsets.only(right: 8), child: FilledButton( - onPressed: ready ? () => _save(ledger, currency, fx) : null, + onPressed: ready ? () => _save(context) : null, child: const Text('Save'), ), ), @@ -506,17 +164,18 @@ class _EntryEditorScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.center, children: [ _CurrencyButton( - code: _currencyCode ?? ledger.group.defaultCurrency, - onChanged: (code) => setState(() => _currencyCode = code), + code: form.currencyCode, + onChanged: controller.chooseCurrency, ), const SizedBox(width: 16), Expanded( - child: TextField( - controller: _amount, - autofocus: !widget.isEditing, + child: TextFormField( + initialValue: form.amount, + autofocus: !form.isEditing, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), + inputFormatters: [DecimalInputFormatter.amount(currency)], textInputAction: TextInputAction.next, style: moneyStyle(textTheme.displaySmall!), decoration: InputDecoration( @@ -529,7 +188,7 @@ class _EntryEditorScreenState extends ConsumerState { : '${currency!.symbol} ', border: InputBorder.none, ), - onChanged: (_) => setState(() {}), + onChanged: controller.setAmount, ), ), ], @@ -545,14 +204,14 @@ class _EntryEditorScreenState extends ConsumerState { ), ), const SizedBox(height: 16), - TextField( - controller: _description, + TextFormField( + initialValue: form.description, textCapitalization: TextCapitalization.sentences, decoration: const InputDecoration( labelText: 'What was it?', hintText: 'Dinner at Toit', ), - onChanged: _guessCategory, + onChanged: controller.setDescription, ), const SizedBox(height: 16), // The details most expenses leave as they are, as chips rather @@ -562,25 +221,21 @@ class _EntryEditorScreenState extends ConsumerState { runSpacing: 8, children: [ _CategoryChip( - value: _categoryId, - onChanged: (id) => setState(() { - _categoryId = id; - _categoryChosen = true; - }), + value: form.categoryId, + onChanged: controller.chooseCategory, ), ActionChip( avatar: const Icon(Icons.event_outlined), - label: Text(_dayName(_date)), + label: Text(_dayName(form.day)), tooltip: 'Change the day', - onPressed: _pickDate, + onPressed: () => _pickDay(context), ), _TimeChip( - shown: _occurredAt?.toLocal(), - onPick: device == null ? null : () => _pickTime(device), - onClear: () => setState(() { - _occurredAt = null; - _zone = null; - }), + shown: form.occurredAt?.toLocal(), + onPick: device == null + ? null + : () => _pickTime(context, device), + onClear: controller.clearTime, ), ], ), @@ -588,42 +243,24 @@ class _EntryEditorScreenState extends ConsumerState { _PayerSection( ledger: ledger, currency: currency, - payers: _payers, - multiple: _multiplePayers, - controllerFor: (id) => _controllerFor(_payerAmounts, id), - onToggleMultiple: (value) => setState(() { - _multiplePayers = value; - if (!value && _payers.length > 1) { - _payers = {_payers.first}; - } - }), - onPayersChanged: (next) => setState(() => _payers = next), - onAmountEdited: () => setState(() {}), + form: form, + controller: controller, ), const SizedBox(height: 32), _SplitSection( ledger: ledger, currency: currency, - totalMinor: totalMinor, - splitKind: _splitKind, - participants: _participants, - shares: _shares, - exactControllerFor: (id) => _controllerFor(_exact, id), - percentControllerFor: (id) => _controllerFor(_percent, id), - onKindChanged: (kind) => setState(() => _splitKind = kind), - onParticipantsChanged: (next) => - setState(() => _participants = next), - onSharesChanged: (next) => setState(() => _shares = next), - onAmountEdited: () => setState(() {}), + form: form, + controller: controller, ), - if (_error != null) ...[ + if (form.error case final error?) ...[ const SizedBox(height: 16), Card.filled( color: Theme.of(context).colorScheme.errorContainer, child: Padding( padding: const EdgeInsets.all(12), child: Text( - _error!, + error, style: textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onErrorContainer, ), @@ -631,9 +268,9 @@ class _EntryEditorScreenState extends ConsumerState { ), ), ], - if (widget.isEditing) ...[ + if (form.editing case final editing?) ...[ const SizedBox(height: 32), - _History(entryId: widget.entryId!, ledger: ledger), + _History(entryId: editing.id, ledger: ledger), ], ], ), @@ -641,14 +278,14 @@ class _EntryEditorScreenState extends ConsumerState { ); } - /// "Today", "Yesterday", or the date. + /// "Today", "Yesterday", or the date, for a day stored at UTC midnight. static String _dayName(DateTime day) { - final today = DateUtils.dateOnly(DateTime.now()); - final daysAgo = today.difference(DateUtils.dateOnly(day)).inDays; + final local = DateTime(day.year, day.month, day.day); + final daysAgo = DateUtils.dateOnly(DateTime.now()).difference(local).inDays; return switch (daysAgo) { 0 => 'Today', 1 => 'Yesterday', - _ => DateFormat.yMMMEd().format(day), + _ => DateFormat.yMMMEd().format(local), }; } } @@ -689,39 +326,14 @@ class _PayerSection extends StatelessWidget { const _PayerSection({ required this.ledger, required this.currency, - required this.payers, - required this.multiple, - required this.controllerFor, - required this.onToggleMultiple, - required this.onPayersChanged, - required this.onAmountEdited, + required this.form, + required this.controller, }); final GroupLedger ledger; final Currency? currency; - - /// Who paid, to render. Read only: a new selection goes back up through - /// [onPayersChanged] rather than being edited in place. - final Set payers; - - final bool multiple; - final TextEditingController Function(String) controllerFor; - final ValueChanged onToggleMultiple; - final ValueChanged> onPayersChanged; - - /// An amount was typed. The controllers belong to the editor, and the field - /// has already written to one, so this only asks for a rebuild. - final VoidCallback onAmountEdited; - - Set _with(String memberId, {required bool selected}) { - final next = {...payers}; - if (selected) { - next.add(memberId); - } else { - next.remove(memberId); - } - return next; - } + final EntryFormState form; + final EntryForm controller; @override Widget build(BuildContext context) { @@ -731,11 +343,11 @@ class _PayerSection extends StatelessWidget { _SectionTitle( 'Paid by', action: TextButton( - onPressed: () => onToggleMultiple(!multiple), - child: Text(multiple ? 'One person' : 'Several people'), + onPressed: () => controller.setSeveralPayers(!form.severalPayers), + child: Text(form.severalPayers ? 'One person' : 'Several people'), ), ), - if (!multiple) + if (!form.severalPayers) Wrap( spacing: 8, runSpacing: 8, @@ -754,8 +366,8 @@ class _PayerSection extends StatelessWidget { ledger.nameOfMember(member) + (member.id == ledger.me?.id ? ' (you)' : ''), ), - selected: payers.contains(member.id), - onSelected: (_) => onPayersChanged({member.id}), + selected: form.payers.contains(member.id), + onSelected: (_) => controller.choosePayer(member.id), ), ], ) @@ -769,26 +381,32 @@ class _PayerSection extends StatelessWidget { child: CheckboxListTile( contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, - value: payers.contains(member.id), + value: form.payers.contains(member.id), title: Text(ledger.nameOfMember(member)), - onChanged: (checked) => onPayersChanged( - _with(member.id, selected: checked ?? false), + onChanged: (checked) => controller.setPaying( + member.id, + paying: checked ?? false, ), ), ), SizedBox( width: 110, - child: TextField( - controller: controllerFor(member.id), - enabled: payers.contains(member.id), + child: TextFormField( + key: ValueKey(('paid', member.id)), + initialValue: form.paidAmounts[member.id], + enabled: form.payers.contains(member.id), keyboardType: const TextInputType.numberWithOptions( decimal: true, ), + inputFormatters: [ + DecimalInputFormatter.amount(currency), + ], decoration: InputDecoration( isDense: true, prefixText: currency?.symbol, ), - onChanged: (_) => onAmountEdited(), + onChanged: (text) => + controller.setPaidAmount(member.id, text), ), ), ], @@ -812,71 +430,19 @@ class _SplitSection extends StatelessWidget { const _SplitSection({ required this.ledger, required this.currency, - required this.totalMinor, - required this.splitKind, - required this.participants, - required this.shares, - required this.exactControllerFor, - required this.percentControllerFor, - required this.onKindChanged, - required this.onParticipantsChanged, - required this.onSharesChanged, - required this.onAmountEdited, + required this.form, + required this.controller, }); final GroupLedger ledger; final Currency? currency; - final int? totalMinor; - final SplitKind splitKind; - - /// Who is in the split and their relative weights, to render. Both read only: - /// a new value goes back up through the callbacks below. - final Set participants; - final Map shares; - - final TextEditingController Function(String) exactControllerFor; - final TextEditingController Function(String) percentControllerFor; - final ValueChanged onKindChanged; - final ValueChanged> onParticipantsChanged; - final ValueChanged> onSharesChanged; - - /// An amount or a percentage was typed. The controllers belong to the editor, - /// so this only asks for a rebuild of the preview. - final VoidCallback onAmountEdited; - - /// The weight for [memberId], nudged by [by] and never below zero. - Map _nudge(String memberId, int by) { - final next = {...shares}; - next[memberId] = ((next[memberId] ?? 1) + by).clamp(0, 1 << 30); - return next; - } - - /// A live preview of what each person ends up owing. - Map? _preview() { - if (currency == null || totalMinor == null || totalMinor! <= 0) return null; - if (participants.isEmpty) return null; - - try { - final spec = switch (splitKind) { - SplitKind.equal => EqualSplit(participants.toList()), - SplitKind.shares => SharesSplit({ - for (final id in participants) id: shares[id] ?? 1, - }), - _ => null, - }; - if (spec == null) return null; - return { - for (final share in spec.resolve(totalMinor!)) - share.memberId: share.amountMinor, - }; - } on SplitException { - return null; - } - } + final EntryFormState form; + final EntryForm controller; @override Widget build(BuildContext context) { - final preview = _preview(); + final currency = this.currency; + final preview = currency == null ? null : form.preview(currency); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -889,8 +455,8 @@ class _SplitSection extends StatelessWidget { ButtonSegment(value: SplitKind.shares, label: Text('Shares')), ButtonSegment(value: SplitKind.percent, label: Text('%')), ], - selected: {splitKind}, - onSelectionChanged: (set) => onKindChanged(set.first), + selected: {form.splitKind}, + onSelectionChanged: (set) => controller.chooseSplitKind(set.first), showSelectedIcon: false, ), const SizedBox(height: 8), @@ -901,58 +467,62 @@ class _SplitSection extends StatelessWidget { child: CheckboxListTile( contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, - value: participants.contains(member.id), + value: form.participants.contains(member.id), title: Text( ledger.nameOfMember(member) + (member.id == ledger.me?.id ? ' (you)' : ''), ), - subtitle: preview != null && participants.contains(member.id) - ? Text( - formatMoney(currency, preview[member.id] ?? 0), - style: moneyStyle( - Theme.of(context).textTheme.bodySmall!, - ), - ) - : null, - onChanged: (checked) { - final next = {...participants}; - if (checked ?? false) { - next.add(member.id); - } else { - next.remove(member.id); - } - onParticipantsChanged(next); + subtitle: switch (preview?[member.id]) { + final owed? => Text( + formatMoney(currency, owed), + style: moneyStyle(Theme.of(context).textTheme.bodySmall!), + ), + null => null, }, + onChanged: (checked) => controller.setInSplit( + member.id, + included: checked ?? false, + ), ), ), - if (participants.contains(member.id)) - switch (splitKind) { + if (form.participants.contains(member.id)) + // Keyed by kind as well as person: an amount and a percentage + // sit in the same place, and must not share a field's text. + switch (form.splitKind) { SplitKind.exact => SizedBox( width: 110, - child: TextField( - controller: exactControllerFor(member.id), + child: TextFormField( + key: ValueKey(('exact', member.id)), + initialValue: form.exactAmounts[member.id], keyboardType: const TextInputType.numberWithOptions( decimal: true, ), + inputFormatters: [DecimalInputFormatter.amount(currency)], decoration: InputDecoration( isDense: true, prefixText: currency?.symbol, ), - onChanged: (_) => onAmountEdited(), + onChanged: (text) => + controller.setExactAmount(member.id, text), ), ), SplitKind.percent => SizedBox( width: 90, - child: TextField( - controller: percentControllerFor(member.id), + child: TextFormField( + key: ValueKey(('percent', member.id)), + initialValue: form.percents[member.id], keyboardType: const TextInputType.numberWithOptions( decimal: true, ), + inputFormatters: [ + DecimalInputFormatter(percentInputPattern), + ], decoration: const InputDecoration( isDense: true, suffixText: '%', ), - onChanged: (_) => onAmountEdited(), + onChanged: (text) => + controller.setPercent(member.id, text), ), ), SplitKind.shares => Row( @@ -960,12 +530,12 @@ class _SplitSection extends StatelessWidget { children: [ IconButton( icon: const Icon(Icons.remove_circle_outline), - onPressed: () => onSharesChanged(_nudge(member.id, -1)), + onPressed: () => controller.nudgeShares(member.id, -1), ), - Text('${shares[member.id] ?? 1}'), + Text('${form.shareCountOf(member.id)}'), IconButton( icon: const Icon(Icons.add_circle_outline), - onPressed: () => onSharesChanged(_nudge(member.id, 1)), + onPressed: () => controller.nudgeShares(member.id, 1), ), ], ), diff --git a/lib/presentation/screens/group_list_screen.dart b/lib/presentation/screens/group_list_screen.dart index 305a40d..638801e 100644 --- a/lib/presentation/screens/group_list_screen.dart +++ b/lib/presentation/screens/group_list_screen.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import '../../application/ledger_providers.dart'; import '../../application/sync_providers.dart'; import '../../data/local/database.dart'; +import '../../data/repositories/drift_group_repository.dart'; import '../../data/web/boot_hint.dart'; import '../widgets/avatar_view.dart'; import '../widgets/brand_mark.dart'; @@ -27,25 +28,26 @@ class GroupListScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Archived groups are pulled in here rather than queried separately, so the - // list and the "archived" row at the bottom of it are two readings of one - // stream and cannot disagree about which group is where. - final source = groupsProvider(includeArchived: true); + // Archived and left groups are pulled in here rather than queried + // separately, so the list and the "archived" row at the bottom of it are + // two readings of one stream and cannot disagree about which group is + // where. + final source = groupListingsProvider; // Leaves a note for the next cold start, so the web loader knows whether to // draw group cards or just the chrome. ref.listen(source, (_, next) { final loaded = next.value; if (loaded != null) { - recordHasGroups(loaded.any((group) => !group.isArchived)); + recordHasGroups(loaded.any((listing) => listing.isCurrent)); } }); final groupsAsync = ref.watch(source); - final all = groupsAsync.value ?? const []; + final all = groupsAsync.value ?? const []; final groups = [ - for (final group in all) - if (!group.isArchived) group, + for (final listing in all) + if (listing.isCurrent) listing.group, ]; final archived = all.length - groups.length; diff --git a/lib/presentation/screens/group_settings_screen.dart b/lib/presentation/screens/group_settings_screen.dart index b140920..61f3019 100644 --- a/lib/presentation/screens/group_settings_screen.dart +++ b/lib/presentation/screens/group_settings_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -85,25 +87,23 @@ class _GroupSettingsScreenState extends ConsumerState { ]; } - /// Leaving is always available, settled or not. There are no roles, so there - /// is nothing to hand over first. + /// Leaving needs you settled up first, as removing anybody else does: a + /// balance left behind would sit with somebody who can no longer see it. + /// There are no roles, so there is nothing else to hand over. Future _leave(GroupLedger ledger, Member me) async { final debts = _outstanding(ledger, me); + if (debts.isNotEmpty) return _settleFirst(debts); + final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Leave this group?'), - content: Text( - [ - 'You stop getting updates, and you will not appear in new ' - 'expenses.', - if (debts.isNotEmpty) - 'You are not settled up: ${debts.join(', ')}. Leaving does not ' - 'clear that — it stays in the group\'s history for everyone ' - 'still in it.', - 'Everything you have already paid for or owed stays exactly as it ' - 'is. This device keeps a copy, archived and read-only.', - ].join('\n\n'), + content: const Text( + 'You stop getting updates, and you will not appear in new ' + 'expenses.\n\n' + 'Everything you have already paid for or owed stays exactly as it ' + 'is. This device keeps a read-only copy with your archived groups, ' + 'and an invite link brings you back to the same place.', ), actions: [ TextButton( @@ -124,9 +124,7 @@ class _GroupSettingsScreenState extends ConsumerState { setState(() => _busy = true); try { - await ref - .read(groupRepositoryProvider) - .leaveGroup(groupId: widget.groupId, memberId: me.id); + await ref.read(groupRepositoryProvider).leaveGroup(memberId: me.id); // Best effort: the leave is already recorded locally and queued, so an // unreachable server only delays it. await ref.read(syncControllerProvider.notifier).syncGroup(widget.groupId); @@ -136,6 +134,33 @@ class _GroupSettingsScreenState extends ConsumerState { if (mounted) goBack(context, '/'); } + /// Says why leaving has to wait, and offers the way to get there. + Future _settleFirst(List debts) async { + final settle = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Settle up first'), + content: Text( + 'You are not settled up in this group: ${debts.join(', ')}. Once ' + 'nothing is owed either way, you can leave.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Not now'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Settle up'), + ), + ], + ), + ); + if ((settle ?? false) && mounted) { + unawaited(context.push('/g/${widget.groupId}/settle')); + } + } + @override Widget build(BuildContext context) { final ledger = ref.watch(groupLedgerProvider(widget.groupId)); @@ -199,8 +224,10 @@ class _GroupSettingsScreenState extends ConsumerState { ), title: const Text('Suggest the fewest payments'), subtitle: const Text( - 'Nets debts down to as few transfers as settle the group. The ' - 'individual debts underneath are unchanged either way.', + 'On: as few payments as settle the group, which can mean ' + 'paying someone you never shared an expense with. Off: ' + 'each person settles with each person they owe. Either ' + 'way, everyone ends up square.', ), ), diff --git a/lib/presentation/screens/join_screen.dart b/lib/presentation/screens/join_screen.dart index 7b0dff7..adf8060 100644 --- a/lib/presentation/screens/join_screen.dart +++ b/lib/presentation/screens/join_screen.dart @@ -104,8 +104,26 @@ class _JoinScreenState extends ConsumerState { } } - /// Spends the token as whoever holds the session now. - Future _join() async { + /// Spends the token as whoever holds the session now, in the place chosen + /// on this screen. A named invite names the place; there is nothing to + /// choose. + Future _join() => _spend((invites) { + if (!(_preview?.isOpenLink ?? false)) return invites.join(widget.token); + final name = _name.text.trim(); + return invites.join( + widget.token, + memberId: _chosenMemberId, + displayName: name.isEmpty ? null : name, + ); + }); + + /// Comes back to the place this account left. The server gives that place + /// back whatever the link offers, so there is nothing to ask for. + Future _rejoin() => _spend((invites) => invites.join(widget.token)); + + Future _spend( + Future Function(Invites invites) request, + ) async { final invites = ref.read(invitesProvider); if (invites == null) return; @@ -114,14 +132,7 @@ class _JoinScreenState extends ConsumerState { _error = null; }); try { - // A named invite names the place; there is nothing to choose. - final joined = (_preview?.isOpenLink ?? false) - ? await invites.join( - widget.token, - memberId: _chosenMemberId, - displayName: _name.text.trim().isEmpty ? null : _name.text.trim(), - ) - : await invites.join(widget.token); + final joined = await request(invites); // Pull the group down before showing it, so it is populated on arrival // rather than filling in underneath them. await ref.read(syncControllerProvider.notifier).syncGroup(joined.groupId); @@ -199,6 +210,28 @@ class _JoinScreenState extends ConsumerState { }; } + /// Somebody who left, coming back. Whatever the link offered, the server + /// gives them their own place back, so that is the only thing to offer. + Widget _rejoinView(ThemeData theme, api.LinkPreview preview) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + BrandHeader( + title: 'Rejoin ${preview.groupName}', + subtitle: + 'You were in this group before. Rejoining brings back your place ' + 'as it was, with everything you paid and owed, and you get ' + 'updates again.', + ), + const SizedBox(height: 32), + ..._errorIfAny(theme), + FilledButton( + onPressed: _joining ? null : _rejoin, + child: Text(_joining ? 'Rejoining…' : 'Rejoin'), + ), + ], + ); + /// A link naming one place, for one person. Widget _invitation( ThemeData theme, @@ -219,6 +252,7 @@ class _JoinScreenState extends ConsumerState { 'new one.', ); } + if (preview.hasLeft) return _rejoinView(theme, preview); if (preview.isMember) { return _Dead( message: @@ -269,6 +303,7 @@ class _JoinScreenState extends ConsumerState { message: 'This link has expired. Ask ${preview.inviter} for a new one.', ); } + if (preview.hasLeft) return _rejoinView(theme, preview); if (preview.isMember) { return _Dead( message: @@ -304,7 +339,9 @@ class _JoinScreenState extends ConsumerState { if (account == null) IdentityChoices( onSignedIn: () async { - await _loadPlaces(); + // Asked again as somebody: whether they were in this group + // before is something only a session can say. + await _peek(); // Not joined yet, deliberately. if (mounted) setState(() {}); }, diff --git a/lib/presentation/screens/settle_up_screen.dart b/lib/presentation/screens/settle_up_screen.dart index ca9acda..4957315 100644 --- a/lib/presentation/screens/settle_up_screen.dart +++ b/lib/presentation/screens/settle_up_screen.dart @@ -7,21 +7,24 @@ import 'package:qr_flutter/qr_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../application/ledger_providers.dart'; +import '../../application/settlement_form.dart'; import '../../application/local_providers.dart'; import '../../application/preferences_providers.dart'; import '../../application/sync_providers.dart'; import '../../data/local/database.dart'; -import '../../domain/calendar_date.dart'; -import '../../domain/entry_draft.dart'; import '../../domain/money_format.dart'; import '../../domain/settle/upi.dart'; +import '../amount_input.dart'; import '../navigation.dart'; import '../widgets/currency_picker.dart'; import '../widgets/page_body.dart'; /// Records a payment between two members, optionally handing off to a UPI app /// first. -class SettleUpScreen extends ConsumerStatefulWidget { +/// +/// Holds no state of its own: what has been chosen and typed lives in +/// [SettlementForm]. +class SettleUpScreen extends ConsumerWidget { const SettleUpScreen({ super.key, required this.groupId, @@ -38,84 +41,69 @@ class SettleUpScreen extends ConsumerStatefulWidget { final String? currency; @override - ConsumerState createState() => _SettleUpScreenState(); -} - -class _SettleUpScreenState extends ConsumerState { - final _amount = TextEditingController(); - final _payeeVpa = TextEditingController(); - - String? _from; - String? _to; - String? _currencyCode; - String? _amountError; - bool _saving = false; - bool _prefilled = false; - - @override - void dispose() { - _amount.dispose(); - _payeeVpa.dispose(); - super.dispose(); - } - - /// Applies route parameters and sensible defaults once the ledger arrives. - void _prefillOnce(GroupLedger ledger, Map currencies) { - if (_prefilled) return; - _prefilled = true; - - _from = widget.fromMemberId ?? ledger.me?.id; - _to = widget.toMemberId; - _currencyCode = widget.currency ?? ledger.group.defaultCurrency; - - if (widget.amountMinor != null) { - final currency = currencies[_currencyCode]; - if (currency != null) { - _amount.text = currency.formatPlain(widget.amountMinor!); - } - } - } + Widget build(BuildContext context, WidgetRef ref) { + final provider = settlementFormProvider( + groupId, + fromId: fromMemberId, + toId: toMemberId, + amountMinor: amountMinor, + currency: currency, + ); - @override - Widget build(BuildContext context) { // Started on the way in, so this device's zone is known by the time a // settlement is recorded. ref.watch(deviceZoneProvider); // Revalidate on the way in, and this is the one screen where it is not // merely tidiness. - ref.watch(groupSyncProvider(widget.groupId)); - - final ledger = ref.watch(groupLedgerProvider(widget.groupId)); - final currencies = ref.watch(currenciesProvider).value ?? const {}; + ref.watch(groupSyncProvider(groupId)); - if (ledger == null) { + final ledger = ref.watch(groupLedgerProvider(groupId)); + final form = ref.watch(provider).value; + if (ledger == null || form == null) { return Scaffold(appBar: AppBar(leading: const BackButton())); } - _prefillOnce(ledger, currencies); + return _SettleUp( + ledger: ledger, + form: form, + controller: ref.read(provider.notifier), + ); + } +} - final currency = currencies[_currencyCode]; - final payee = _to == null ? null : ledger.memberById(_to!); +class _SettleUp extends ConsumerWidget { + const _SettleUp({ + required this.ledger, + required this.form, + required this.controller, + }); - // Fill the payee's handle the first time we learn it, without stamping over - // anything the user has typed. - final knownVpa = payee == null ? null : ledger.upiOf(payee); - if (_payeeVpa.text.isEmpty && knownVpa != null) { - _payeeVpa.text = knownVpa; - } + final GroupLedger ledger; + final SettlementFormState form; + final SettlementForm controller; - final amountMinor = currency?.parseToMinor(_amount.text); - final upiUri = buildUpiPaymentUri( - payeeVpa: _payeeVpa.text, - payeeName: payee == null ? '' : ledger.nameOfMember(payee), - amountMinor: amountMinor ?? 0, - currency: currency ?? const Currency(code: '', exponent: 2, name: ''), - ); + Future _record(BuildContext context, WidgetRef ref) async { + if (!await controller.record() || !context.mounted) return; + goBack(context, '/g/${ledger.group.id}'); + + // The one place the app asks for a review: a debt has just been cleared, + // which is the app finishing the thing it exists to do. + final review = ref.read(reviewPromptProvider); + unawaited(review.isDue().then((due) => due ? review.ask() : null)); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currencies = ref.watch(currenciesProvider).value ?? const {}; + final currency = currencies[form.currencyCode]; + final payee = form.toId == null ? null : ledger.memberById(form.toId!); + final payeeName = payee == null ? '' : ledger.nameOfMember(payee); + final (from, to) = (form.fromId, form.toId); return Scaffold( appBar: AppBar( leading: BackButton( - onPressed: () => goBack(context, '/g/${widget.groupId}'), + onPressed: () => goBack(context, '/g/${ledger.group.id}'), ), title: const Text('Settle up'), ), @@ -125,66 +113,76 @@ class _SettleUpScreenState extends ConsumerState { children: [ _MemberDropdown( label: 'Who is paying', - members: ledger.members, + members: ledger.settleable, nameOf: ledger.nameOfMember, meId: ledger.me?.id, - value: _from, - onChanged: (id) => setState(() => _from = id), + value: from, + onChanged: controller.choosePayer, ), const SizedBox(height: 16), _MemberDropdown( label: 'Who is being paid', - members: ledger.members, + members: ledger.settleable, nameOf: ledger.nameOfMember, meId: ledger.me?.id, - value: _to, - excludeId: _from, - onChanged: (id) => setState(() => _to = id), + value: to, + excludeId: from, + onChanged: controller.choosePayee, ), const SizedBox(height: 16), CurrencyPicker( - value: _currencyCode ?? ledger.group.defaultCurrency, - onChanged: (code) => setState(() => _currencyCode = code), + value: form.currencyCode, + onChanged: controller.chooseCurrency, ), const SizedBox(height: 16), - TextField( - controller: _amount, + TextFormField( + initialValue: form.amount, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), + inputFormatters: [DecimalInputFormatter.amount(currency)], decoration: InputDecoration( labelText: 'Amount', prefixText: currency?.symbol == null ? null : '${currency!.symbol} ', - errorText: _amountError, + errorText: form.error, ), - onChanged: (_) => setState(() => _amountError = null), + onChanged: controller.setAmount, ), - if (_from != null && _to != null && currency != null) ...[ + if (from != null && to != null && currency != null) ...[ const SizedBox(height: 8), _OutstandingHint( ledger: ledger, - from: _from!, - to: _to!, + from: from, + to: to, currency: currency, ), ], const SizedBox(height: 24), - if (currency?.code == 'INR') ...[ + if (currency != null && currency.code == 'INR') ...[ _UpiSection( - payeeName: payee == null ? '' : ledger.nameOfMember(payee), - vpaController: _payeeVpa, - uri: upiUri, - onChanged: () => setState(() {}), - onPaid: () => _record(ledger, currency!), + payeeName: payeeName, + // A new payee, or their handle arriving with their profile, + // is a new field: what it shows is never left over from + // somebody else. + fieldKey: ValueKey((to, form.knownHandleIn(ledger))), + handle: form.handleIn(ledger), + onHandleChanged: to == null ? null : controller.setHandle, + uri: buildUpiPaymentUri( + payeeVpa: form.handleIn(ledger), + payeeName: payeeName, + amountMinor: currency.parseToMinor(form.amount) ?? 0, + currency: currency, + ), + onPaid: () => _record(context, ref), ), const SizedBox(height: 24), ], FilledButton.icon( - onPressed: _saving || currency == null + onPressed: form.saving || currency == null ? null - : () => _record(ledger, currency), + : () => _record(context, ref), icon: const Icon(Icons.check), label: const Text('Record this payment'), ), @@ -199,66 +197,6 @@ class _SettleUpScreenState extends ConsumerState { ), ); } - - Future _record(GroupLedger ledger, Currency currency) async { - final from = _from; - final to = _to; - final amountMinor = currency.parseToMinor(_amount.text); - - if (from == null || to == null) { - setState(() => _amountError = 'Choose who is paying whom.'); - return; - } - if (amountMinor == null) { - setState( - () => _amountError = currency.exponent == 0 - ? 'Enter a whole amount.' - : 'Enter an amount with at most ${currency.exponent} decimal ' - 'places.', - ); - return; - } - if (amountMinor <= 0) { - setState(() => _amountError = 'Enter an amount greater than zero.'); - return; - } - - setState(() => _saving = true); - try { - // Paid now, here. Saving never waits to learn where "here" is: with no - // zone known yet, only the day is kept. - final zone = ref.read(deviceZoneProvider).value; - final now = DateTime.now(); - await ref - .read(entryRepositoryProvider) - .create( - EntryDraft.settlement( - groupId: widget.groupId, - currency: currency.code, - amountMinor: amountMinor, - fromMemberId: from, - toMemberId: to, - entryDate: calendarDay(now), - occurredAt: zone == null ? null : now.toUtc(), - timeZone: zone, - ), - createdBy: ledger.me?.id ?? from, - ); - - if (!mounted) return; - goBack(context, '/g/${widget.groupId}'); - - // The one place the app asks for a review: a debt has just been cleared, - // which is the app finishing the thing it exists to do. - final review = ref.read(reviewPromptProvider); - unawaited(review.isDue().then((due) => due ? review.ask() : null)); - } catch (error) { - if (!mounted) return; - setState(() => _amountError = '$error'); - } finally { - if (mounted) setState(() => _saving = false); - } - } } /// Shows what is actually outstanding between the two chosen members, so a @@ -296,16 +234,23 @@ class _OutstandingHint extends StatelessWidget { class _UpiSection extends StatelessWidget { const _UpiSection({ required this.payeeName, - required this.vpaController, + required this.fieldKey, + required this.handle, + required this.onHandleChanged, required this.uri, - required this.onChanged, required this.onPaid, }); final String payeeName; - final TextEditingController vpaController; + + /// Identifies what the field was filled from, so a change there gives a + /// fresh field rather than one still showing the old handle. + final Key fieldKey; + final String? handle; + + /// Null until somebody is chosen to be paid. + final ValueChanged? onHandleChanged; final Uri? uri; - final VoidCallback onChanged; final Future Function() onPaid; @override @@ -327,15 +272,17 @@ class _UpiSection extends StatelessWidget { ], ), const SizedBox(height: 12), - TextField( - controller: vpaController, + TextFormField( + key: fieldKey, + initialValue: handle, + enabled: onHandleChanged != null, decoration: InputDecoration( labelText: payeeName.isEmpty ? 'Their UPI ID' : "$payeeName's UPI ID", hintText: 'name@bank', ), - onChanged: (_) => onChanged(), + onChanged: onHandleChanged, ), const SizedBox(height: 16), if (uri == null) @@ -462,6 +409,11 @@ class _MemberDropdown extends StatelessWidget { ]; return DropdownMenu( + // DropdownMenu reads its selection once, and on later changes only + // follows one it can find among its entries: cleared to nobody, it + // would go on showing the last name. Keyed by the value, a different + // value is a fresh menu that shows exactly that. + key: ValueKey(value), initialSelection: options.any((m) => m.id == value) ? value : null, label: Text(label), // A group's member list is short and every name is already visible, so diff --git a/lib/presentation/widgets/balances_panel.dart b/lib/presentation/widgets/balances_panel.dart index f21ad65..4bc31de 100644 --- a/lib/presentation/widgets/balances_panel.dart +++ b/lib/presentation/widgets/balances_panel.dart @@ -8,7 +8,7 @@ import '../../application/ledger_providers.dart'; import '../../data/local/database.dart'; import '../../domain/balance/member_balance.dart'; import '../../domain/balance/simplify.dart'; -import '../../domain/models/entry.dart'; +import '../../domain/balance/transfer_basis.dart'; import '../../domain/money_format.dart'; import '../theme.dart'; import 'avatar_view.dart'; @@ -379,7 +379,7 @@ class _TransferTile extends StatelessWidget { } } -/// Explains where a simplified payment came from. +/// Explains where a suggested payment came from. class _TransferExplanation extends StatelessWidget { const _TransferExplanation({ required this.ledger, @@ -395,22 +395,14 @@ class _TransferExplanation extends StatelessWidget { Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final debtor = transfer.fromMemberId; - final net = ledger.balanceOf(debtor, transfer.currency); - - // Every entry in this currency that moved the debtor's position, with the - // amount it moved it by. - final contributions = <({Entry entry, int delta})>[]; - for (final entry in ledger.entries) { - if (entry.row.currency != transfer.currency) continue; - var delta = 0; - for (final payer in entry.payers) { - if (payer.memberId == debtor) delta += payer.amountMinor; - } - for (final share in entry.shares) { - if (share.memberId == debtor) delta -= share.amountMinor; - } - if (delta != 0) contributions.add((entry: entry, delta: delta)); - } + final creditor = transfer.toMemberId; + final simplified = ledger.group.simplifyDebts; + + final (:contributions, :net) = basisOf( + transfer, + ledger.entries, + simplified: simplified, + ); final name = ledger.nameOf(debtor); final isMe = debtor == ledger.me?.id; @@ -419,15 +411,21 @@ class _TransferExplanation extends StatelessWidget { final rest = transfer.amountMinor == net.abs() ? '' : ', plus the other suggested payments'; - final why = - '${isMe ? 'You owe' : '$name owes'} ' - '${formatMoneyAbs(currency, net)} in total across this group. ' - 'Rather than paying several people separately, that whole amount is ' - 'cleared by paying ${ledger.nameOf(transfer.toMemberId)} ' - '${formatMoney(currency, transfer.amountMinor)}$rest.'; - const caveat = - 'This can name someone you never directly owed. It is the shortest ' - 'set of payments that leaves everybody square.'; + final why = simplified + ? '${isMe ? 'You owe' : '$name owes'} ' + '${formatMoneyAbs(currency, net)} in total across this group. ' + 'Rather than paying several people separately, that whole amount ' + 'is cleared by paying ${ledger.nameOf(creditor)} ' + '${formatMoney(currency, transfer.amountMinor)}$rest.' + : '${isMe ? 'You owe' : '$name owes'} ${ledger.nameOf(creditor)} ' + '${formatMoney(currency, transfer.amountMinor)}, from the ' + 'expenses the two of you shared, each way netted against the ' + 'other.'; + final caveat = simplified + ? 'This can name someone you never directly owed. It is the shortest ' + 'set of payments that leaves everybody square.' + : 'Only the expenses these two shared count here. Turn on "Suggest ' + 'the fewest payments" in group settings to settle in fewer steps.'; return DraggableScrollableSheet( expand: false, diff --git a/lib/presentation/widgets/link_account_prompt.dart b/lib/presentation/widgets/link_account_prompt.dart index 10bd861..7eef538 100644 --- a/lib/presentation/widgets/link_account_prompt.dart +++ b/lib/presentation/widgets/link_account_prompt.dart @@ -22,8 +22,7 @@ class LinkAccountPrompt extends ConsumerWidget { final account = ref.watch(accountProvider).value; if (account == null || !account.isAnonymous) return const SizedBox.shrink(); - final groups = - ref.watch(groupsProvider(includeArchived: true)).value ?? const []; + final groups = ref.watch(groupListingsProvider).value ?? const []; final dismissed = ref.watch(promptDismissedProvider); if (groups.isEmpty || dismissed) return const SizedBox.shrink(); diff --git a/packages/opensplit_api/lib/src/model/link_preview.dart b/packages/opensplit_api/lib/src/model/link_preview.dart index 98ef2e4..6e33473 100644 --- a/packages/opensplit_api/lib/src/model/link_preview.dart +++ b/packages/opensplit_api/lib/src/model/link_preview.dart @@ -36,6 +36,8 @@ class LinkPreview { required this.isRevoked, required this.isMember, + + required this.hasLeft, }); @JsonKey( @@ -74,6 +76,9 @@ class LinkPreview { @JsonKey(name: r'isMember', required: true, includeIfNull: false) final bool isMember; + @JsonKey(name: r'hasLeft', required: true, includeIfNull: false) + final bool hasLeft; + @override bool operator ==(Object other) => identical(this, other) || @@ -87,7 +92,8 @@ class LinkPreview { other.isRedeemed == isRedeemed && other.isExpired == isExpired && other.isRevoked == isRevoked && - other.isMember == isMember; + other.isMember == isMember && + other.hasLeft == hasLeft; @override int get hashCode => @@ -100,7 +106,8 @@ class LinkPreview { isRedeemed.hashCode + isExpired.hashCode + isRevoked.hashCode + - isMember.hashCode; + isMember.hashCode + + hasLeft.hashCode; factory LinkPreview.fromJson(Map json) => _$LinkPreviewFromJson(json); diff --git a/packages/opensplit_api/lib/src/model/link_preview.g.dart b/packages/opensplit_api/lib/src/model/link_preview.g.dart index 1e27c83..844fdff 100644 --- a/packages/opensplit_api/lib/src/model/link_preview.g.dart +++ b/packages/opensplit_api/lib/src/model/link_preview.g.dart @@ -21,6 +21,7 @@ LinkPreview _$LinkPreviewFromJson(Map json) => 'isExpired', 'isRevoked', 'isMember', + 'hasLeft', ], ); final val = LinkPreview( @@ -41,6 +42,7 @@ LinkPreview _$LinkPreviewFromJson(Map json) => isExpired: $checkedConvert('isExpired', (v) => v as bool), isRevoked: $checkedConvert('isRevoked', (v) => v as bool), isMember: $checkedConvert('isMember', (v) => v as bool), + hasLeft: $checkedConvert('hasLeft', (v) => v as bool), ); return val; }); @@ -57,6 +59,7 @@ Map _$LinkPreviewToJson(LinkPreview instance) => 'isExpired': instance.isExpired, 'isRevoked': instance.isRevoked, 'isMember': instance.isMember, + 'hasLeft': instance.hasLeft, }; const _$LinkKindEnumMap = { diff --git a/server/src/api/account.ts b/server/src/api/account.ts index 9bf1351..664faba 100644 --- a/server/src/api/account.ts +++ b/server/src/api/account.ts @@ -3,7 +3,7 @@ import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; import type { DrizzleD1Database } from "drizzle-orm/d1"; import { user } from "../auth-schema"; -import type { AppEnv } from "../context"; +import { type AppEnv, mayChangeIdentity } from "../context"; import { deviceTokens, memberships, profiles } from "../db/d1/schema"; import { forgetAccount } from "../forget"; import { AccountDeletionSchema, DeviceForgottenSchema, DeviceSchema, ProfilePageSchema, ProfileSchema, ProfileUpdateSchema } from "../schemas/account"; @@ -14,9 +14,6 @@ import { refusals, signedIn } from "./routing"; const PAGE_MAX = 200; -/** How recently somebody must have proved who they are to delete their account: Better Auth's `freshAge`, for our route. */ -const REAUTH_WINDOW = 10 * 60 * 1000; - const profileFeedRoute = createRoute({ ...signedIn, method: "get", @@ -142,7 +139,7 @@ export function accountRoutes(routes: OpenAPIHono) { const db = c.var.db; // A session lasts a year, so holding one is not enough to end the account behind it. - if (!c.var.session.isAnonymous && Date.now() - c.var.session.createdAt > REAUTH_WINDOW) { + if (!mayChangeIdentity(c.var.session)) { return c.json(apiError("reauth_required", "Confirm it is you before deleting this account."), 403); } diff --git a/server/src/chunked.ts b/server/src/chunked.ts index 7a33505..7de4f98 100644 --- a/server/src/chunked.ts +++ b/server/src/chunked.ts @@ -1,6 +1,24 @@ -/** D1 and Durable Object SQLite bind at most 100 parameters per statement, so lists go in chunks below that. */ +/** + * D1 and Durable Object SQLite bind at most 100 values a statement. Every + * value a query sends is one: each column of each row in an insert, each id in + * an `IN (…)` list, each value put into a `sql` template. + */ +export const MAX_BOUND_VALUES = 100; + +/** A list in pieces no longer than [size]. The default leaves room for a few other values in the same `IN (…)` query. */ export function chunked(items: readonly T[], size = 90): T[][] { const chunks: T[][] = []; for (let start = 0; start < items.length; start += size) chunks.push(items.slice(start, start + size)); return chunks; } + +/** + * Rows for a multi-row insert, in pieces that each fit one statement: as many + * rows as the limit allows at this many columns a row. Inside a Durable Object + * the pieces run in the same transaction and the same process, so splitting + * costs nothing. + */ +export function insertable(rows: readonly T[]): T[][] { + const columns = Math.max(1, ...rows.map((row) => Object.keys(row).length)); + return chunked(rows, Math.floor(MAX_BOUND_VALUES / columns)); +} diff --git a/server/src/context.ts b/server/src/context.ts index c06c005..b7f441a 100644 --- a/server/src/context.ts +++ b/server/src/context.ts @@ -11,6 +11,18 @@ export interface Session { createdAt: number; } +/** How recently somebody must have proved who they are to change who they are or end the account: Better Auth's `freshAge`, for our routes. */ +export const REAUTH_WINDOW = 10 * 60 * 1000; + +/** + * Whether this session may change the identity behind it. A session lasts a + * year, so holding one is not proof enough; a guest has nothing to confirm + * with, and attaching an identity is how a guest keeps their groups. + */ +export function mayChangeIdentity(session: Session): boolean { + return session.isAnonymous || Date.now() - session.createdAt <= REAUTH_WINDOW; +} + interface Services { db: DrizzleD1Database; auth: Auth; diff --git a/server/src/do/fx/index.ts b/server/src/do/fx/index.ts index f0c623b..370f80d 100644 --- a/server/src/do/fx/index.ts +++ b/server/src/do/fx/index.ts @@ -1,11 +1,12 @@ import { DurableObject } from "cloudflare:workers"; -import { and, asc, between, eq, gte, inArray, lte, sql } from "drizzle-orm"; +import { and, asc, between, eq, gte, inArray, sql } from "drizzle-orm"; import { type DrizzleSqliteDODatabase, drizzle } from "drizzle-orm/durable-sqlite"; import { migrate } from "drizzle-orm/durable-sqlite/migrator"; +import { insertable } from "../../chunked"; import * as schema from "../../db/fx/schema"; import { type MonthBlob, monthKey } from "../../fx/blob"; -import { type FxProvider, providers } from "../../fx/providers"; +import { type FxProvider, type FxSnapshot, providers } from "../../fx/providers"; import { supportedCurrencies } from "../../reference"; import type { FxRate } from "../../schemas/reference"; import migrations from "./migrations/migrations"; @@ -70,6 +71,39 @@ export class Fx extends DurableObject { return true; } + /** + * Keeps the last [HISTORY_DAYS] free of any stretch longer than + * [STALE_AFTER_DAYS] without a publication, since a rate that old does not + * answer for a day. Each such stretch is fetched whole, in one request, from + * the first provider that can answer for a range. Run daily: on a new + * deployment the first run fills the year, and every run after finds + * nothing to do unless a provider was down for a week. + */ + async fillHistory(now: number = Date.now()): Promise { + const gaps = this.gaps(now); + const months = new Set(); + let stored = 0; + + for (const gap of gaps) { + for (const provider of providers) { + if (!provider.fetchRange) continue; + const { snapshots, failure } = await this.attemptRange(provider, gap); + this.recordHealth(provider.name, now, failure); + if (!snapshots) continue; + + for (const snapshot of snapshots) { + const written = this.store(snapshot, provider.name, now); + stored += written.length; + if (written.length > 0) months.add(snapshot.asOf.slice(0, 7)); + } + break; + } + } + + await this.publish([...months]); + return { gaps, stored, months: [...months].sort() }; + } + /** Rates on or after a day, straight from storage. */ async since(asOf: string, limit: number): Promise { return this.db.select(rateColumns).from(schema.fxRates).where(gte(schema.fxRates.asOf, asOf)).orderBy(asc(schema.fxRates.asOf), asc(schema.fxRates.currency)).limit(limit).all(); @@ -106,21 +140,72 @@ export class Fx extends DurableObject { this.recordHealth(provider.name, now, failure); if (!snapshot) continue; - const rows = Object.entries(snapshot.rates) - .filter(([code]) => outstanding.has(code)) - .map(([currency, rate]) => ({ asOf: snapshot.asOf, currency, rate, source: provider.name, createdAt: new Date(now).toISOString() })); - if (rows.length === 0) continue; + const answered = Object.entries(snapshot.rates).filter(([code]) => outstanding.has(code)); + const written = this.store({ asOf: snapshot.asOf, rates: Object.fromEntries(answered) }, provider.name, now); + // Covered whether written now or already held: a second run on the same day asks nobody else for it. + for (const [currency] of answered) outstanding.delete(currency); + if (written.length === 0) continue; - // Never overwritten: the `source` is stamped on expenses converted with the rate. - const written = this.db.insert(schema.fxRates).values(rows).onConflictDoNothing().returning({ currency: schema.fxRates.currency }).all(); stored += written.length; months.add(snapshot.asOf.slice(0, 7)); - for (const { currency } of written) outstanding.delete(currency); } return { stored, covered: wanted.filter((code) => !outstanding.has(code)), missing: [...outstanding], months: [...months] }; } + /** + * Stores one day's rates and returns the currencies newly written. Never + * overwrites: the `source` is stamped on expenses converted with a rate. + * In pieces, since one statement carries at most 100 values. + */ + private store(snapshot: FxSnapshot, source: string, now: number): string[] { + const createdAt = new Date(now).toISOString(); + const rows = Object.entries(snapshot.rates).map(([currency, rate]) => ({ asOf: snapshot.asOf, currency, rate, source, createdAt })); + return insertable(rows).flatMap((chunk) => + this.db + .insert(schema.fxRates) + .values(chunk) + .onConflictDoNothing() + .returning({ currency: schema.fxRates.currency }) + .all() + .map((row) => row.currency), + ); + } + + /** + * Stretches of the last year where some day has no publication within + * [STALE_AFTER_DAYS] before it, each as the run of days between the + * publications either side. The edges of the year count as publications. + */ + private gaps(now: number): Gap[] { + const today = isoDay(now); + const floor = shiftDay(today, -HISTORY_DAYS); + const published = this.db + .selectDistinct({ asOf: schema.fxRates.asOf }) + .from(schema.fxRates) + .where(between(schema.fxRates.asOf, floor, today)) + .orderBy(asc(schema.fxRates.asOf)) + .all() + .map((row) => row.asOf); + + const gaps: Gap[] = []; + let before = shiftDay(floor, -1); + for (const after of [...published, shiftDay(today, 1)]) { + if (daysBetween(before, after) > STALE_AFTER_DAYS + 1) gaps.push({ from: shiftDay(before, 1), to: shiftDay(after, -1) }); + before = after; + } + return gaps; + } + + private async attemptRange(provider: FxProvider, { from, to }: Gap) { + try { + const snapshots = (await provider.fetchRange?.({ from, to, currencies: supportedCurrencies, env: this.env })) ?? null; + return { snapshots, failure: snapshots === null ? "no usable response for a range" : null }; + } catch (cause) { + return { snapshots: null, failure: String(cause) }; + } + } + private async attempt(provider: FxProvider, asOf: string | null, currencies: string[]) { try { const snapshot = await provider.fetch({ asOf, currencies, env: this.env }); @@ -143,12 +228,16 @@ export class Fx extends DurableObject { .run(); } - /** Currencies with no rate on or before a day: a Friday rate answers a Sunday. */ + /** + * Currencies with no rate that answers for a day: one published on it or in + * the [STALE_AFTER_DAYS] before, so Friday's answers a Sunday and last + * spring's does not answer today. + */ private missingFor(asOf: string): string[] { const held = this.db .selectDistinct({ currency: schema.fxRates.currency }) .from(schema.fxRates) - .where(and(lte(schema.fxRates.asOf, asOf), inArray(schema.fxRates.currency, supportedCurrencies))) + .where(and(between(schema.fxRates.asOf, shiftDay(asOf, -STALE_AFTER_DAYS), asOf), inArray(schema.fxRates.currency, supportedCurrencies))) .all(); const covered = new Set(held.map((row) => row.currency)); return supportedCurrencies.filter((code) => !covered.has(code)); @@ -168,6 +257,28 @@ export class Fx extends DurableObject { } } +/** How far back history is kept complete: the client's own rate window. */ +export const HISTORY_DAYS = 365; + +/** + * How old a rate may be and still answer for a day. A week covers every + * weekend and every ECB holiday. The app holds the same rule, in + * `lib/domain/fx/fx_quote.dart`, for the rates it looks up itself. + */ +export const STALE_AFTER_DAYS = 7; + +/** A run of days, `YYYY-MM-DD`, inclusive. */ +export interface Gap { + from: string; + to: string; +} + +export interface HistoryOutcome { + gaps: Gap[]; + stored: number; + months: string[]; +} + const rateColumns = { asOf: schema.fxRates.asOf, currency: schema.fxRates.currency, rate: schema.fxRates.rate, source: schema.fxRates.source }; export interface RunOutcome { @@ -181,4 +292,7 @@ const BACKFILL_COOLDOWN = 60 * 60 * 1000; /** After this many, a date is accepted as one no provider will answer. */ const BACKFILL_ATTEMPTS = 5; +const DAY = 24 * 60 * 60 * 1000; const isoDay = (at: number) => new Date(at).toISOString().slice(0, 10); +const shiftDay = (day: string, by: number) => isoDay(Date.parse(day) + by * DAY); +const daysBetween = (from: string, to: string) => Math.round((Date.parse(to) - Date.parse(from)) / DAY); diff --git a/server/src/do/group/balances.ts b/server/src/do/group/balances.ts index ee3fc62..ddbb0b2 100644 --- a/server/src/do/group/balances.ts +++ b/server/src/do/group/balances.ts @@ -38,3 +38,12 @@ export function isSettled(tx: Tx, memberId: string): boolean { export function isGroupSettled(tx: Tx): boolean { return outstanding(tx).length === 0; } + +/** Every open position, by member and then currency: positive is owed, negative owes. */ +export function positionsByMember(tx: Tx): Map> { + const byMember = new Map>(); + for (const { memberId, currency, balanceMinor } of outstanding(tx)) { + byMember.set(memberId, (byMember.get(memberId) ?? new Map()).set(currency, balanceMinor)); + } + return byMember; +} diff --git a/server/src/do/group/changes.ts b/server/src/do/group/changes.ts index 7c92fad..9bc5e40 100644 --- a/server/src/do/group/changes.ts +++ b/server/src/do/group/changes.ts @@ -55,7 +55,8 @@ export function changesSince(tx: Tx, profileId: string, since: number, limit: nu purgedAt: null, // The group and member rows changed since the cursor ride on every page, even ahead of it: // this page's entries and events may name them, and a device's foreign keys need them first. - group: meta.seq > since ? meta : null, + // Not past the ceiling: somebody who left does not hear what the group is called now. + group: meta.seq > since && meta.seq <= ceiling ? meta : null, members: tx .select() .from(schema.members) diff --git a/server/src/do/group/invites.ts b/server/src/do/group/invites.ts index 0ca450e..b38cfad 100644 --- a/server/src/do/group/invites.ts +++ b/server/src/do/group/invites.ts @@ -84,8 +84,8 @@ export function peek(tx: Tx, token: string, viewer: string | null, now: string): const meta = requireMeta(tx); const nameOf = (memberId: string) => tx.select({ name: schema.members.displayName }).from(schema.members).where(eq(schema.members.id, memberId)).get()?.name ?? null; const memberCount = tx.select({ count: sql`count(*)` }).from(schema.members).where(isNull(schema.members.leftAt)).get()?.count ?? 0; - const isMember = viewer !== null && activeMemberFor(tx, viewer) !== undefined; - const shared = { groupId: meta.id, groupName: meta.name, memberCount, isMember }; + const place = viewer === null ? undefined : placeOf(tx, viewer); + const shared = { groupId: meta.id, groupName: meta.name, memberCount, isMember: place !== undefined && place.leftAt === null, hasLeft: place?.leftAt != null }; const invite = tx.select().from(schema.invites).where(eq(schema.invites.token, token)).get(); if (invite) { @@ -129,23 +129,26 @@ export function placeholders(tx: Tx, token: string, now: string): Placeholder[] /** * Walking in: redeem an invite, claim a placeholder behind an open link, or - * arrive as somebody new. Never a second place for one account. + * arrive as somebody new. Never a second place for one account: somebody who + * left gets their own place back, whatever the link offered. */ export function join(tx: Tx, token: string, profileId: string, request: JoinRequest, now: string): Member { requireMeta(tx); - if (tx.select().from(schema.members).where(eq(schema.members.profileId, profileId)).get()) { - refuse("already_member", "You are already in this group."); - } + const previous = placeOf(tx, profileId); + if (previous && previous.leftAt === null) refuse("already_member", "You are already in this group."); const invite = tx.select().from(schema.invites).where(eq(schema.invites.token, token)).get(); if (invite) { if (invite.redeemedAt !== null) refuse("invite_spent", "This invite link has already been used."); if (invite.expiresAt < now) refuse("invite_expired", "This invite link has expired."); + // The invite names somebody else's place, so it stays unspent for them. + if (previous) return rejoin(tx, previous, now); tx.update(schema.invites).set({ redeemedAt: now, redeemedBy: profileId }).where(eq(schema.invites.token, token)).run(); return claim(tx, invite.memberId, profileId, now); } requireLiveLink(tx, token, now); + if (previous) return rejoin(tx, previous, now); if (request.memberId !== null) return claim(tx, request.memberId, profileId, now); const name = request.displayName?.trim() ?? ""; @@ -159,6 +162,15 @@ export function join(tx: Tx, token: string, profileId: string, request: JoinRequ return requireMember(tx, id); } +/** Coming back to the place you left, balances and history as they were. */ +function rejoin(tx: Tx, member: MemberRow, now: string): Member { + const seq = nextSeq(tx); + tx.update(schema.members).set({ leftAt: null, updatedAt: now, seq }).where(eq(schema.members.id, member.id)).run(); + append(tx, { seq, now, actorId: null, kind: "member_joined", subjectId: member.id, member: { displayName: member.displayName, previousName: null } }); + if (member.profileId !== null) stageMembership(tx, member.profileId, null, now); + return requireMember(tx, member.id); +} + /** Taking over a placeholder sets one column; no balance moves. Nobody did it to them, so no actor. */ function claim(tx: Tx, memberId: string, profileId: string, now: string): Member { const member = requireMember(tx, memberId); @@ -179,10 +191,7 @@ function requireLiveLink(tx: Tx, token: string, now: string): void { if (link.expiresAt < now) refuse("invite_expired", "This invite link has expired."); } -function activeMemberFor(tx: Tx, profileId: string): MemberRow | undefined { - return tx - .select() - .from(schema.members) - .where(and(eq(schema.members.profileId, profileId), isNull(schema.members.leftAt))) - .get(); +/** The account's place here, current or left. One account never holds two. */ +function placeOf(tx: Tx, profileId: string): MemberRow | undefined { + return tx.select().from(schema.members).where(eq(schema.members.profileId, profileId)).get(); } diff --git a/server/src/do/group/ledger.ts b/server/src/do/group/ledger.ts index 048649a..b764414 100644 --- a/server/src/do/group/ledger.ts +++ b/server/src/do/group/ledger.ts @@ -1,8 +1,9 @@ -import { eq, inArray } from "drizzle-orm"; +import { eq, inArray, isNotNull } from "drizzle-orm"; -import { chunked } from "../../chunked"; +import { chunked, insertable } from "../../chunked"; import * as schema from "../../db/group/schema"; import { type Entry, type EntryInput, editableEntryColumns } from "../../schemas/ledger"; +import { positionsByMember } from "./balances"; import { appendSnapshot, byMember, moneyRows } from "./events"; import { refuse } from "./refusal"; import { type EntryRow, nextSeq, requireMeta, type Tx, type WriteContext } from "./store"; @@ -60,6 +61,7 @@ export function putEntry(tx: Tx, id: string, input: EntryInput, { now, actor }: assertBaseIsCurrent(stored, input); if (JSON.stringify(comparable(stored)) === JSON.stringify(comparable(input))) return stored; } + assertDepartedOnlySettle(tx, stored, input); const seq = nextSeq(tx); @@ -85,12 +87,13 @@ export function putEntry(tx: Tx, id: string, input: EntryInput, { now, actor }: tx.delete(schema.entryPayers).where(eq(schema.entryPayers.entryId, id)).run(); tx.delete(schema.entryShares).where(eq(schema.entryShares.entryId, id)).run(); - tx.insert(schema.entryPayers) - .values(input.payers.map(({ memberId, amountMinor }) => ({ entryId: id, memberId, amountMinor }))) - .run(); - tx.insert(schema.entryShares) - .values(input.shares.map(({ memberId, amountMinor, weightMicros }) => ({ entryId: id, memberId, amountMinor, weightMicros }))) - .run(); + // A crowd's worth of payers or shares is more than one statement can carry. + for (const payers of insertable(input.payers.map(({ memberId, amountMinor }) => ({ entryId: id, memberId, amountMinor })))) { + tx.insert(schema.entryPayers).values(payers).run(); + } + for (const shares of insertable(input.shares.map(({ memberId, amountMinor, weightMicros }) => ({ entryId: id, memberId, amountMinor, weightMicros })))) { + tx.insert(schema.entryShares).values(shares).run(); + } const entry = requireEntry(tx, id); appendSnapshot(tx, entry, entry.payers, entry.shares, { seq, now, actorId: actor.id }); @@ -120,6 +123,51 @@ function assertMembersExist(tx: Tx, input: EntryInput): void { if (known.length !== named.length) refuse("no_such_member", "This expense names somebody who is not in this group."); } +/** + * Somebody who has left cannot see this group, so no write may change what + * they owe or are owed, except to settle it: a balance they left with only + * moves toward zero. Editing a description, or a split among the people still + * here, moves none of their money and goes through. + */ +function assertDepartedOnlySettle(tx: Tx, stored: Entry | undefined, input: EntryInput): void { + const departed = new Map( + tx + .select({ id: schema.members.id, name: schema.members.displayName }) + .from(schema.members) + .where(isNotNull(schema.members.leftAt)) + .all() + .map((member) => [member.id, member.name]), + ); + if (departed.size === 0) return; + + // Per member, per currency: what this write moves, the stored version taken back out. + const moved = new Map>(); + const count = (entry: Pick, sign: 1 | -1) => { + if (entry.deletedAt !== null) return; + const add = (memberId: string, amount: number) => { + if (!departed.has(memberId)) return; + const byCurrency = moved.get(memberId) ?? new Map(); + byCurrency.set(entry.currency, (byCurrency.get(entry.currency) ?? 0) + sign * amount); + moved.set(memberId, byCurrency); + }; + for (const payer of entry.payers) add(payer.memberId, payer.amountMinor); + for (const share of entry.shares) add(share.memberId, -share.amountMinor); + }; + if (stored) count(stored, -1); + count(input, 1); + + const held = positionsByMember(tx); + for (const [memberId, byCurrency] of moved) { + for (const [currency, delta] of byCurrency) { + if (delta === 0) continue; + const before = held.get(memberId)?.get(currency) ?? 0; + const after = before + delta; + const settles = before !== 0 && Math.sign(after) !== -Math.sign(before) && Math.abs(after) < Math.abs(before); + if (!settles) refuse("forbidden", `${departed.get(memberId)} has left this group, so this expense cannot change what they owe.`); + } + } +} + /** * A stale base is refused only when the write would move money; two people * fixing a typo never arbitrate. A null base claims no version. diff --git a/server/src/do/group/roster.ts b/server/src/do/group/roster.ts index a62bb56..528d217 100644 --- a/server/src/do/group/roster.ts +++ b/server/src/do/group/roster.ts @@ -89,27 +89,38 @@ function addMember(tx: Tx, memberId: string, input: MemberInput, { now, actor }: /** * The column rules. Your own row and any placeholder are editable; another * account holder's name and payment handle are not, since a handle redirects - * money. Leaving is always yours; removing somebody else requires them settled. + * money. Nobody leaves or is removed owing or owed anything, so a balance is + * never stranded with somebody who can no longer see the group. A placeholder + * can be brought back by anybody; an account holder only comes back + * themselves, with a link. */ function updateMember(tx: Tx, memberId: string, input: MemberInput, { now, actor }: WriteContext): Member { const target = requireMember(tx, memberId); - const { displayName, upiVpa, leftAt } = input; - - const mineOrPlaceholder = target.profileId === null || target.id === actor.id; - if ((displayName !== target.displayName || upiVpa !== target.upiVpa) && !mineOrPlaceholder) { + const { displayName, upiVpa } = input; + // The device says whether; the server's clock says when, once. + const leftAt = input.leftAt === null ? null : (target.leftAt ?? now); + const isSelf = target.id === actor.id; + const leaving = leftAt !== null && target.leftAt === null; + const returning = leftAt === null && target.leftAt !== null; + + if ((displayName !== target.displayName || upiVpa !== target.upiVpa) && target.profileId !== null && !isSelf) { refuse("forbidden", `Only ${target.displayName} can change their own name or payment handle.`); } - if (leftAt !== target.leftAt && target.id !== actor.id && !isSettled(tx, target.id)) { - refuse("not_settled", `${target.displayName} is not settled up in this group, so they cannot be removed from it.`); + if (leaving && !isSettled(tx, target.id)) { + refuse("not_settled", isSelf ? "You are not settled up in this group yet, so you cannot leave it." : `${target.displayName} is not settled up in this group, so they cannot be removed from it.`); + } + if (returning && target.profileId !== null) { + refuse("forbidden", `${target.displayName} left this group, so only they can come back, with a link.`); } - if (displayName === target.displayName && upiVpa === target.upiVpa && leftAt === target.leftAt) return target; + if (displayName === target.displayName && upiVpa === target.upiVpa && !leaving && !returning) return target; const seq = nextSeq(tx); tx.update(schema.members).set({ displayName, upiVpa, leftAt, updatedAt: now, seq }).where(eq(schema.members.id, memberId)).run(); - // A payment handle and a rejoin are deliberately silent. + // A payment handle is deliberately silent. const written = { seq, now, actorId: actor.id, subjectId: memberId }; - if (leftAt !== null && target.leftAt === null) append(tx, { ...written, kind: "member_left", member: { displayName, previousName: null } }); + if (leaving) append(tx, { ...written, kind: "member_left", member: { displayName, previousName: null } }); + if (returning) append(tx, { ...written, kind: "member_added", member: { displayName, previousName: null } }); if (displayName !== target.displayName) append(tx, { ...written, kind: "member_renamed", member: { displayName, previousName: target.displayName } }); if (target.profileId !== null) stageMembership(tx, target.profileId, leftAt, now); diff --git a/server/src/fx/providers.ts b/server/src/fx/providers.ts index db18c9a..15f4a95 100644 --- a/server/src/fx/providers.ts +++ b/server/src/fx/providers.ts @@ -24,6 +24,14 @@ export interface FetchOptions { env: Env; } +export interface RangeOptions { + /** The first and last day wanted, inclusive, `YYYY-MM-DD`. */ + from: string; + to: string; + currencies: string[]; + env: Env; +} + /** A rate source; everything provider-specific stays inside the adapter. */ export interface FxProvider { readonly name: string; @@ -33,6 +41,12 @@ export interface FxProvider { /** Null for any failure: a provider being down is ordinary. */ fetch(options: FetchOptions): Promise; + + /** + * Every publication between two days in one request, for filling a long + * stretch of history. Only some providers can; null for any failure. + */ + fetchRange?(options: RangeOptions): Promise; } /** A fetch that cannot hang the run. */ @@ -83,6 +97,24 @@ export const frankfurter: FxProvider = { clean.USD = 1; return { asOf: date, rates: clean }; }, + + async fetchRange({ from, to, currencies }: RangeOptions): Promise { + const symbols = currencies.filter((code) => code !== "USD"); + if (symbols.length === 0) return null; + + // A year of ECB's thirty-odd currencies is a few hundred kilobytes. + const body = await getJson(`https://api.frankfurter.dev/v1/${from}..${to}?base=USD&symbols=${symbols.join(",")}`, 30_000); + if (typeof body !== "object" || body === null) return null; + + const { rates } = body as { rates?: unknown }; + if (typeof rates !== "object" || rates === null) return null; + + const snapshots = Object.entries(rates as Record).flatMap(([asOf, day]) => { + const clean = sanitise(day, symbols); + return /^\d{4}-\d{2}-\d{2}$/.test(asOf) && Object.keys(clean).length > 0 ? [{ asOf, rates: { ...clean, USD: 1 } }] : []; + }); + return snapshots.length > 0 ? snapshots : null; + }, }; /** diff --git a/server/src/identity/routes.ts b/server/src/identity/routes.ts index 0510f32..9482903 100644 --- a/server/src/identity/routes.ts +++ b/server/src/identity/routes.ts @@ -6,7 +6,7 @@ import type { Context } from "hono"; import { clientIp, exhausted, maybeSignedIn, rateLimited, signedIn, tooMany } from "../api/routing"; import { user } from "../auth-schema"; -import type { AppEnv } from "../context"; +import { type AppEnv, mayChangeIdentity, type Session } from "../context"; import { apiError, errorResponse, jsonBody, jsonResponse } from "../schemas/common"; import { EmailStartRequestSchema, EmailStartResponseSchema, EmailVerifyRequestSchema, GoogleIdentityRequestSchema, GoogleRedirectRequestSchema, GoogleRedirectSchema, IdentityOutcomeSchema, ReauthStartSchema, ReauthVerifyRequestSchema, SessionSchema } from "../schemas/identity"; import { outcomeFor, toAccount } from "./outcome"; @@ -22,6 +22,13 @@ import { outcomeFor, toAccount } from "./outcome"; * strand a ledger until the caller says it has warned the person. */ +/** + * Attaching an address or a Google account to an account that already has + * one is how a session somebody else got hold of would become theirs for + * good, so it needs the same fresh sign-in as deleting the account. + */ +const reauthRequired = errorResponse("This account has an address, and its sign-in is too old to change who it belongs to. Confirm it is you first: `POST /identity/reauth`, then `POST /identity/reauth/verify`."); + const guestRoute = createRoute({ ...maybeSignedIn, method: "post", @@ -63,6 +70,7 @@ const googleRoute = createRoute({ request: { body: jsonBody(GoogleIdentityRequestSchema) }, responses: { 200: jsonResponse(IdentityOutcomeSchema, "Linked, or signed in"), + 403: reauthRequired, 409: errorResponse("That Google account already has an account of its own, and allowSignIn was not set."), }, }); @@ -76,7 +84,7 @@ const googleRedirectRoute = createRoute({ summary: "Where to send the browser to link or sign in with Google", description: "Links to the session in hand unless allowSignIn is set or there is none. Afterwards, read `GET /identity/session`.", request: { body: jsonBody(GoogleRedirectRequestSchema) }, - responses: { 200: jsonResponse(GoogleRedirectSchema, "The URL to visit"), 400: errorResponse("The callback URL is not on this origin.") }, + responses: { 200: jsonResponse(GoogleRedirectSchema, "The URL to visit"), 400: errorResponse("The callback URL is not on this origin."), 403: reauthRequired }, }); const emailStartRoute = createRoute({ @@ -87,7 +95,7 @@ const emailStartRoute = createRoute({ tags: ["identity"], summary: "Send a sign-in code, and say which flow it started", request: { body: jsonBody(EmailStartRequestSchema) }, - responses: { 200: jsonResponse(EmailStartResponseSchema, "A code is on its way"), ...rateLimited }, + responses: { 200: jsonResponse(EmailStartResponseSchema, "A code is on its way"), 403: reauthRequired, ...rateLimited }, }); const emailVerifyRoute = createRoute({ @@ -102,6 +110,7 @@ const emailVerifyRoute = createRoute({ 200: jsonResponse(IdentityOutcomeSchema, "Attached, or signed in"), 400: errorResponse("The code is wrong or has expired."), 401: errorResponse("That code was for a flow this session cannot finish."), + 403: reauthRequired, ...rateLimited, }, }); @@ -139,6 +148,12 @@ const reauthVerifyRoute = createRoute({ }, }); +function staleForIdentityChange(session: Session | null): boolean { + return session !== null && !mayChangeIdentity(session); +} + +const confirmFirst = () => apiError("reauth_required", "Confirm it is you before changing how this account signs in."); + /** Better Auth checks the token's nonce claim against `nonce` exactly, so the app gives Google the same value it sends here. */ const google = (idToken: string, nonce: string | null) => ({ provider: "google" as const, idToken: { token: idToken, nonce: nonce ?? undefined } }); @@ -198,8 +213,11 @@ export function identityRoutes(routes: OpenAPIHono) { const { auth, session } = c.var; const { idToken, nonce, allowSignIn } = c.req.valid("json"); const before = session?.userId ?? null; + // An old session may still sign in somewhere else; it may not attach an identity to itself. + const mayLink = !staleForIdentityChange(session); + if (!mayLink && !allowSignIn) return c.json(confirmFirst(), 403); - if (before !== null) { + if (before !== null && mayLink) { try { const linked = await auth.api.linkSocialAccount({ body: google(idToken, nonce), headers: c.req.raw.headers, returnHeaders: true }); await settle(c.var.db, before); @@ -223,6 +241,7 @@ export function identityRoutes(routes: OpenAPIHono) { if (new URL(callbackUrl).origin !== new URL(c.env.APP_ORIGIN).origin) return c.json(apiError("malformed", "The callback URL must be on this site."), 400); const body = { provider: "google" as const, callbackURL: callbackUrl, errorCallbackURL: callbackUrl, disableRedirect: true }; const link = c.var.session !== null && !allowSignIn; + if (link && staleForIdentityChange(c.var.session)) return c.json(confirmFirst(), 403); const started = link ? await c.var.auth.api.linkSocialAccount({ body, headers: c.req.raw.headers, returnHeaders: true }) : await c.var.auth.api.signInSocial({ body, headers: c.req.raw.headers, returnHeaders: true }); // The OAuth state cookie has to reach the browser for the callback to verify. @@ -242,6 +261,7 @@ export function identityRoutes(routes: OpenAPIHono) { return c.json({ flow: "signInPending" as const }, 200); } + if (staleForIdentityChange(session)) return c.json(confirmFirst(), 403); await auth.api.requestEmailChangeEmailOTP({ body: { newEmail: email }, headers: c.req.raw.headers }); return c.json({ flow: "linkPending" as const }, 200); }); @@ -255,6 +275,7 @@ export function identityRoutes(routes: OpenAPIHono) { if (flow === "linkPending") { if (before === null) return c.json(apiError("no_session", "That code was for attaching an address to a session, and there is no session to attach it to."), 401); + if (staleForIdentityChange(session)) return c.json(confirmFirst(), 403); const changed = await auth.api.changeEmailEmailOTP({ body: { newEmail: email, otp: code }, headers: c.req.raw.headers, returnHeaders: true }); await settle(c.var.db, before); return c.json(outcomeFor(await accountById(c.var.db, before), before, forward(c, changed.headers)), 200); diff --git a/server/src/scheduled.ts b/server/src/scheduled.ts index 33c1ab4..f166444 100644 --- a/server/src/scheduled.ts +++ b/server/src/scheduled.ts @@ -9,10 +9,15 @@ import { finishDeletions } from "./forget"; /** The two crons. Dormant groups are not swept: each group object arms its own alarm. */ export async function refreshRates(env: Env): Promise { - const outcome = await env.FX.getByName("global").refresh(); + const fx = env.FX.getByName("global"); + const outcome = await fx.refresh(); // Logged, not alerted: a missing rate is a missing estimate, never a wrong balance. if (outcome.missing.length > 0) console.warn("[fx] uncovered after the waterfall", outcome.missing.join(",")); console.log("[fx] stored", outcome.stored, "months", outcome.months.join(",")); + + // After today's, so a gap reaching up to today is measured against it. Nothing to do on almost every run. + const history = await fx.fillHistory(); + if (history.gaps.length > 0) console.log("[fx] filled", history.gaps.map((gap) => `${gap.from}..${gap.to}`).join(","), "stored", history.stored); } const DAY = 24 * 60 * 60 * 1000; diff --git a/server/src/schemas/ledger.ts b/server/src/schemas/ledger.ts index 5a4b0d8..25a7c1e 100644 --- a/server/src/schemas/ledger.ts +++ b/server/src/schemas/ledger.ts @@ -162,6 +162,8 @@ export const LinkPreviewSchema = z isRevoked: z.boolean(), /** Whether the caller already holds a place in this group. */ isMember: z.boolean(), + /** Whether the caller held a place here and left it. Joining brings that same place back. */ + hasLeft: z.boolean(), }) .openapi("LinkPreview"); diff --git a/server/test/fx.test.ts b/server/test/fx.test.ts index 28edbed..3f0a217 100644 --- a/server/test/fx.test.ts +++ b/server/test/fx.test.ts @@ -126,6 +126,20 @@ describe("the waterfall", () => { expect(stored.find((rate) => rate.currency === "EUR")?.rate).toBe(0.92); }); + it("counts a rate it already holds as covered, so a second run asks nobody else", async () => { + stubFetch((url) => (url.includes("frankfurter") ? frankfurterBody("2026-09-24", Object.fromEntries(supportedCurrencies.map((code) => [code, 3]))) : null)); + const object = fx(); + await object.refresh(); + + vi.unstubAllGlobals(); + const calls = stubFetch((url) => (url.includes("frankfurter") ? frankfurterBody("2026-09-24", Object.fromEntries(supportedCurrencies.map((code) => [code, 3]))) : null)); + const again = await object.refresh(); + + expect(again.missing).toEqual([]); + expect(again.stored).toBe(0); + expect(calls.filter((url) => url.includes("exchangerate-api"))).toEqual([]); + }); + it("skips a provider with no key rather than failing the run", async () => { // How a fork, or a local `wrangler dev`, runs on Frankfurter alone without // editing anything. An unconfigured source is not a broken one. @@ -203,6 +217,72 @@ describe("a backfill", () => { }); }); +/** Frankfurter's range shape: one map of rates per business day. */ +function frankfurterRange(url: string, skip: (day: string) => boolean = () => false) { + const match = /\/v1\/(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})/.exec(url); + if (!match) return null; + const [, from = "", to = ""] = match; + const rates: Record> = {}; + for (let at = Date.parse(from); at <= Date.parse(to); at += 24 * 60 * 60 * 1000) { + const day = new Date(at).toISOString().slice(0, 10); + const weekday = new Date(at).getUTCDay(); + if (weekday !== 0 && weekday !== 6 && !skip(day)) rates[day] = { EUR: 0.9, GBP: 0.8 }; + } + return { amount: 1, base: "USD", start_date: from, end_date: to, rates }; +} + +describe("the year of history", () => { + const now = Date.parse("2026-09-24T12:00:00Z"); + + it("is filled in one request on a new deployment, and left alone after", async () => { + const calls = stubFetch((url) => (url.includes("frankfurter") ? frankfurterRange(url) : null)); + const object = fx(); + + const first = await object.fillHistory(now); + expect(first.gaps).toEqual([{ from: "2025-09-24", to: "2026-09-24" }]); + expect(calls).toHaveLength(1); + // Two currencies and USD on each of about 260 business days, written in + // chunks, since one statement binds at most 100 parameters. + expect(first.stored).toBeGreaterThan(700); + expect(first.months).toContain("2025-12"); + expect((await env.CACHE.get(monthKey("2025-12"), "json"))?.rates.length).toBeGreaterThan(0); + + const second = await object.fillHistory(now); + expect(second.gaps).toEqual([]); + expect(calls).toHaveLength(1); + }); + + it("does not count a weekend or a holiday as missing, but does a week of outage", async () => { + // Christmas, and a provider down from 2 to 13 March. + const holiday = (day: string) => day === "2025-12-25" || day === "2025-12-26"; + const outage = (day: string) => day >= "2026-03-02" && day <= "2026-03-13"; + stubFetch((url) => (url.includes("frankfurter") ? frankfurterRange(url, (day) => holiday(day) || outage(day)) : null)); + const object = fx(); + await object.fillHistory(now); + + vi.unstubAllGlobals(); + const calls = stubFetch((url) => (url.includes("frankfurter") ? frankfurterRange(url) : null)); + const outcome = await object.fillHistory(now); + + // Every day between the Friday before and the Monday after, in one request. + expect(outcome.gaps).toEqual([{ from: "2026-02-28", to: "2026-03-15" }]); + expect(calls).toHaveLength(1); + expect(outcome.stored).toBeGreaterThan(0); + }); + + it("is what a day's rate is looked for in, a week back at most", async () => { + stubFetch((url) => (url.includes("frankfurter") ? frankfurterBody("2026-01-05", { EUR: 0.9 }) : null)); + const object = fx(); + await object.refresh("2026-01-05"); + + // Last January's rate does not answer for September, so asking is worth it. + stubFetch(() => null); + expect(await object.backfill("2026-09-21", "EUR", now)).toBe(true); + // But it does answer for the weekend after it. + expect(await object.backfill("2026-01-10", "EUR", now)).toBe(false); + }); +}); + describe("the month blobs", () => { it("are written whole, from what the object holds", async () => { stubFetch((url) => (url.includes("frankfurter") ? frankfurterBody("2026-09-24", { EUR: 0.92, GBP: 0.78 }) : null)); diff --git a/server/test/ledger.test.ts b/server/test/ledger.test.ts index 0e6fff7..6e311cd 100644 --- a/server/test/ledger.test.ts +++ b/server/test/ledger.test.ts @@ -379,3 +379,29 @@ describe("a large page", () => { expect(page.entries.every((entry) => entry.payers.length === 1 && entry.shares.length === 2)).toBe(true); }); }); + +/** + * One expense among more people than one SQL statement can name: a share is + * four bound values and a payer three, against a limit of 100 a statement. + */ +describe("an expense shared by a crowd", () => { + it("is stored whole, every share and every payer", async () => { + const { groupId, ravi } = await makeGroup(); + const object = stub(groupId); + const people = [ravi.id]; + for (let index = 0; index < 39; index++) { + people.push(ok(await object.putMember(freshId("m"), { displayName: `Guest ${index}`, upiVpa: null, leftAt: null }, RAVI)).id); + } + + const wedding = expense({ + id: freshId("e"), + amountMinor: 4000, + payers: people.map((memberId) => ({ memberId, amountMinor: 100 })), + shares: people.map((memberId) => ({ memberId, amountMinor: 100, weightMicros: 1_000_000 })), + }); + const stored = ok(await saveEntry(object, wedding, RAVI)); + + expect(stored.payers).toHaveLength(40); + expect(stored.shares).toHaveLength(40); + }); +}); diff --git a/server/test/reauth.test.ts b/server/test/reauth.test.ts index aa2b397..ab7cdb8 100644 --- a/server/test/reauth.test.ts +++ b/server/test/reauth.test.ts @@ -100,7 +100,60 @@ describe("deleting an account with an address", () => { }); }); +/** + * A session somebody else got hold of must not be able to point the account + * at their own address or Google account: after that, the confirmation above + * would go to them. + */ +describe("changing how an account with an address signs in", () => { + it("is refused for an address once the sign-in is old", async () => { + const account = await googleAccount("relink-stale-email"); + await age(account.id); + + const response = await call("/identity/email", account.token, { method: "POST", body: { email: "thief@example.com" } }); + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ error: { code: "reauth_required" } }); + }); + + it("is refused for a Google account once the sign-in is old", async () => { + const account = await googleAccount("relink-stale-google"); + await age(account.id); + + const linked = await call("/identity/google", account.token, { method: "POST", body: { idToken: await googleIdToken({ sub: "thief", email: "thief@example.com" }), nonce: null, allowSignIn: false } }); + expect(linked.status).toBe(403); + expect(await linked.json()).toMatchObject({ error: { code: "reauth_required" } }); + + const redirect = await call("/identity/google/redirect", account.token, { method: "POST", body: { callbackUrl: "http://localhost:8787/app/welcome", allowSignIn: false } }); + expect(redirect.status).toBe(403); + }); + + it("still lets an old session sign in to a different account", async () => { + const account = await googleAccount("relink-stale-switch"); + await age(account.id); + + const response = await call("/identity/google", account.token, { method: "POST", body: { idToken: await googleIdToken({ sub: "relink-other", email: "other@example.com" }), nonce: null, allowSignIn: true } }); + expect(response.status).toBe(200); + const outcome = (await response.json()) as IdentityOutcome; + expect(outcome.outcome).toBe("replaced"); + expect(outcome.strandedUserId).toBe(account.id); + }); + + it("goes ahead straight after signing in", async () => { + const account = await googleAccount("relink-fresh"); + const response = await call("/identity/email", account.token, { method: "POST", body: { email: "relink-fresh-new@example.com" } }); + expect(await response.json()).toEqual({ flow: "linkPending" }); + }); +}); + describe("a guest", () => { + it("attaches an address however old the session, since that is how it keeps its groups", async () => { + const guest = await signInAsGuest(); + await age(guest.id); + + const response = await call("/identity/email", guest.token, { method: "POST", body: { email: "old-guest@example.com" } }); + expect(await response.json()).toEqual({ flow: "linkPending" }); + }); + /** Nothing identifies a guest but the session, so there is nothing more to ask for. */ it("deletes without confirming, however old the session", async () => { const guest = await signInAsGuest(); diff --git a/server/test/roster.test.ts b/server/test/roster.test.ts index 995812e..19da10b 100644 --- a/server/test/roster.test.ts +++ b/server/test/roster.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { defaultGroupLook } from "../src/db/appearance"; -import { deleteEntry, editGroup, editMember, evenly, freshId, makeGroup, makeGroupOfTwo, ok, PRIYA, RAVI, refusal, saveEntry, stub, ZARA } from "./group"; +import { deleteEntry, draftOf, editGroup, editMember, evenly, freshId, makeGroup, makeGroupOfTwo, ok, PRIYA, RAVI, refusal, saveEntry, stub, ZARA } from "./group"; /** What one member of a group can do to another, which is a different question from what a stranger can do and has a much less obvious answer. */ @@ -54,14 +54,28 @@ describe("what a member may change about somebody else", () => { }); describe("leaving, and being removed", () => { - it("is always yours to do, settled or not", async () => { + it("is yours to do once you are settled", async () => { + const { groupId, ravi } = await makeGroupOfTwo(); + + const left = ok(await editMember(groupId, ravi.id, RAVI, { leftAt: new Date().toISOString() })); + expect(left.leftAt).not.toBeNull(); + }); + + /** Otherwise the balance stays behind with somebody who can no longer see it. */ + it("but not while you still owe or are owed", async () => { const { groupId, ravi, priya } = await makeGroupOfTwo(); const object = stub(groupId); ok(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id, priya.id], 1000), RAVI)); - const left = ok(await editMember(groupId, ravi.id, RAVI, { leftAt: new Date().toISOString() })); - expect(left.leftAt).not.toBeNull(); + const refused = refusal(await editMember(groupId, ravi.id, RAVI, { leftAt: new Date().toISOString() })); + expect(refused.code).toBe("not_settled"); + }); + + it("is stamped with the server's own time, whatever the device sent", async () => { + const { groupId, ravi } = await makeGroupOfTwo(); + const left = ok(await editMember(groupId, ravi.id, RAVI, { leftAt: "2001-01-01T00:00:00.000Z" })); + expect(Date.parse(left.leftAt ?? "")).toBeGreaterThan(Date.parse("2026-01-01")); }); /** @@ -112,6 +126,15 @@ describe("leaving, and being removed", () => { expect(after.hasMore).toBe(false); }); + it("including what the group is called after they went", async () => { + const { groupId, priya } = await makeGroupOfTwo(); + const object = stub(groupId); + ok(await editMember(groupId, priya.id, RAVI, { leftAt: new Date().toISOString() })); + ok(await editGroup(groupId, RAVI, { name: "Somewhere new" })); + + expect(ok(await object.changes(PRIYA, 0, 500)).group?.name).not.toBe("Somewhere new"); + }); + it("and stops them writing to it at all", async () => { const { groupId, ravi, priya } = await makeGroupOfTwo(); const object = stub(groupId); @@ -121,6 +144,89 @@ describe("leaving, and being removed", () => { }); }); +describe("coming back", () => { + /** Being pulled back into a group is not something somebody else gets to decide for you. */ + it("is not for anybody else to decide, for somebody with an account", async () => { + const { groupId, priya } = await makeGroupOfTwo(); + ok(await editMember(groupId, priya.id, PRIYA, { leftAt: new Date().toISOString() })); + + const refused = refusal(await editMember(groupId, priya.id, RAVI, { leftAt: null })); + expect(refused.code).toBe("forbidden"); + }); + + it("but a removed placeholder can be put back, on the record", async () => { + const { groupId, priya } = await makeGroup(); + const object = stub(groupId); + ok(await editMember(groupId, priya.id, RAVI, { leftAt: new Date().toISOString() })); + + const back = ok(await editMember(groupId, priya.id, RAVI, { leftAt: null })); + expect(back.leftAt).toBeNull(); + + const events = ok(await object.changes(RAVI, 0, 500)).events; + expect(events.at(-1)).toMatchObject({ kind: "member_added", subjectId: priya.id }); + }); + + it("is a link away for somebody who left, into the same place", async () => { + const { groupId, priya } = await makeGroupOfTwo(); + const object = stub(groupId); + ok(await editMember(groupId, priya.id, PRIYA, { leftAt: new Date().toISOString() })); + + const link = ok(await object.createLink(RAVI)); + expect(ok(await object.peekLink(link.token, PRIYA))).toMatchObject({ isMember: false, hasLeft: true }); + + // Even asking to arrive as somebody new: one account, one place. + const { member } = ok(await object.join(link.token, PRIYA, { memberId: null, displayName: "Priya again" })); + expect(member.id).toBe(priya.id); + expect(member.leftAt).toBeNull(); + expect(ok(await object.changes(PRIYA, 0, 500)).events.at(-1)).toMatchObject({ kind: "member_joined", subjectId: priya.id }); + }); + + it("leaves an invite for somebody else's place unspent", async () => { + const { groupId, priya } = await makeGroupOfTwo(); + const object = stub(groupId); + ok(await editMember(groupId, priya.id, PRIYA, { leftAt: new Date().toISOString() })); + + const zara = ok(await object.putMember(freshId("m"), { displayName: "Zara", upiVpa: null, leftAt: null }, RAVI)); + const invite = ok(await object.createInvite(zara.id, RAVI)); + + expect(ok(await object.join(invite.token, PRIYA, { memberId: null, displayName: null })).member.id).toBe(priya.id); + expect(ok(await object.join(invite.token, ZARA, { memberId: null, displayName: null })).member.id).toBe(zara.id); + }); +}); + +/** + * Somebody who has left cannot see the group, so nothing written after they + * go may change what they owe. Their past expenses stay editable in every + * other respect. + */ +describe("the money of somebody who has left", () => { + async function departedPlaceholder() { + const fixture = await makeGroup(); + const object = stub(fixture.groupId); + const dinner = ok(await saveEntry(object, evenly(freshId("e"), fixture.ravi.id, [fixture.ravi.id, fixture.priya.id], 1000), RAVI)); + const settled = ok(await saveEntry(object, { ...evenly(freshId("e"), fixture.priya.id, [fixture.ravi.id], 500), kind: "settlement" }, RAVI)); + ok(await editMember(fixture.groupId, fixture.priya.id, RAVI, { leftAt: new Date().toISOString() })); + return { ...fixture, object, dinner, settled }; + } + + it("cannot be charged again", async () => { + const { object, ravi, priya } = await departedPlaceholder(); + const refused = refusal(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id, priya.id], 600), RAVI)); + expect(refused.code).toBe("forbidden"); + expect(refused.message).toContain("Priya"); + }); + + it("is not reopened by deleting an expense they were part of", async () => { + const { object, dinner } = await departedPlaceholder(); + expect(refusal(await deleteEntry(object, dinner, RAVI)).code).toBe("forbidden"); + }); + + it("leaves the rest of that expense editable", async () => { + const { object, dinner } = await departedPlaceholder(); + expect(ok(await saveEntry(object, draftOf(dinner, { description: "Dinner at Thalassa" }), RAVI)).description).toBe("Dinner at Thalassa"); + }); +}); + describe("the group itself", () => { it("can be renamed by any member, including one who did not create it", async () => { const { groupId } = await makeGroupOfTwo(); diff --git a/test/application/forms_test.dart b/test/application/forms_test.dart new file mode 100644 index 0000000..7a53b92 --- /dev/null +++ b/test/application/forms_test.dart @@ -0,0 +1,271 @@ +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:opensplit/application/entry_form.dart'; +import 'package:opensplit/application/ledger_providers.dart' + show GroupLedger, currenciesProvider, groupLedgerProvider; +import 'package:opensplit/application/local_providers.dart'; +import 'package:opensplit/application/preferences_providers.dart'; +import 'package:opensplit/application/session_providers.dart'; +import 'package:opensplit/application/settlement_form.dart'; +import 'package:opensplit/data/local/database.dart'; +import 'package:opensplit/domain/entry_draft.dart'; +import 'package:opensplit/domain/split/splitter.dart'; +import 'package:opensplit_api/opensplit_api.dart' show EntryKind, SplitKind; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../harness.dart'; + +/// The two forms that record money, driven the way their screens drive them: +/// one intent at a time, then read back. +void main() { + final now = DateTime.utc(2026, 8, 27); + late AppDatabase db; + late ProviderContainer container; + + setUp(() async { + db = await testDatabase(NativeDatabase.memory()); + await db + .into(db.groups) + .insert( + GroupsCompanion.insert( + id: 'g1', + name: 'Goa', + defaultCurrency: 'INR', + createdAt: now, + ), + ); + await db.batch((batch) { + batch.insertAll(db.members, [ + MembersCompanion.insert( + id: 'm-ravi', + groupId: 'g1', + profileId: const Value(testAccountId), + displayName: 'Ravi', + joinedAt: now, + ), + MembersCompanion.insert( + id: 'm-priya', + groupId: 'g1', + displayName: 'Priya', + upiVpa: const Value('priya@okaxis'), + joinedAt: now, + ), + MembersCompanion.insert( + id: 'm-arun', + groupId: 'g1', + displayName: 'Arun', + upiVpa: const Value('arun@okicici'), + joinedAt: now, + ), + ]); + }); + + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + container = ProviderContainer.test( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + appDatabaseProvider.overrideWithValue(db), + currentAccountIdProvider.overrideWithValue(testAccountId), + signedInProvider.overrideWithValue(true), + deviceZoneProvider.overrideWith((ref) async => testZone), + ], + ); + }); + + tearDown(() => db.close()); + + /// The ledger, resolved, which every form reads when it acts. + Future ledger() async { + container.listen(groupLedgerProvider('g1'), (_, _) {}); + container.listen(currenciesProvider, (_, _) {}); + for (var i = 0; i < 100; i++) { + final resolved = container.read(groupLedgerProvider('g1')); + if (resolved != null) return resolved; + await Future.delayed(const Duration(milliseconds: 5)); + } + throw StateError('the ledger never resolved'); + } + + group('settling up', () { + Future< + ({ + SettlementForm form, + SettlementFormState Function() state, + GroupLedger ledger, + }) + > + open({String? toId, int? amountMinor}) async { + final provider = settlementFormProvider( + 'g1', + toId: toId, + amountMinor: amountMinor, + ); + container.listen(provider, (_, _) {}); + await container.read(provider.future); + return ( + form: container.read(provider.notifier), + state: () => container.read(provider).requireValue, + ledger: await ledger(), + ); + } + + test('opens on the suggested payment, paid by this account', () async { + final settle = await open(toId: 'm-priya', amountMinor: 125050); + + expect(settle.state().fromId, 'm-ravi'); + expect(settle.state().toId, 'm-priya'); + expect(settle.state().amount, '1250.50'); + expect(settle.state().handleIn(settle.ledger), 'priya@okaxis'); + }); + + test('never pays a handle typed for somebody else', () async { + final settle = await open(toId: 'm-priya'); + + settle.form.setHandle('priya.new@okhdfc'); + expect(settle.state().handleIn(settle.ledger), 'priya.new@okhdfc'); + + settle.form.choosePayee('m-arun'); + expect(settle.state().handleIn(settle.ledger), 'arun@okicici'); + + // What was typed for Priya is still hers. + settle.form.choosePayee('m-priya'); + expect(settle.state().handleIn(settle.ledger), 'priya.new@okhdfc'); + }); + + test('choosing the payee as the payer leaves nobody being paid', () async { + final settle = await open(toId: 'm-priya'); + + settle.form.choosePayer('m-priya'); + + expect(settle.state().fromId, 'm-priya'); + expect(settle.state().toId, isNull); + }); + + test('records what was typed, decimal comma and all', () async { + final settle = await open(toId: 'm-priya'); + + settle.form.setAmount('12,50'); + expect(await settle.form.record(), isTrue); + + final recorded = await db.select(db.entries).getSingle(); + expect(recorded.kind, EntryKind.settlement); + expect(recorded.amountMinor, 1250); + }); + + test('says what is missing rather than recording nothing', () async { + final settle = await open(); + + settle.form.setAmount('100'); + expect(await settle.form.record(), isFalse); + expect(settle.state().error, 'Choose who is paying whom.'); + + // Any change is a new attempt, so the complaint goes. + settle.form.choosePayee('m-priya'); + expect(settle.state().error, isNull); + }); + }); + + group('an expense', () { + Future<({EntryForm form, EntryFormState Function() state})> open({ + String? entryId, + }) async { + await ledger(); + final provider = entryFormProvider('g1', entryId: entryId); + container.listen(provider, (_, _) {}); + await container.read(provider.future); + return ( + form: container.read(provider.notifier), + state: () => container.read(provider).requireValue, + ); + } + + Currency inr() => container.read(currenciesProvider).requireValue['INR']!; + + test('starts split between everyone, paid by this account', () async { + final expense = await open(); + expense.form.setAmount('300'); + + final check = expense.state().check(inr(), groupId: 'g1'); + final draft = (check as DraftReady).draft; + expect(draft.payerAmounts, {'m-ravi': 30000}); + expect(draft.split, isA()); + expect(expense.state().preview(inr()), { + 'm-arun': 10000, + 'm-priya': 10000, + 'm-ravi': 10000, + }); + }); + + test('takes percentages typed with a decimal comma', () async { + final expense = await open(); + expense.form + ..setAmount('200') + ..chooseSplitKind(SplitKind.percent) + ..setInSplit('m-arun', included: false) + ..setPercent('m-ravi', '50,5') + ..setPercent('m-priya', '49,5'); + + final draft = + (expense.state().check(inr(), groupId: 'g1') as DraftReady).draft; + expect(draft.split.resolve(20000), [ + (memberId: 'm-priya', amountMinor: 9900, weightMicros: 49500000), + (memberId: 'm-ravi', amountMinor: 10100, weightMicros: 50500000), + ]); + }); + + test('says what is missing before anything is written', () async { + final expense = await open(); + expense.form + ..setAmount('300') + ..chooseSplitKind(SplitKind.exact); + + expect(await expense.form.save(), isFalse); + expect( + expense.state().error, + 'Check the split — some amounts are missing.', + ); + expect(await db.select(db.entries).get(), isEmpty); + }); + + test('going back to one payer keeps the first of several', () async { + final expense = await open(); + expense.form + ..setSeveralPayers(true) + ..setPaying('m-priya', paying: true) + ..setSeveralPayers(false); + + expect(expense.state().payers, {'m-ravi'}); + }); + + test( + 'is not saved over a version somebody else changed meanwhile', + () async { + final created = await open(); + created.form + ..setAmount('300') + ..setDescription('Dinner'); + expect(await created.form.save(), isTrue); + final id = (await db.select(db.entries).getSingle()).id; + + final editing = await open(entryId: id); + editing.form.setDescription('Dinner at Thalassa'); + + // A sync lands underneath the open editor. + await (db.update(db.entries)..where((t) => t.id.equals(id))).write( + const EntriesCompanion(amountMinor: Value(40000)), + ); + + expect(await editing.form.save(), isFalse); + expect( + editing.state().error, + contains('changed while you were editing'), + ); + // And the editor kept what was typed. + expect(editing.state().description, 'Dinner at Thalassa'); + }, + ); + }); +} diff --git a/test/data/fx_repository_test.dart b/test/data/fx_repository_test.dart index 2738b7e..c3e7579 100644 --- a/test/data/fx_repository_test.dart +++ b/test/data/fx_repository_test.dart @@ -42,6 +42,29 @@ void main() { }); } + group('how old a rate may be', () { + test("a weekend is answered by Friday's publication", () async { + await publish('2026-08-21', {'USD': 1, 'INR': 95.7}); + final quote = await repository.quote( + base: 'USD', + quote: 'INR', + asOf: DateTime.utc(2026, 8, 23), + ); + expect(quote?.rate, 95.7); + }); + + test('a rate from over a week before does not answer for a day', () async { + // Otherwise one old backfill would quietly price every day after it. + await publish('2026-01-05', {'USD': 1, 'INR': 90}); + final quote = await repository.quote( + base: 'USD', + quote: 'INR', + asOf: DateTime.utc(2026, 8, 23), + ); + expect(quote, isNull); + }); + }); + group('pivot lookup', () { test('derives a pair neither side of which is the pivot', () async { // 3.6725 AED and 95.7 INR to the dollar. diff --git a/test/data/live_backend.dart b/test/data/live_backend.dart index ef00f9a..eb13931 100644 --- a/test/data/live_backend.dart +++ b/test/data/live_backend.dart @@ -147,7 +147,7 @@ class Device { Device._(this.client, this.tap, this.profileId, {required this.pageSize}) : db = AppDatabase(NativeDatabase.memory()) { outbox = OutboxQueue(db); - groups = DriftGroupRepository(db, outbox: outbox); + groups = DriftGroupRepository(db, outbox: outbox, accountId: profileId); entries = DriftEntryRepository(db, outbox: outbox); sync = engine(pageSize: pageSize); } @@ -206,6 +206,7 @@ class Device { db: db, client: client, outbox: outbox, + accountId: profileId, pageSize: pageSize, requestTimeout: requestTimeout, ); diff --git a/test/data/repositories_test.dart b/test/data/repositories_test.dart index bdf2ebe..bd26d68 100644 --- a/test/data/repositories_test.dart +++ b/test/data/repositories_test.dart @@ -82,6 +82,59 @@ void main() { expect(withLeft.firstWhere((m) => m.id == arun.id).isActive, isFalse); }); + test('a group this account left is listed as left, not current', () async { + final mine = DriftGroupRepository(db, accountId: 'me'); + final created = await mine.createGroup( + name: 'Old flat', + defaultCurrency: 'INR', + creatorDisplayName: 'Ravi', + creatorProfileId: 'me', + ); + + await mine.leaveGroup(memberId: created.creator.id); + + final listing = (await mine.watchListings().first).single; + expect(listing.hasLeft, isTrue); + expect(listing.isCurrent, isFalse); + // Left, not archived: a sync bringing the group row back cannot undo it. + expect(listing.group.isArchived, isFalse); + }); + + test('groups are listed by their newest activity', () async { + final mine = DriftGroupRepository(db, accountId: 'me'); + final older = await mine.createGroup( + name: 'Made first', + defaultCurrency: 'INR', + creatorDisplayName: 'Ravi', + creatorProfileId: 'me', + ); + await mine.createGroup( + name: 'Made second', + defaultCurrency: 'INR', + creatorDisplayName: 'Ravi', + creatorProfileId: 'me', + ); + await entries.create( + EntryDraft( + groupId: older.group.id, + currency: 'INR', + amountMinor: 1000, + description: 'Dinner', + split: EqualSplit([older.creator.id]), + payerAmounts: {older.creator.id: 1000}, + entryDate: DateTime.utc(2026, 1, 1), + ), + createdBy: older.creator.id, + now: DateTime.now().add(const Duration(minutes: 1)), + ); + + final names = [ + for (final listing in await mine.watchListings().first) + listing.group.name, + ]; + expect(names, ['Made first', 'Made second']); + }); + test('archived groups drop out of the default list', () async { final created = await groups.createGroup( name: 'Old Trip', @@ -91,11 +144,9 @@ void main() { await groups.setArchived(created.group.id, archived: true); - expect(await groups.watchGroups().first, isEmpty); - expect( - await groups.watchGroups(includeArchived: true).first, - hasLength(1), - ); + final listed = (await groups.watchListings().first).single; + expect(listed.group.isArchived, isTrue); + expect(listed.isCurrent, isFalse); }); }); diff --git a/test/data/sync_test.dart b/test/data/sync_test.dart index 20c3e51..24c7a2b 100644 --- a/test/data/sync_test.dart +++ b/test/data/sync_test.dart @@ -918,7 +918,7 @@ void main() { liveTest('a group left behind is not rediscovered', () async { final g = await seedGroup(joined: false); await a.sync.syncGroup(g.groupId); - await a.groups.leaveGroup(groupId: g.groupId, memberId: g.ravi); + await a.groups.leaveGroup(memberId: g.ravi); await a.sync.syncGroup(g.groupId); expect( @@ -927,6 +927,21 @@ void main() { ); }); + liveTest( + 'nor pulled again by the device that left it, once it is confirmed', + () async { + final g = await seedGroup(joined: false); + await a.sync.syncGroup(g.groupId); + await a.groups.leaveGroup(memberId: g.ravi); + + // Pushed and read back: the server has said so. + await a.sync.syncGroup(g.groupId); + expect(await a.sync.discoverGroups(), isEmpty); + // Still on the device, read-only, among the groups put away. + expect((await a.groups.watchListings().first).single.hasLeft, isTrue); + }, + ); + liveTest( 'failed discovery is not a successful local-only answer', () async { diff --git a/test/domain/balance_test.dart b/test/domain/balance_test.dart index d6cbbbd..d2cf1a9 100644 --- a/test/domain/balance_test.dart +++ b/test/domain/balance_test.dart @@ -1,6 +1,8 @@ import 'package:opensplit/domain/balance/balance_fold.dart'; import 'package:opensplit/domain/balance/member_balance.dart'; +import 'package:opensplit/domain/balance/pairwise.dart'; import 'package:opensplit/domain/balance/simplify.dart'; +import 'package:opensplit/domain/balance/transfer_basis.dart'; import 'package:opensplit/domain/models/entry.dart'; import 'package:opensplit_api/opensplit_api.dart' show EntryKind, SplitKind; import 'package:test/test.dart'; @@ -333,4 +335,141 @@ void main() { }, ); }); + + group('pairwiseDebts', () { + test('owes each payer only for the expenses actually shared with them', () { + final transfers = pairwiseDebts([ + _entry( + id: 'dinner', + currency: 'INR', + amountMinor: 300, + payers: {'a': 300}, + shares: {'a': 100, 'b': 100, 'c': 100}, + ), + _entry( + id: 'taxi', + currency: 'INR', + amountMinor: 80, + payers: {'c': 80}, + shares: {'d': 80}, + ), + ]); + + expect(transfers, [ + const Transfer( + fromMemberId: 'b', + toMemberId: 'a', + currency: 'INR', + amountMinor: 100, + ), + const Transfer( + fromMemberId: 'c', + toMemberId: 'a', + currency: 'INR', + amountMinor: 100, + ), + const Transfer( + fromMemberId: 'd', + toMemberId: 'c', + currency: 'INR', + amountMinor: 80, + ), + ]); + }); + + test('nets two people against each other', () { + final transfers = pairwiseDebts([ + _entry( + id: 'one', + currency: 'INR', + amountMinor: 100, + payers: {'a': 100}, + shares: {'b': 100}, + ), + _entry( + id: 'two', + currency: 'INR', + amountMinor: 60, + payers: {'b': 60}, + shares: {'a': 60}, + ), + ]); + + expect(transfers, [ + const Transfer( + fromMemberId: 'b', + toMemberId: 'a', + currency: 'INR', + amountMinor: 40, + ), + ]); + }); + + test('owes several payers in proportion to what each paid', () { + final debts = debtsWithin( + _entry( + id: 'shared', + currency: 'INR', + amountMinor: 400, + payers: {'a': 300, 'b': 100}, + shares: {'c': 400}, + ), + ); + expect(debts, {('c', 'a'): 300, ('c', 'b'): 100}); + }); + + test('settling every pair leaves nothing owed, in any ledger', () { + final gen = EntryGen(424242); + for (var i = 0; i < 2000; i++) { + final members = gen.memberIds(2 + gen.random.nextInt(9)); + final entries = gen.entries(members, 1 + gen.random.nextInt(30)); + + final transfers = pairwiseDebts(entries); + final settled = [ + ...entries, + for (var j = 0; j < transfers.length; j++) + _settlement(transfers[j], j), + ]; + + expect( + foldBalances(settled), + isEmpty, + reason: 'seed ${gen.seed}, case $i', + ); + } + }); + }); + + group('basisOf', () { + test('adds up to the payment it explains, in either mode', () { + final gen = EntryGen(73); + for (var i = 0; i < 500; i++) { + final members = gen.memberIds(2 + gen.random.nextInt(6)); + final entries = gen.entries(members, 1 + gen.random.nextInt(20)); + final balances = foldBalances(entries); + + for (final transfer in pairwiseDebts(entries)) { + expect( + basisOf(transfer, entries, simplified: false).net, + -transfer.amountMinor, + reason: 'seed ${gen.seed}, case $i', + ); + } + for (final transfer in simplifyDebts(balances)) { + final owed = balances + .firstWhere( + (b) => + b.memberId == transfer.fromMemberId && + b.currency == transfer.currency, + ) + .balanceMinor; + expect( + basisOf(transfer, entries, simplified: true).net, + owed, + reason: 'seed ${gen.seed}, case $i', + ); + } + } + }); + }); } diff --git a/test/domain/currency_test.dart b/test/domain/currency_test.dart index af58255..ad650fc 100644 --- a/test/domain/currency_test.dart +++ b/test/domain/currency_test.dart @@ -58,8 +58,35 @@ void main() { expect(kwd.parseToMinor('2.5'), 2500); }); - test('strips grouping separators and surrounding space', () { - expect(inr.parseToMinor(' 1,20,000.50 '), 12000050); + test('takes a comma as the decimal point, and trims space', () { + // A German or French number keyboard has no dot key. Read as grouping, + // "12,50" would be a thousand and a quarter. + expect(inr.parseToMinor(' 12,50 '), 1250); + expect(kwd.parseToMinor('2,5'), 2500); + }); + + test( + 'refuses grouping, which means different things on different keyboards', + () { + expect(inr.parseToMinor('1,20,000.50'), isNull); + expect(inr.parseToMinor('1,250'), isNull); + }, + ); + + test('refuses an amount too long to stay exact', () { + expect(inr.parseToMinor('999999999999'), 99999999999900); + expect(inr.parseToMinor('9999999999999'), isNull); + expect(inr.parseToMinor('99999999999999999999'), isNull); + }); + + test('accepts every prefix of a valid amount as a field is typed', () { + final shape = amountInputPattern(inr.exponent); + for (final typed in ['', '1', '12', '12,', '12.5', '12,50']) { + expect(shape.hasMatch(typed), isTrue, reason: typed); + } + for (final typed in ['12.505', '1.2.3', '-1', '1,250.00', 'abc']) { + expect(shape.hasMatch(typed), isFalse, reason: typed); + } }); test('refuses more precision than the currency has', () { diff --git a/test/presentation/guest_account_test.dart b/test/presentation/guest_account_test.dart index f37b05c..29d42dc 100644 --- a/test/presentation/guest_account_test.dart +++ b/test/presentation/guest_account_test.dart @@ -27,6 +27,7 @@ Future _pumpPrompt( ProviderScope( overrides: [ appDatabaseProvider.overrideWithValue(db), + currentAccountIdProvider.overrideWithValue(testAccountId), accountProvider.overrideWith( (ref) => Stream.value(_account(guest: guest)), ), diff --git a/test/presentation/settle_up_test.dart b/test/presentation/settle_up_test.dart new file mode 100644 index 0000000..1680be0 --- /dev/null +++ b/test/presentation/settle_up_test.dart @@ -0,0 +1,180 @@ +import 'package:drift/drift.dart' show Value; +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:opensplit/data/local/database.dart'; +import 'package:opensplit/presentation/app.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../harness.dart'; + +/// Settling up hands real money to a payment app, so the handle it hands over +/// has to be the handle of the person named beside it. + +Future _beats(WidgetTester tester) async { + for (var i = 0; i < 25; i++) { + await tester.pump(const Duration(milliseconds: 40)); + } +} + +Future _open(WidgetTester tester, AppDatabase db, String location) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + tester.view.physicalSize = const Size(400, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + signedInApp(db: db, prefs: prefs, child: const OpenSplitApp()), + ); + await _beats(tester); + // Into the group first, then on to settling up, as a person gets there. + final router = GoRouter.of(tester.element(find.byType(Scaffold).first)); + router.go('/g/g1'); + await _beats(tester); + router.push(location); + await _beats(tester); +} + +Future _unmount(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(Duration.zero); +} + +Future _seed(AppDatabase db) async { + final now = DateTime.utc(2026, 8, 26); + await db + .into(db.groups) + .insert( + GroupsCompanion.insert( + id: 'g1', + name: 'Flat 4B', + defaultCurrency: 'INR', + createdBy: const Value('m-ravi'), + createdAt: now, + ), + ); + await db.batch((batch) { + batch.insertAll(db.members, [ + MembersCompanion.insert( + id: 'm-ravi', + groupId: 'g1', + profileId: const Value(testAccountId), + displayName: 'Ravi', + joinedAt: now, + ), + MembersCompanion.insert( + id: 'm-priya', + groupId: 'g1', + displayName: 'Priya', + upiVpa: const Value('priya@okaxis'), + joinedAt: now, + ), + MembersCompanion.insert( + id: 'm-arun', + groupId: 'g1', + displayName: 'Arun', + upiVpa: const Value('arun@okicici'), + joinedAt: now, + ), + ]); + }); +} + +/// The UPI ID field, found by its label, which names the payee. +Finder _upiField(String payee) => find.ancestor( + of: find.text("$payee's UPI ID"), + matching: find.byType(TextField), +); + +String _textOf(WidgetTester tester, Finder field) => + tester.widget(field).controller!.text; + +/// Opens the picker labelled [label] and picks [name] from its menu, not +/// from another picker that happens to be showing the same name. +Future _choose(WidgetTester tester, String label, String name) async { + await tester.tap(find.text(label)); + await _beats(tester); + await tester.tap(find.widgetWithText(MenuItemButton, name).last); + await _beats(tester); +} + +Future _choosePayee(WidgetTester tester, String name) => + _choose(tester, 'Who is being paid', name); + +/// What a member picker shows, found by its label. +String _shownIn(WidgetTester tester, String label) => _textOf( + tester, + find.ancestor(of: find.text(label), matching: find.byType(TextField)), +); + +void main() { + late AppDatabase db; + + setUp(() async { + db = await testDatabase(); + await _seed(db); + }); + + tearDown(() => db.close()); + + testWidgets('choosing another payee hands over their UPI ID, not the last ' + "one's", (tester) async { + await _open(tester, db, '/g/g1/settle?to=m-priya¤cy=INR'); + expect(_textOf(tester, _upiField('Priya')), 'priya@okaxis'); + + await _choosePayee(tester, 'Arun'); + expect(_textOf(tester, _upiField('Arun')), 'arun@okicici'); + + await _unmount(tester); + }); + + testWidgets('choosing the payee as the payer clears who is being paid, on ' + 'screen too', (tester) async { + await _open(tester, db, '/g/g1/settle?to=m-priya¤cy=INR'); + expect(_shownIn(tester, 'Who is being paid'), 'Priya'); + + await _choose(tester, 'Who is paying', 'Priya'); + + expect(_shownIn(tester, 'Who is paying'), 'Priya'); + expect(_shownIn(tester, 'Who is being paid'), isEmpty); + // Nothing to hand a payment app until somebody is chosen. + expect(find.text("Priya's UPI ID"), findsNothing); + + await _unmount(tester); + }); + + testWidgets('a typed UPI ID stays put until the payee changes', ( + tester, + ) async { + await _open(tester, db, '/g/g1/settle?to=m-priya¤cy=INR'); + + await tester.enterText(_upiField('Priya'), 'priya.new@okhdfc'); + await _beats(tester); + expect(_textOf(tester, _upiField('Priya')), 'priya.new@okhdfc'); + + await _choosePayee(tester, 'Arun'); + expect(_textOf(tester, _upiField('Arun')), 'arun@okicici'); + + await _unmount(tester); + }); + + testWidgets('an amount typed with a decimal comma is recorded as written', ( + tester, + ) async { + await _open(tester, db, '/g/g1/settle?to=m-priya¤cy=INR'); + + await tester.enterText( + find.ancestor(of: find.text('Amount'), matching: find.byType(TextField)), + '12,50', + ); + await _beats(tester); + await tester.tap(find.text('Record this payment')); + await _beats(tester); + + final recorded = await db.select(db.entries).getSingle(); + expect(recorded.amountMinor, 1250); + + await _unmount(tester); + }); +} diff --git a/test/presentation/sync_refresh_test.dart b/test/presentation/sync_refresh_test.dart index b192b51..a2efb05 100644 --- a/test/presentation/sync_refresh_test.dart +++ b/test/presentation/sync_refresh_test.dart @@ -10,6 +10,7 @@ import 'package:opensplit/application/session_providers.dart'; import 'package:opensplit/application/sync_coordinator.dart'; import 'package:opensplit/application/sync_providers.dart'; import 'package:opensplit/data/local/database.dart'; +import 'package:opensplit/data/repositories/drift_group_repository.dart'; import 'package:opensplit/data/sync/sync_engine.dart'; import 'package:opensplit/presentation/screens/group_detail_screen.dart'; import 'package:opensplit/presentation/screens/group_list_screen.dart'; @@ -51,9 +52,18 @@ Future _mount( ProviderScope( overrides: [ syncControllerProvider.overrideWith(() => sync), - groupsProvider( - includeArchived: true, - ).overrideWith((ref) => groups ?? Stream.value([_group])), + groupListingsProvider.overrideWith( + (ref) => (groups ?? Stream.value([_group])).map( + (list) => [ + for (final group in list) + GroupListing( + group: group, + hasLeft: false, + lastActivityAt: group.createdAt, + ), + ], + ), + ), groupProvider(_group.id).overrideWith((ref) => Stream.value(_group)), groupLedgerProvider(_group.id).overrideWith((ref) => _ledger), groupSyncProvider(_group.id).overrideWith((ref) async {}), From a37a11c938dad79a2201df871fd52d942529ea10 Mon Sep 17 00:00:00 2001 From: Seenu K Date: Mon, 5 Oct 2026 02:19:02 +0530 Subject: [PATCH 2/9] Complete sync audit implementation - Add stale base check for all edits in ledger - Replace separate activity table with lastActivityAt column on meta - Implement integer profile version counters in D1 - Replace per-group sync endpoints with batched POST /sync - Implement cross-group ID collision guard on client - Optimize partial syncs to pull only affected groups and exchange rates - Add local database secondary indexes - Use PRAGMA data_version to detect background handler writes - Replace outbox timestamps with position ordering --- CHANGELOG.md | 7 + README.md | 5 +- docs/architecture.md | 56 +- docs/openapi.json | 245 +- drift_schemas/drift_schema_v10.json | 2236 +++++++++++++++++ lib/application/push_providers.dart | 2 +- lib/application/sync_coordinator.dart | 44 +- lib/application/sync_providers.dart | 12 +- lib/data/local/database.dart | 22 +- lib/data/local/tables.dart | 30 +- lib/data/sync/apply_changes.dart | 87 +- lib/data/sync/outbox_queue.dart | 105 +- lib/data/sync/shared_feeds.dart | 38 +- lib/data/sync/sync_engine.dart | 222 +- lib/data/sync/wire.dart | 2 +- lib/presentation/app.dart | 2 +- lib/presentation/screens/members_screen.dart | 16 +- packages/opensplit_api/lib/opensplit_api.dart | 5 +- .../lib/src/api/entries_api.dart | 2 +- .../opensplit_api/lib/src/api/sync_api.dart | 139 +- .../opensplit_api/lib/src/deserialize.dart | 15 +- .../lib/src/model/group_cursor.dart | 44 + .../lib/src/model/group_cursor.g.dart | 20 + .../lib/src/model/group_ids.dart | 39 - .../lib/src/model/group_ids.g.dart | 23 - .../lib/src/model/group_refusal.dart | 61 + .../lib/src/model/group_refusal.g.dart | 58 + .../opensplit_api/lib/src/model/profile.dart | 12 +- .../lib/src/model/profile.g.dart | 3 + .../lib/src/model/profile_page.dart | 14 +- .../lib/src/model/profile_page.g.dart | 6 +- .../opensplit_api/lib/src/model/pull.dart | 50 + .../opensplit_api/lib/src/model/pull.g.dart | 37 + .../lib/src/model/pull_request.dart | 46 + .../lib/src/model/pull_request.g.dart | 28 + .../migration.sql | 12 + .../snapshot.json | 936 +++++++ server/src/api/account.ts | 49 +- server/src/api/invites.ts | 4 +- server/src/api/ledger.ts | 104 +- server/src/auth.ts | 4 +- server/src/db/d1/profile-version.ts | 22 + server/src/db/d1/schema.ts | 21 +- server/src/db/group/schema.ts | 1 + server/src/do/group/index.ts | 6 +- server/src/do/group/ledger.ts | 16 +- .../migration.sql | 1 + .../snapshot.json | 1379 ++++++++++ server/src/do/group/migrations/migrations.js | 4 +- server/src/do/group/roster.ts | 3 +- server/src/do/group/upkeep.ts | 23 +- server/src/forget.ts | 12 +- server/src/schemas/account.ts | 16 +- server/src/schemas/common.ts | 2 +- server/src/schemas/ledger.ts | 35 +- server/test/account.test.ts | 35 +- server/test/activity.test.ts | 3 +- server/test/api-types.ts | 2 +- server/test/api.test.ts | 98 +- server/test/dormancy.test.ts | 54 +- server/test/group.ts | 6 +- server/test/ledger.test.ts | 70 +- server/test/links.test.ts | 8 +- server/test/session.ts | 26 + test/application/sync_coordinator_test.dart | 96 +- test/application/sync_triggers_test.dart | 28 +- test/data/apply_changes_test.dart | 132 +- test/data/cloudflare_integration_test.dart | 72 +- test/data/external_sync_visibility_test.dart | 67 +- test/data/generated_migrations/schema.dart | 5 +- .../data/generated_migrations/schema_v10.dart | 1663 ++++++++++++ test/data/live_backend.dart | 36 +- test/data/outbox_test.dart | 90 +- test/data/sync_test.dart | 302 ++- test/presentation/unsynced_banner_test.dart | 2 +- 75 files changed, 8242 insertions(+), 936 deletions(-) create mode 100644 drift_schemas/drift_schema_v10.json create mode 100644 packages/opensplit_api/lib/src/model/group_cursor.dart create mode 100644 packages/opensplit_api/lib/src/model/group_cursor.g.dart delete mode 100644 packages/opensplit_api/lib/src/model/group_ids.dart delete mode 100644 packages/opensplit_api/lib/src/model/group_ids.g.dart create mode 100644 packages/opensplit_api/lib/src/model/group_refusal.dart create mode 100644 packages/opensplit_api/lib/src/model/group_refusal.g.dart create mode 100644 packages/opensplit_api/lib/src/model/pull.dart create mode 100644 packages/opensplit_api/lib/src/model/pull.g.dart create mode 100644 packages/opensplit_api/lib/src/model/pull_request.dart create mode 100644 packages/opensplit_api/lib/src/model/pull_request.g.dart create mode 100644 server/migrations/20261003013707_profile_versions/migration.sql create mode 100644 server/migrations/20261003013707_profile_versions/snapshot.json create mode 100644 server/src/db/d1/profile-version.ts create mode 100644 server/src/do/group/migrations/20261004204449_wooden_phil_sheldon/migration.sql create mode 100644 server/src/do/group/migrations/20261004204449_wooden_phil_sheldon/snapshot.json create mode 100644 test/data/generated_migrations/schema_v10.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 93700f5..2d75e29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,8 +30,15 @@ What changed in each version of the app, newest first. The format is - Groups you have left move to the archived list, read-only, and stop syncing. - Amounts can be typed with a comma as the decimal point. Thousands separators are no longer accepted while typing. - Exchange-rate estimates only use a rate from within a week of the expense's day, and the server keeps a full year of rates to draw on. +- Two people editing the same expense at once no longer quietly lose one edit: whichever arrives second is set aside, with the newer version shown, to be applied again on top of it. +- Saving something syncs that group alone, and all your groups are read in one request, so syncing uses far less data and battery. ### Fixed +- Settling up with somebody and then removing them while offline no longer leaves the removal refused once you are back online. +- One group that can't be read yet, such as one created offline, no longer stops your other groups from syncing. +- A group is no longer archived as unused while people are still editing its expenses. +- A name change could occasionally never reach the people you share groups with. +- Updates the app received in the background now show on screen as soon as you return, and screens no longer redraw after every sync when nothing changed. - A group where you're owed one currency and owe another now says both on the groups list, instead of calling it all owed to you. - Signing in with Google on Android works again. - A name you give while creating your first group now shows on your profile straight away. diff --git a/README.md b/README.md index 15591f7..6d574a3 100644 --- a/README.md +++ b/README.md @@ -102,8 +102,9 @@ cd server && pnpm format # Biome, across the whole repository; CI runs `pnpm outranks a personal default formatter. The server tests are not optional decoration. They cover the balance invariant -rejecting an expense that does not add up, that a stale edit is refused only -when applying it would move money, that entries cannot be hard-deleted, that an +rejecting an expense that does not add up, that an edit composed on an older +version is refused rather than undoing the one in between, that entries cannot +be hard-deleted, that an invite token can be spent exactly once by somebody with no other access to the group, and that a collected group answers everybody with a tombstone rather than refusing every device that still holds a copy. diff --git a/docs/architecture.md b/docs/architecture.md index 421ea33..63fcabe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,12 +41,30 @@ of changes is only ever cut between sequence numbers — so a device sees a whol change or none of it, and can never hold an expense whose shares have not arrived. -One feed uses a timestamp cursor instead, and honestly: profiles live in D1, which -several requests write concurrently, so there is nothing there that can issue a -sequence number. A time cursor cannot see a profile that becomes *visible* -without changing — somebody named long ago claiming a place in your group — so -each group page carries the profiles of its current members, read by the ids -the page itself names. The profile feed then only has renames to deliver. +Profiles live in D1, outside every group, and are numbered the same way by D1 +itself: each write takes the next value of a `counters` row in the same batch +(`server/src/db/d1/profile-version.ts`). D1 commits one write at a time, so the +versions follow commit order, which a timestamp from a Worker's clock does not: +two requests can stamp their times in one order and commit in the other, and a +device that read in between would pass one of them for good. A counter rather +than `max(version) + 1`, which would hand a deleted row's number out again. + +A version cannot see a profile that becomes *visible* without changing — +somebody named long ago claiming a place in your group — so each group page +carries the profiles of its current members, read by the ids the page itself +names. The profile feed then only has renames to deliver. + +### An edit is judged against the version it was made on + +Every write replaces the whole row (see below), so an expense edit carries +`baseSeq`, the version it was composed on, and the object refuses it as +`stale_base` unless that is the version stored now. Refusing only edits that +move money would be wrong: the edit still carries the old value of every field +it did not touch, so a stale rename would silently put back the amount +somebody else just corrected. The device parks a refused edit for its author to +apply again on top of the newer version. Sending exactly what is stored is +answered with the stored row, whatever the base, which is what a retry after a +lost response is. --- @@ -57,8 +75,9 @@ the page itself names. The profile feed then only has renames to deliver. tables that exist because the object is a little machine as well as a table: `counter` (the sequence allocator), `meta` (the group row itself), `tombstone` (what is left after a purge, so a device that still holds a copy is told to drop -it rather than being refused forever), `outbox` (writes owed to D1) and -`schedule` (the dormancy alarm). +it rather than being refused forever), `outbox` (writes owed to D1), +`schedule` (the dormancy alarm) and `activity` (when a member last changed +anything, which is what quiet is measured from; housekeeping never writes it). A purge happens when a group is archived, settled in every currency and quiet for a year, or at once when its last account holder deletes their account. @@ -68,7 +87,8 @@ shares and activity. An edit that device never pushed goes with it. The copy that survives is whatever somebody exported beforehand. **D1** (`server/src/db/d1/schema.ts`) — `profiles`, `memberships`, -`device_tokens`, `link_tokens`, plus Better Auth's own four tables. Everything +`device_tokens`, `link_tokens`, `counters` (the profile feed's version), plus +Better Auth's own four tables. Everything here except `profiles` is a **derived index**: the objects are the truth, and D1 is what makes "which groups am I in" and "what does this token point at" answerable without asking every object in the account. @@ -243,9 +263,21 @@ No column changes, so no migration and no device rebuild. ## Sync is event-triggered, never polled -The client syncs on real triggers — screen open, pull-to-refresh, a data-only -push waking the device — and asks each group's object for everything after its -cursor, as one-shot requests. +The client syncs on real triggers, as one-shot requests, and each trigger asks +for only what it could have changed: + +- **App start, resume, pull-to-refresh, a retry after a failure:** everything. + The outbox, the shared feeds (reference data with its `ETag`, rates, + profiles), and every group the account is in. +- **A local write, a screen opening, a push waking the device:** the outbox, + then the named group and the groups the push wrote to. Nothing else on the + device changed, and another device's change arrives with its own push. + +Groups are read with one `POST /sync`, which takes up to fifty groups and their +cursors, reads them from their objects in parallel, and answers a page each. It +also names every group the account is in, so a group joined on another device +is found by the same request rather than a separate one. A group the server +cannot read is listed under `refusals`, and the rest are answered. This is a cost decision as much as a correctness one. Request volume from sync is the line that scales with usage here; Durable Object compute, storage and diff --git a/docs/openapi.json b/docs/openapi.json index 3da39f5..7400956 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -704,7 +704,7 @@ "flow" ] }, - "GroupIds": { + "Pull": { "type": "object", "properties": { "groupIds": { @@ -715,10 +715,24 @@ "maxLength": 64, "example": "0195f3a2-8b1e-7c3d-9f42-1a2b3c4d5e6f" } + }, + "pages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChangePage" + } + }, + "refusals": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupRefusal" + } } }, "required": [ - "groupIds" + "groupIds", + "pages", + "refusals" ] }, "ChangePage": { @@ -903,6 +917,11 @@ "nullable": true, "format": "date-time", "example": "2026-09-23T11:20:41.865Z" + }, + "version": { + "type": "integer", + "minimum": 0, + "example": 412 } }, "required": [ @@ -915,7 +934,8 @@ "avatarIcon", "avatarPhoto", "updatedAt", - "deletedAt" + "deletedAt", + "version" ] }, "Entry": { @@ -1196,6 +1216,69 @@ "ordinal" ] }, + "GroupRefusal": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "example": "0195f3a2-8b1e-7c3d-9f42-1a2b3c4d5e6f" + }, + "code": { + "$ref": "#/components/schemas/ErrorCode" + }, + "message": { + "type": "string" + } + }, + "required": [ + "groupId", + "code", + "message" + ] + }, + "PullRequest": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupCursor" + }, + "maxItems": 50 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [ + "groups", + "limit" + ] + }, + "GroupCursor": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "example": "0195f3a2-8b1e-7c3d-9f42-1a2b3c4d5e6f" + }, + "since": { + "type": "integer", + "minimum": 0, + "example": 412 + } + }, + "required": [ + "groupId", + "since" + ] + }, "GroupInput": { "type": "object", "properties": { @@ -1650,9 +1733,10 @@ "$ref": "#/components/schemas/Profile" } }, - "cursor": { - "type": "string", - "nullable": true + "seq": { + "type": "integer", + "minimum": 0, + "example": 412 }, "hasMore": { "type": "boolean" @@ -1660,7 +1744,7 @@ }, "required": [ "profiles", - "cursor", + "seq", "hasMore" ] }, @@ -2421,8 +2505,8 @@ } } }, - "/api/groups": { - "get": { + "/api/sync": { + "post": { "security": [ { "bearer": [] @@ -2431,89 +2515,29 @@ "cookie": [] } ], - "operationId": "listGroups", + "operationId": "pull", "tags": [ "sync" ], - "summary": "The groups this account is still in", - "responses": { - "200": { - "description": "The group ids", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GroupIds" - } - } - } - }, - "401": { - "description": "No session.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } + "summary": "Every group this account is in, and what changed in the ones asked for", + "description": "Each group asked for gets its own page past its own cursor, read in parallel; a group that cannot be read is listed in `refusals` and the rest are still answered. `groupIds` names every group the account is in now, so one with no cursor yet is asked for next time, from 0. A page is cut only between sequence numbers, so a device sees a whole write or none of it; send each page's `seq` back as that group's `since`.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PullRequest" } } } - } - } - }, - "/api/groups/{groupId}/changes": { - "get": { - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - } - ], - "operationId": "getChanges", - "tags": [ - "sync" - ], - "summary": "Everything that changed in one group since a cursor", - "description": "`limit` counts changes, not rows, and a page is cut only between sequence numbers, so a device sees a whole write or none of it. Send `seq` back as `since` next time.", - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "example": "0195f3a2-8b1e-7c3d-9f42-1a2b3c4d5e6f" - }, - "required": true, - "name": "groupId", - "in": "path" - }, - { - "schema": { - "type": "integer", - "example": 412 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": "integer", - "example": 200 - }, - "required": false, - "name": "limit", - "in": "query" - } - ], + }, "responses": { "200": { - "description": "The page, and the cursor to send next time", + "description": "The account's groups, and a page or a refusal for each group asked for", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChangePage" + "$ref": "#/components/schemas/Pull" } } } @@ -2537,56 +2561,6 @@ } } } - }, - "403": { - "description": "You are not a member of this group, or not allowed to change that.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "No such group, entry, member or link.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "The request conflicts with the group's current state. Read `error.retry` before retrying.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "410": { - "description": "The group was collected, or the link has expired.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The expense does not add up.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } } } } @@ -2863,7 +2837,7 @@ "entries" ], "summary": "Record, edit, delete or restore an expense, whole", - "description": "A stale `baseSeq` is refused only when the write would move money, so two people fixing a typo never arbitrate. Deleting and restoring always move money.", + "description": "`baseSeq` must be the version stored now (null for a new expense), or the write is refused as `stale_base`: the expense is replaced whole, so a write composed on an older version would undo the edit in between. Sending the stored contents again is answered with the stored row, whatever the base.", "parameters": [ { "schema": { @@ -3754,14 +3728,15 @@ "sync" ], "summary": "Everybody you share a group with, plus yourself", - "description": "Cursored on time, since profiles have no single writer to number them. Send `cursor` back as `after`.", + "description": "Cursored on each profile's `version`, which D1 numbers in commit order. Send `seq` back as `since` next time.", "parameters": [ { "schema": { - "type": "string" + "type": "integer", + "example": 412 }, "required": false, - "name": "after", + "name": "since", "in": "query" }, { diff --git a/drift_schemas/drift_schema_v10.json b/drift_schemas/drift_schema_v10.json new file mode 100644 index 0000000..6e3ba83 --- /dev/null +++ b/drift_schemas/drift_schema_v10.json @@ -0,0 +1,2236 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "currencies", + "was_declared_in_moor": false, + "columns": [ + { + "name": "code", + "getter_name": "code", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "allowed-lengths": { + "min": 3, + "max": 3 + } + } + ] + }, + { + "name": "exponent", + "getter_name": "exponent", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "symbol", + "getter_name": "symbol", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "code" + ] + } + }, + { + "id": 1, + "references": [ + 0 + ], + "type": "table", + "data": { + "name": "groups", + "was_declared_in_moor": false, + "columns": [ + { + "name": "avatar_kind", + "getter_name": "avatarKind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'initials\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarKind.values,AvatarKind.unknownDefaultOpenApi)", + "dart_type_name": "AvatarKind" + } + }, + { + "name": "avatar_color", + "getter_name": "avatarColor", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarColor.values,AvatarColor.unknownDefaultOpenApi)", + "dart_type_name": "AvatarColor" + } + }, + { + "name": "avatar_emoji", + "getter_name": "avatarEmoji", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "avatar_icon", + "getter_name": "avatarIcon", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarIcon.values,AvatarIcon.unknownDefaultOpenApi)", + "dart_type_name": "AvatarIcon" + } + }, + { + "name": "avatar_photo", + "getter_name": "avatarPhoto", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "default_currency", + "getter_name": "defaultCurrency", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES currencies (code)", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES currencies (code)" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "currencies", + "column": "code" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": null + } + } + ] + }, + { + "name": "is_direct", + "getter_name": "isDirect", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_direct\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_direct\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "simplify_debts", + "getter_name": "simplifyDebts", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"simplify_debts\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"simplify_debts\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "cover_kind", + "getter_name": "coverKind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'generated\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(CoverKind.values,CoverKind.unknownDefaultOpenApi)", + "dart_type_name": "CoverKind" + } + }, + { + "name": "cover_photo", + "getter_name": "coverPhoto", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_by", + "getter_name": "createdBy", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "archived_at", + "getter_name": "archivedAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "seq", + "getter_name": "seq", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 2, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "entries", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "group_id", + "getter_name": "groupId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "groups", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "kind", + "getter_name": "kind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(EntryKind.values,EntryKind.unknownDefaultOpenApi)", + "dart_type_name": "EntryKind" + } + }, + { + "name": "description", + "getter_name": "description", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'\\'')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "category_id", + "getter_name": "categoryId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "currency", + "getter_name": "currency", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "amount_minor", + "getter_name": "amountMinor", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "entry_date", + "getter_name": "entryDate", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const CalendarDateConverter()", + "dart_type_name": "DateTime" + } + }, + { + "name": "occurred_at", + "getter_name": "occurredAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "time_zone", + "getter_name": "timeZone", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "split_kind", + "getter_name": "splitKind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(SplitKind.values,SplitKind.unknownDefaultOpenApi)", + "dart_type_name": "SplitKind" + } + }, + { + "name": "fx_rate", + "getter_name": "fxRate", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fx_source", + "getter_name": "fxSource", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fx_at", + "getter_name": "fxAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_by", + "getter_name": "createdBy", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "seq", + "getter_name": "seq", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "deleted_at", + "getter_name": "deletedAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 3, + "references": [ + 2 + ], + "type": "table", + "data": { + "name": "entries_fts", + "was_declared_in_moor": true, + "columns": [ + { + "name": "description", + "getter_name": "description", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": true, + "create_virtual_stmt": "CREATE VIRTUAL TABLE \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid)", + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 4, + "references": [ + 2, + 3 + ], + "type": "trigger", + "data": { + "on": 2, + "references_in_body": [ + 2, + 3 + ], + "name": "entries_fts_insert", + "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" + } + }, + { + "id": 5, + "references": [ + 2, + 3 + ], + "type": "trigger", + "data": { + "on": 2, + "references_in_body": [ + 2, + 3 + ], + "name": "entries_fts_delete", + "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\nEND;" + } + }, + { + "id": 6, + "references": [ + 2, + 3 + ], + "type": "trigger", + "data": { + "on": 2, + "references_in_body": [ + 2, + 3 + ], + "name": "entries_fts_update", + "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN\n INSERT INTO entries_fts(entries_fts, rowid, description, notes)\n VALUES ('delete', old.rowid, old.description, old.notes);\n INSERT INTO entries_fts(rowid, description, notes)\n VALUES (new.rowid, new.description, new.notes);\nEND;" + } + }, + { + "id": 7, + "references": [], + "type": "table", + "data": { + "name": "profiles", + "was_declared_in_moor": false, + "columns": [ + { + "name": "avatar_kind", + "getter_name": "avatarKind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'initials\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarKind.values,AvatarKind.unknownDefaultOpenApi)", + "dart_type_name": "AvatarKind" + } + }, + { + "name": "avatar_color", + "getter_name": "avatarColor", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarColor.values,AvatarColor.unknownDefaultOpenApi)", + "dart_type_name": "AvatarColor" + } + }, + { + "name": "avatar_emoji", + "getter_name": "avatarEmoji", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "avatar_icon", + "getter_name": "avatarIcon", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(AvatarIcon.values,AvatarIcon.unknownDefaultOpenApi)", + "dart_type_name": "AvatarIcon" + } + }, + { + "name": "avatar_photo", + "getter_name": "avatarPhoto", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "display_name", + "getter_name": "displayName", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "upi_vpa", + "getter_name": "upiVpa", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "version", + "getter_name": "version", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 8, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "members", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "group_id", + "getter_name": "groupId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "groups", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "profile_id", + "getter_name": "profileId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "display_name", + "getter_name": "displayName", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "joined_at", + "getter_name": "joinedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "left_at", + "getter_name": "leftAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "upi_vpa", + "getter_name": "upiVpa", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "seq", + "getter_name": "seq", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 9, + "references": [], + "type": "table", + "data": { + "name": "categories", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "icon", + "getter_name": "icon", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 10, + "references": [ + 2, + 8 + ], + "type": "table", + "data": { + "name": "entry_payers", + "was_declared_in_moor": false, + "columns": [ + { + "name": "entry_id", + "getter_name": "entryId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "entries", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "member_id", + "getter_name": "memberId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES members (id)", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES members (id)" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "members", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": null + } + } + ] + }, + { + "name": "amount_minor", + "getter_name": "amountMinor", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "entry_id", + "member_id" + ] + } + }, + { + "id": 11, + "references": [ + 2, + 8 + ], + "type": "table", + "data": { + "name": "entry_shares", + "was_declared_in_moor": false, + "columns": [ + { + "name": "entry_id", + "getter_name": "entryId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "entries", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "member_id", + "getter_name": "memberId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES members (id)", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES members (id)" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "members", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": null + } + } + ] + }, + { + "name": "amount_minor", + "getter_name": "amountMinor", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "weight_micros", + "getter_name": "weightMicros", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "entry_id", + "member_id" + ] + } + }, + { + "id": 12, + "references": [ + 1, + 8 + ], + "type": "table", + "data": { + "name": "group_events", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "group_id", + "getter_name": "groupId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "groups", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "actor_id", + "getter_name": "actorId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "defaultConstraints": "REFERENCES members (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES members (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "members", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "kind", + "getter_name": "kind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireEnumConverter(EventKind.values,EventKind.unknownDefaultOpenApi)", + "dart_type_name": "EventKind" + } + }, + { + "name": "subject_id", + "getter_name": "subjectId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "entry", + "getter_name": "entry", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireJsonConverter(EntrySnapshot.fromJson)", + "dart_type_name": "EntrySnapshot" + } + }, + { + "name": "member", + "getter_name": "member", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireJsonConverter(MemberEventPayload.fromJson)", + "dart_type_name": "MemberEventPayload" + } + }, + { + "name": "group", + "getter_name": "group", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireJsonConverter(GroupEventPayload.fromJson)", + "dart_type_name": "GroupEventPayload" + } + }, + { + "name": "link", + "getter_name": "link", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireJsonConverter(LinkEventPayload.fromJson)", + "dart_type_name": "LinkEventPayload" + } + }, + { + "name": "seq", + "getter_name": "seq", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "ordinal", + "getter_name": "ordinal", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_provisional", + "getter_name": "isProvisional", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_provisional\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_provisional\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 13, + "references": [ + 2, + 1 + ], + "type": "table", + "data": { + "name": "entry_conflicts", + "was_declared_in_moor": false, + "columns": [ + { + "name": "entry_id", + "getter_name": "entryId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES entries (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES entries (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "entries", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "group_id", + "getter_name": "groupId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "groups", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "attempted", + "getter_name": "attempted", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const WireJsonConverter(EntrySnapshot.fromJson)", + "dart_type_name": "EntrySnapshot" + } + }, + { + "name": "base_seq", + "getter_name": "baseSeq", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "rejected_at", + "getter_name": "rejectedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "entry_id" + ] + } + }, + { + "id": 14, + "references": [], + "type": "table", + "data": { + "name": "fx_rates", + "was_declared_in_moor": false, + "columns": [ + { + "name": "as_of", + "getter_name": "asOf", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "currency", + "getter_name": "currency", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "rate", + "getter_name": "rate", + "moor_type": "double", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "source", + "getter_name": "source", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "as_of", + "currency" + ] + } + }, + { + "id": 15, + "references": [], + "type": "table", + "data": { + "name": "outbox", + "was_declared_in_moor": false, + "columns": [ + { + "name": "target", + "getter_name": "target", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(OutboxTarget.values)", + "dart_type_name": "OutboxTarget" + } + }, + { + "name": "target_id", + "getter_name": "targetId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "revision", + "getter_name": "revision", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "position", + "getter_name": "position", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "attempts", + "getter_name": "attempts", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "next_attempt_at", + "getter_name": "nextAttemptAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_error", + "getter_name": "lastError", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "dead_lettered_at", + "getter_name": "deadLetteredAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "target", + "target_id" + ] + } + }, + { + "id": 16, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "group_cursors", + "was_declared_in_moor": false, + "columns": [ + { + "name": "group_id", + "getter_name": "groupId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES \"groups\" (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES \"groups\" (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "groups", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "seq", + "getter_name": "seq", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_synced_at", + "getter_name": "lastSyncedAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "group_id" + ] + } + }, + { + "id": 17, + "references": [], + "type": "table", + "data": { + "name": "feed_cursors", + "was_declared_in_moor": false, + "columns": [ + { + "name": "feed", + "getter_name": "feed", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "cursor", + "getter_name": "cursor", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_synced_at", + "getter_name": "lastSyncedAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "feed" + ] + } + }, + { + "id": 18, + "references": [], + "type": "table", + "data": { + "name": "sync_leases", + "was_declared_in_moor": false, + "columns": [ + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "owner", + "getter_name": "owner", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "expires_at", + "getter_name": "expiresAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "name" + ] + } + }, + { + "id": 19, + "references": [], + "type": "table", + "data": { + "name": "sync_sessions", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "epoch", + "getter_name": "epoch", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "enabled", + "getter_name": "enabled", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"enabled\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"enabled\" IN (0, 1))" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 20, + "references": [ + 12 + ], + "type": "index", + "data": { + "on": 12, + "name": "group_events_group", + "sql": null, + "unique": false, + "columns": [ + { + "column": "group_id", + "order_by": null + }, + { + "column": "seq", + "order_by": null + }, + { + "column": "ordinal", + "order_by": null + } + ] + } + }, + { + "id": 21, + "references": [ + 12 + ], + "type": "index", + "data": { + "on": 12, + "name": "group_events_subject", + "sql": null, + "unique": false, + "columns": [ + { + "column": "subject_id", + "order_by": null + } + ] + } + }, + { + "id": 22, + "references": [ + 12 + ], + "type": "index", + "data": { + "on": 12, + "name": "group_events_actor", + "sql": null, + "unique": false, + "columns": [ + { + "column": "actor_id", + "order_by": null + } + ] + } + }, + { + "id": 23, + "references": [ + 8 + ], + "type": "index", + "data": { + "on": 8, + "name": "members_group", + "sql": null, + "unique": false, + "columns": [ + { + "column": "group_id", + "order_by": null + } + ] + } + }, + { + "id": 24, + "references": [ + 8 + ], + "type": "index", + "data": { + "on": 8, + "name": "members_profile", + "sql": null, + "unique": false, + "columns": [ + { + "column": "profile_id", + "order_by": null + } + ] + } + }, + { + "id": 25, + "references": [ + 2 + ], + "type": "index", + "data": { + "on": 2, + "name": "entries_group", + "sql": null, + "unique": false, + "columns": [ + { + "column": "group_id", + "order_by": null + }, + { + "column": "entry_date", + "order_by": null + } + ] + } + }, + { + "id": 26, + "references": [ + 13 + ], + "type": "index", + "data": { + "on": 13, + "name": "entry_conflicts_group", + "sql": null, + "unique": false, + "columns": [ + { + "column": "group_id", + "order_by": null + } + ] + } + }, + { + "id": 27, + "references": [ + 10 + ], + "type": "index", + "data": { + "on": 10, + "name": "entry_payers_member", + "sql": null, + "unique": false, + "columns": [ + { + "column": "member_id", + "order_by": null + } + ] + } + }, + { + "id": 28, + "references": [ + 11 + ], + "type": "index", + "data": { + "on": 11, + "name": "entry_shares_member", + "sql": null, + "unique": false, + "columns": [ + { + "column": "member_id", + "order_by": null + } + ] + } + }, + { + "id": 29, + "references": [ + 14 + ], + "type": "index", + "data": { + "on": 14, + "name": "fx_rates_currency", + "sql": null, + "unique": false, + "columns": [ + { + "column": "currency", + "order_by": null + }, + { + "column": "as_of", + "order_by": null + } + ] + } + } + ], + "fixed_sql": [ + { + "name": "currencies", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"currencies\" (\"code\" TEXT NOT NULL, \"exponent\" INTEGER NOT NULL, \"symbol\" TEXT NULL, \"name\" TEXT NOT NULL, PRIMARY KEY (\"code\"));" + } + ] + }, + { + "name": "groups", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"groups\" (\"avatar_kind\" TEXT NOT NULL DEFAULT 'initials', \"avatar_color\" TEXT NULL, \"avatar_emoji\" TEXT NULL, \"avatar_icon\" TEXT NULL, \"avatar_photo\" TEXT NULL, \"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"default_currency\" TEXT NOT NULL REFERENCES currencies (code), \"is_direct\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_direct\" IN (0, 1)), \"simplify_debts\" INTEGER NOT NULL DEFAULT 1 CHECK (\"simplify_debts\" IN (0, 1)), \"cover_kind\" TEXT NOT NULL DEFAULT 'generated', \"cover_photo\" TEXT NULL, \"created_by\" TEXT NULL, \"created_at\" INTEGER NOT NULL, \"archived_at\" INTEGER NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "entries", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"entries\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"kind\" TEXT NOT NULL, \"description\" TEXT NOT NULL DEFAULT '', \"category_id\" TEXT NULL, \"currency\" TEXT NOT NULL, \"amount_minor\" INTEGER NOT NULL, \"entry_date\" TEXT NOT NULL, \"occurred_at\" INTEGER NULL, \"time_zone\" TEXT NULL, \"split_kind\" TEXT NOT NULL, \"fx_rate\" REAL NULL, \"fx_source\" TEXT NULL, \"fx_at\" INTEGER NULL, \"notes\" TEXT NULL, \"created_by\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"seq\" INTEGER NULL, \"deleted_at\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "entries_fts", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"entries_fts\" USING fts5(description, notes, content=entries, content_rowid=rowid);" + } + ] + }, + { + "name": "entries_fts_insert", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER entries_fts_insert AFTER INSERT ON entries BEGIN INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" + } + ] + }, + { + "name": "entries_fts_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER entries_fts_delete AFTER DELETE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);END" + } + ] + }, + { + "name": "entries_fts_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER entries_fts_update AFTER UPDATE ON entries BEGIN INSERT INTO entries_fts (entries_fts, \"rowid\", description, notes) VALUES ('delete', old.\"rowid\", old.description, old.notes);INSERT INTO entries_fts (\"rowid\", description, notes) VALUES (new.\"rowid\", new.description, new.notes);END" + } + ] + }, + { + "name": "profiles", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"profiles\" (\"avatar_kind\" TEXT NOT NULL DEFAULT 'initials', \"avatar_color\" TEXT NULL, \"avatar_emoji\" TEXT NULL, \"avatar_icon\" TEXT NULL, \"avatar_photo\" TEXT NULL, \"id\" TEXT NOT NULL, \"display_name\" TEXT NULL, \"upi_vpa\" TEXT NULL, \"version\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "members", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"members\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"profile_id\" TEXT NULL, \"display_name\" TEXT NOT NULL, \"joined_at\" INTEGER NOT NULL, \"left_at\" INTEGER NULL, \"upi_vpa\" TEXT NULL, \"seq\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "categories", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"categories\" (\"id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"icon\" TEXT NOT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "entry_payers", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"entry_payers\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" + } + ] + }, + { + "name": "entry_shares", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"entry_shares\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"member_id\" TEXT NOT NULL REFERENCES members (id), \"amount_minor\" INTEGER NOT NULL, \"weight_micros\" INTEGER NULL, PRIMARY KEY (\"entry_id\", \"member_id\"));" + } + ] + }, + { + "name": "group_events", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"group_events\" (\"id\" TEXT NOT NULL, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"actor_id\" TEXT NULL REFERENCES members (id) ON DELETE CASCADE, \"created_at\" INTEGER NOT NULL, \"kind\" TEXT NOT NULL, \"subject_id\" TEXT NULL, \"entry\" TEXT NULL, \"member\" TEXT NULL, \"group\" TEXT NULL, \"link\" TEXT NULL, \"seq\" INTEGER NULL, \"ordinal\" INTEGER NULL, \"is_provisional\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_provisional\" IN (0, 1)), PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "entry_conflicts", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"entry_conflicts\" (\"entry_id\" TEXT NOT NULL REFERENCES entries (id) ON DELETE CASCADE, \"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"attempted\" TEXT NOT NULL, \"base_seq\" INTEGER NULL, \"rejected_at\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\"));" + } + ] + }, + { + "name": "fx_rates", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"fx_rates\" (\"as_of\" TEXT NOT NULL, \"currency\" TEXT NOT NULL, \"rate\" REAL NOT NULL, \"source\" TEXT NOT NULL, PRIMARY KEY (\"as_of\", \"currency\"));" + } + ] + }, + { + "name": "outbox", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"outbox\" (\"target\" TEXT NOT NULL, \"target_id\" TEXT NOT NULL, \"revision\" TEXT NOT NULL, \"position\" INTEGER NOT NULL, \"attempts\" INTEGER NOT NULL DEFAULT 0, \"next_attempt_at\" INTEGER NULL, \"last_error\" TEXT NULL, \"dead_lettered_at\" INTEGER NULL, PRIMARY KEY (\"target\", \"target_id\"));" + } + ] + }, + { + "name": "group_cursors", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"group_cursors\" (\"group_id\" TEXT NOT NULL REFERENCES \"groups\" (id) ON DELETE CASCADE, \"seq\" INTEGER NOT NULL DEFAULT 0, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"group_id\"));" + } + ] + }, + { + "name": "feed_cursors", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"feed_cursors\" (\"feed\" TEXT NOT NULL, \"cursor\" TEXT NULL, \"last_synced_at\" INTEGER NULL, PRIMARY KEY (\"feed\"));" + } + ] + }, + { + "name": "sync_leases", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"sync_leases\" (\"name\" TEXT NOT NULL, \"owner\" TEXT NOT NULL, \"expires_at\" INTEGER NOT NULL, PRIMARY KEY (\"name\"));" + } + ] + }, + { + "name": "sync_sessions", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"sync_sessions\" (\"id\" TEXT NOT NULL, \"epoch\" TEXT NOT NULL, \"enabled\" INTEGER NOT NULL CHECK (\"enabled\" IN (0, 1)), PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "group_events_group", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX group_events_group ON group_events (group_id, seq, ordinal)" + } + ] + }, + { + "name": "group_events_subject", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX group_events_subject ON group_events (subject_id)" + } + ] + }, + { + "name": "group_events_actor", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX group_events_actor ON group_events (actor_id)" + } + ] + }, + { + "name": "members_group", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX members_group ON members (group_id)" + } + ] + }, + { + "name": "members_profile", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX members_profile ON members (profile_id)" + } + ] + }, + { + "name": "entries_group", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX entries_group ON entries (group_id, entry_date)" + } + ] + }, + { + "name": "entry_conflicts_group", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX entry_conflicts_group ON entry_conflicts (group_id)" + } + ] + }, + { + "name": "entry_payers_member", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX entry_payers_member ON entry_payers (member_id)" + } + ] + }, + { + "name": "entry_shares_member", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX entry_shares_member ON entry_shares (member_id)" + } + ] + }, + { + "name": "fx_rates_currency", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX fx_rates_currency ON fx_rates (currency, as_of)" + } + ] + } + ] +} \ No newline at end of file diff --git a/lib/application/push_providers.dart b/lib/application/push_providers.dart index a62c571..f5a4dcc 100644 --- a/lib/application/push_providers.dart +++ b/lib/application/push_providers.dart @@ -37,7 +37,7 @@ PushService pushService(Ref ref) { ), onOpenGroup: (groupId) { // The background isolate writes through another Drift connection. - ref.read(appDatabaseProvider).refreshAfterExternalSync(); + unawaited(ref.read(appDatabaseProvider).noticeWritesElsewhere()); ref.read(routerProvider).go(groupNotificationRoute(groupId)); unawaited(ref.read(syncControllerProvider.notifier).syncGroup(groupId)); }, diff --git a/lib/application/sync_coordinator.dart b/lib/application/sync_coordinator.dart index 69e188b..7dc64c1 100644 --- a/lib/application/sync_coordinator.dart +++ b/lib/application/sync_coordinator.dart @@ -31,7 +31,7 @@ class SyncStatus { class SyncCoordinator extends ChangeNotifier { SyncCoordinator({ required this._syncAll, - required this._syncGroup, + required this._syncGroups, this._online = const Stream.empty(), this._writes = const Stream.empty(), DateTime Function()? clock, @@ -39,8 +39,11 @@ class SyncCoordinator extends ChangeNotifier { this.writeDelay = const Duration(seconds: 1), }) : _clock = clock ?? DateTime.now; + /// The outbox, the shared feeds and every group. final Future Function() _syncAll; - final Future Function(String) _syncGroup; + + /// The outbox, then these groups and every group it wrote to. + final Future Function(Set groupIds) _syncGroups; final Stream _online; final Stream _writes; final DateTime Function() _clock; @@ -50,6 +53,9 @@ class SyncCoordinator extends ChangeNotifier { final _pendingGroups = {}; final _subscriptions = >[]; bool _allPending = false; + + /// A partial run is owed: the outbox, read back, and [_pendingGroups]. + bool _partialPending = false; bool _disposed = false; int _failures = 0; DateTime? _lastStarted; @@ -93,13 +99,22 @@ class SyncCoordinator extends ChangeNotifier { return syncAll(); } _pendingGroups.add(groupId); + return _syncWrites(); + } + + /// What was written here, sent, and the groups it touched read back. The + /// rest of the account waits for the next full sync: nothing else changed + /// on this device, and another device's change arrives with its push. + Future _syncWrites() { + if (_disposed) return Future.value(); + _partialPending = true; return _begin(); } void _writeQueued() { if (_disposed) return; _writeTimer?.cancel(); - _writeTimer = Timer(writeDelay, () => unawaited(syncAll())); + _writeTimer = Timer(writeDelay, () => unawaited(_syncWrites())); } Future _begin() { @@ -116,20 +131,18 @@ class SyncCoordinator extends ChangeNotifier { Future _drain(Completer completed) async { if (!_disposed) _setStatus(isSyncing: true); - while (!_disposed && (_allPending || _pendingGroups.isNotEmpty)) { + while (!_disposed && (_allPending || _partialPending)) { + // A full run covers everything a partial one would. final full = _allPending; - final groupId = full ? null : _pendingGroups.first; + final groups = {..._pendingGroups}; _allPending = false; - if (full) { - _pendingGroups.clear(); - _lastStarted = _clock(); - } else { - _pendingGroups.remove(groupId); - } + _partialPending = false; + _pendingGroups.clear(); + if (full) _lastStarted = _clock(); SyncReport report; try { - report = await (full ? _syncAll() : _syncGroup(groupId!)); + report = await (full ? _syncAll() : _syncGroups(groups)); } catch (error, stackTrace) { report = SyncReport( pushed: 0, @@ -150,6 +163,7 @@ class SyncCoordinator extends ChangeNotifier { ); // One failure ends this pass; the retry is a full sync. _allPending = false; + _partialPending = false; _pendingGroups.clear(); } _status = SyncStatus( @@ -164,8 +178,11 @@ class SyncCoordinator extends ChangeNotifier { completed.complete(); } + /// After a failure, a full sync once it has backed off; after a write the + /// server could not take yet, that write again when it is due. void _scheduleRetry() { Duration? delay; + var retry = syncAll; if (_status.error != null) { delay = Duration(seconds: math.min(5 * (1 << _failures), 300)); _failures = math.min(_failures + 1, 6); @@ -179,9 +196,10 @@ class SyncCoordinator extends ChangeNotifier { 1000, ), ); + retry = _syncWrites; } } - if (delay != null) _retry = Timer(delay, () => unawaited(syncAll())); + if (delay != null) _retry = Timer(delay, () => unawaited(retry())); _setStatus( isSyncing: false, retryAt: delay == null ? null : _clock().add(delay), diff --git a/lib/application/sync_providers.dart b/lib/application/sync_providers.dart index 2b6c389..ed29da5 100644 --- a/lib/application/sync_providers.dart +++ b/lib/application/sync_providers.dart @@ -49,8 +49,8 @@ class SyncController extends _$SyncController { if (engine == null) return const SyncStatus(enabled: false); final coordinator = SyncCoordinator( - syncAll: () => _refreshing(engine.syncEverything()), - syncGroup: (groupId) => _refreshing(engine.syncGroup(groupId)), + syncAll: engine.syncEverything, + syncGroups: engine.syncGroups, online: ref.watch(networkSignalProvider).changes, // Every local write, from every screen, through one stream. writes: ref.watch(outboxQueueProvider).queued, @@ -79,14 +79,6 @@ class SyncController extends _$SyncController { await ref.read(syncEngineProvider)?.discardRefused(); await syncAll(); } - - /// Streams notice writes made through this database on their own; this - /// covers ones a background isolate made to the same file. - Future _refreshing(Future sync) async { - final report = await sync; - if (ref.mounted) ref.read(appDatabaseProvider).refreshAfterExternalSync(); - return report; - } } /// Syncs one group when a screen showing it opens. diff --git a/lib/data/local/database.dart b/lib/data/local/database.dart index 7c689bd..0c474c2 100644 --- a/lib/data/local/database.dart +++ b/lib/data/local/database.dart @@ -79,15 +79,30 @@ class AppDatabase extends _$AppDatabase { /// Bump this on **any** change to a table in `tables.dart`. @override - int get schemaVersion => 9; + int get schemaVersion => 10; /// Timestamps are stored as ISO-8601 text rather than Unix seconds. @override DriftDatabaseOptions get options => const DriftDatabaseOptions(storeDateTimeAsText: true); - /// Re-runs live queries after the background isolate changed this file. - void refreshAfterExternalSync() => markTablesUpdated(allTables); + /// SQLite's `data_version` when this connection last looked. + int? _dataVersion; + + /// Re-runs live queries if another connection has committed to this file + /// since the last look: the push handler, which wakes in its own Flutter + /// engine and so cannot share this connection. Drift sees only the writes + /// made through it; `data_version` changes for every commit but this + /// connection's own, so nothing is re-run when nothing else wrote. + Future noticeWritesElsewhere() async { + final seen = _dataVersion; + final now = _dataVersion = await _readDataVersion(); + if (seen != null && seen != now) markTablesUpdated(allTables); + } + + Future _readDataVersion() async => (await customSelect( + 'PRAGMA data_version', + ).getSingle()).read('data_version'); @override MigrationStrategy get migration => MigrationStrategy( @@ -113,6 +128,7 @@ class AppDatabase extends _$AppDatabase { await customStatement('PRAGMA journal_mode = WAL'); await customStatement('PRAGMA busy_timeout = 5000'); } + _dataVersion = await _readDataVersion(); }, ); } diff --git a/lib/data/local/tables.dart b/lib/data/local/tables.dart index 8120e53..a9de69f 100644 --- a/lib/data/local/tables.dart +++ b/lib/data/local/tables.dart @@ -113,9 +113,9 @@ class Profiles extends Table with AvatarColumns { /// Personal UPI handle; a member's own handle takes precedence. TextColumn get upiVpa => text().nullable()(); - /// The server's timestamp, and the profile feed's cursor. Null for a row - /// this device wrote and has not pushed. - DateTimeColumn get updatedAt => dateTime().nullable()(); + /// The server's version of this row, which the profile feed is cursored + /// on. Null for a row this device wrote and has not pushed. + IntColumn get version => integer().nullable()(); @override Set get primaryKey => {id}; @@ -124,6 +124,9 @@ class Profiles extends Table with AvatarColumns { /// The server's record of what happened, plus provisional lines this device /// wrote for changes it has not pushed yet. @DataClassName('GroupEventRow') +@TableIndex(name: 'group_events_group', columns: {#groupId, #seq, #ordinal}) +@TableIndex(name: 'group_events_subject', columns: {#subjectId}) +@TableIndex(name: 'group_events_actor', columns: {#actorId}) class GroupEvents extends Table { TextColumn get id => text()(); TextColumn get groupId => @@ -213,6 +216,8 @@ class Groups extends Table with AvatarColumns { /// a member with no [profileId] is a placeholder — a full member who has no /// account yet. Claiming an invite sets that one column. @DataClassName('Member') +@TableIndex(name: 'members_group', columns: {#groupId}) +@TableIndex(name: 'members_profile', columns: {#profileId}) class Members extends Table { TextColumn get id => text()(); TextColumn get groupId => @@ -245,6 +250,7 @@ class Categories extends Table { } @DataClassName('EntryRow') +@TableIndex(name: 'entries_group', columns: {#groupId, #entryDate}) class Entries extends Table { TextColumn get id => text()(); TextColumn get groupId => @@ -288,7 +294,7 @@ class Entries extends Table { DateTimeColumn get createdAt => dateTime()(); /// Also the base the next edit is judged against: the server refuses an - /// edit composed on an older `seq` only when it would move money. + /// edit composed on any older `seq`. IntColumn get seq => integer().nullable()(); /// Soft delete; entries are never removed. @@ -300,6 +306,7 @@ class Entries extends Table { /// An edit the server refused because the expense had moved underneath it. @DataClassName('EntryConflictRow') +@TableIndex(name: 'entry_conflicts_group', columns: {#groupId}) class EntryConflicts extends Table { TextColumn get entryId => text().references(Entries, #id, onDelete: KeyAction.cascade)(); @@ -320,6 +327,7 @@ class EntryConflicts extends Table { } @DataClassName('EntryPayerRow') +@TableIndex(name: 'entry_payers_member', columns: {#memberId}) class EntryPayers extends Table { TextColumn get entryId => text().references(Entries, #id, onDelete: KeyAction.cascade)(); @@ -331,6 +339,7 @@ class EntryPayers extends Table { } @DataClassName('EntryShareRow') +@TableIndex(name: 'entry_shares_member', columns: {#memberId}) class EntryShares extends Table { TextColumn get entryId => text().references(Entries, #id, onDelete: KeyAction.cascade)(); @@ -348,6 +357,7 @@ class EntryShares extends Table { /// Exchange rates against a USD pivot: one row per currency per day, and any /// pair is a division. @DataClassName('FxRateRow') +@TableIndex(name: 'fx_rates_currency', columns: {#currency, #asOf}) class FxRates extends Table { /// Publication date, `yyyy-MM-dd`; ISO dates sort, which is the lookup. TextColumn get asOf => text()(); @@ -363,8 +373,9 @@ class FxRates extends Table { Set get primaryKey => {asOf, currency}; } -/// What an outbox item refers to. Pushed in declaration order within a batch: -/// a group before its members, members before the entries that name them. +/// What an outbox item refers to. Declared in dependency order, which breaks +/// a tie in [Outbox.position]: a group before its members, members before the +/// entries that name them. enum OutboxTarget { group, member, entry, profile } /// Rows this device changed and has not pushed. Client-only. @@ -376,7 +387,12 @@ class Outbox extends Table { /// Identifies one edit, so a response for an older one is not applied over /// a newer one made during the upload. TextColumn get revision => text()(); - DateTimeColumn get createdAt => dateTime()(); + + /// Where this item is pushed, in the order this device made its changes: + /// the server checks each write against what it already has, so a + /// settlement has to arrive before the removal it makes possible. See + /// `OutboxQueue.enqueue` for how a later edit moves an item. + IntColumn get position => integer()(); IntColumn get attempts => integer().withDefault(const Constant(0))(); DateTimeColumn get nextAttemptAt => dateTime().nullable()(); TextColumn get lastError => text().nullable()(); diff --git a/lib/data/sync/apply_changes.dart b/lib/data/sync/apply_changes.dart index 7ba1704..2aee4d1 100644 --- a/lib/data/sync/apply_changes.dart +++ b/lib/data/sync/apply_changes.dart @@ -1,6 +1,8 @@ /// Writing one page of a group's history into the local database. library; +import 'dart:developer' as developer; + import 'package:drift/drift.dart'; import 'package:opensplit_api/opensplit_api.dart' as api; @@ -17,6 +19,48 @@ Future> _dirtyIds(AppDatabase db, OutboxTarget target) async { return {for (final row in rows) row.targetId}; } +/// Which of [ids] this device holds under a group other than [groupId]. +/// +/// Ids are minted by devices and each group's server only checks them within +/// that group, so a member of one group could reuse an id this device holds +/// in another. Applying it would move that row between groups here; it is +/// skipped instead, and the group it belongs to keeps it. +Future> _heldByOtherGroups( + AppDatabase db, + ResultSetImplementation table, { + required GeneratedColumn id, + required GeneratedColumn owner, + required String groupId, + required List ids, +}) async { + if (ids.isEmpty) return const {}; + final rows = + await (db.selectOnly(table) + ..addColumns([id]) + ..where(id.isIn(ids) & owner.equals(groupId).not())) + .get(); + final held = {for (final row in rows) row.read(id)!}; + for (final skipped in held) { + _logForeign(groupId, skipped); + } + return held; +} + +Future> _memberIds(AppDatabase db, String groupId) async => { + for (final row + in await (db.selectOnly(db.members) + ..addColumns([db.members.id]) + ..where(db.members.groupId.equals(groupId))) + .get()) + row.read(db.members.id)!, +}; + +void _logForeign(String groupId, String id) => developer.log( + 'Skipped $id in group $groupId: it names a row of another group.', + name: 'opensplit.sync', + level: 900, +); + /// Applies one page and advances the group's cursor, in one transaction, so a /// crash between them can neither re-read nor skip a page. Future applyGroupChanges( @@ -51,9 +95,20 @@ Future applyGroupChanges( } final dirtyMembers = await _dirtyIds(db, OutboxTarget.member); + final foreignMembers = await _heldByOtherGroups( + db, + db.members, + id: db.members.id, + owner: db.members.groupId, + groupId: groupId, + ids: [for (final member in page.members) member.id], + ); await db.batch((batch) { for (final member in page.members) { - if (dirtyMembers.contains(member.id)) continue; + if (dirtyMembers.contains(member.id) || + foreignMembers.contains(member.id)) { + continue; + } final row = member.toRow(groupId).toCompanion(false); // DO UPDATE, never REPLACE: SQLite's REPLACE deletes first, and // payers and shares reference members without a cascade. @@ -62,9 +117,27 @@ Future applyGroupChanges( }); final dirtyEntries = await _dirtyIds(db, OutboxTarget.entry); + final foreignEntries = await _heldByOtherGroups( + db, + db.entries, + id: db.entries.id, + owner: db.entries.groupId, + groupId: groupId, + ids: [for (final entry in page.entries) entry.id], + ); + final ownMembers = await _memberIds(db, groupId); var applied = 0; for (final entry in page.entries) { if (dirtyEntries.contains(entry.id)) continue; + final names = [ + for (final payer in entry.payers) payer.memberId, + for (final share in entry.shares) share.memberId, + ]; + if (foreignEntries.contains(entry.id) || + !names.every(ownMembers.contains)) { + _logForeign(groupId, entry.id); + continue; + } await writeEntryInTransaction(db, entry.toEntry(groupId)); applied++; } @@ -137,9 +210,8 @@ Future _applyEvents( } } -/// Profiles, the one feed still cursored on a timestamp (they live in D1, -/// which has several writers and so no sequence number). Here a newer local -/// `updatedAt` really can mean the remote row is older. +/// Profiles, which arrive on their own feed and on group pages, so a row can +/// arrive older than the copy already held: a newer local `version` wins. Future applyProfiles(AppDatabase db, List rows) async { if (rows.isEmpty) return 0; @@ -147,12 +219,12 @@ Future applyProfiles(AppDatabase db, List rows) async { final locals = await (db.select( db.profiles, )..where((t) => t.id.isIn([for (final row in rows) row.id]))).get(); - final heldAt = {for (final local in locals) local.id: local.updatedAt}; + final held = {for (final local in locals) local.id: local.version}; final winners = [ for (final profile in rows) if (!dirty.contains(profile.id) && - _remoteWins(heldAt[profile.id], profile.updatedAt)) + (held[profile.id] ?? 0) < profile.version) profile.toRow().toCompanion(false), ]; @@ -163,6 +235,3 @@ Future applyProfiles(AppDatabase db, List rows) async { }); return winners.length; } - -bool _remoteWins(DateTime? local, DateTime? remote) => - local == null || remote == null || local.isBefore(remote); diff --git a/lib/data/sync/outbox_queue.dart b/lib/data/sync/outbox_queue.dart index 937e5ed..5ab7332 100644 --- a/lib/data/sync/outbox_queue.dart +++ b/lib/data/sync/outbox_queue.dart @@ -49,35 +49,76 @@ class OutboxQueue { /// Marks [targetId] as dirty, coalescing with any item already waiting for /// the same row. - Future enqueue(OutboxTarget target, String targetId) async { - if (!(await readSyncSession(_db)).enabled) { - throw StateError('This account session has ended.'); - } - final revision = const Uuid().v4(); - await _db - .into(_db.outbox) - .insert( - OutboxCompanion.insert( - target: target, - targetId: targetId, - revision: revision, - createdAt: _clock(), - ), - onConflict: DoUpdate( - // A fresh change deserves an immediate attempt even if a previous - // one had been backed off, or set aside as a dead letter: whatever - // the server refused may be exactly what this edit changed. - (_) => OutboxCompanion( - revision: Value(revision), - attempts: Value(0), - nextAttemptAt: Value(null), - lastError: Value(null), - deadLetteredAt: Value(null), - ), - ), - ); - - if (_queued.hasListener) _queued.add(null); + /// + /// The item goes to the back of the queue, where this change was made, + /// unless the server has never seen the row: then it keeps its place, ahead + /// of the changes that came after its creation and may name it. Pushing + /// reads each row as it is then, so a creation sent from its old place + /// carries any later edit too. + Future enqueue(OutboxTarget target, String targetId) => + _db.transaction(() async { + if (!(await readSyncSession(_db)).enabled) { + throw StateError('This account session has ended.'); + } + final revision = const Uuid().v4(); + final position = await _nextPosition(); + final moves = await _isOnServer(target, targetId); + await _db + .into(_db.outbox) + .insert( + OutboxCompanion.insert( + target: target, + targetId: targetId, + revision: revision, + position: position, + ), + onConflict: DoUpdate( + // A fresh change deserves an immediate attempt even if a + // previous one had been backed off, or set aside as a dead + // letter: whatever the server refused may be exactly what this + // edit changed. + (_) => OutboxCompanion( + revision: Value(revision), + position: moves ? Value(position) : const Value.absent(), + attempts: const Value(0), + nextAttemptAt: const Value(null), + lastError: const Value(null), + deadLetteredAt: const Value(null), + ), + ), + ); + + if (_queued.hasListener) _queued.add(null); + }); + + Future _nextPosition() async { + final last = _db.outbox.position.max(); + final row = await (_db.selectOnly( + _db.outbox, + )..addColumns([last])).getSingle(); + return (row.read(last) ?? 0) + 1; + } + + /// Whether the server holds a version of the row: a sequence number, or for + /// a profile a version, came back for it. + Future _isOnServer(OutboxTarget target, String id) async { + final BaseSelectStatement acknowledged = switch (target) { + OutboxTarget.group => _db.select( + _db.groups, + )..where((t) => t.id.equals(id) & t.seq.isNotNull()), + OutboxTarget.member => _db.select( + _db.members, + )..where((t) => t.id.equals(id) & t.seq.isNotNull()), + OutboxTarget.entry => _db.select( + _db.entries, + )..where((t) => t.id.equals(id) & t.seq.isNotNull()), + OutboxTarget.profile => _db.select( + _db.profiles, + )..where((t) => t.id.equals(id) & t.version.isNotNull()), + }; + final exists = existsQuery(acknowledged); + return (await _db.selectExpressions([exists]).getSingle()).read(exists) ?? + false; } /// Items ready to be attempted now, in an order the server can accept. @@ -98,8 +139,10 @@ class OutboxQueue { )..where((t) => t.deadLetteredAt.isNull())).get(); rows.sort((a, b) { - final byKind = a.target.index.compareTo(b.target.index); - return byKind != 0 ? byKind : a.createdAt.compareTo(b.createdAt); + final byPosition = a.position.compareTo(b.position); + return byPosition != 0 + ? byPosition + : a.target.index.compareTo(b.target.index); }); return rows; diff --git a/lib/data/sync/shared_feeds.dart b/lib/data/sync/shared_feeds.dart index 47deeca..61241d5 100644 --- a/lib/data/sync/shared_feeds.dart +++ b/lib/data/sync/shared_feeds.dart @@ -38,6 +38,7 @@ class SharedFeeds { static const _fxFloor = 'fx:floor'; static const _profileFeed = 'profiles'; + static const _referenceTag = 'reference:etag'; Future pullAll() async { await pullReferenceData(); @@ -47,12 +48,24 @@ class SharedFeeds { /// Currencies and categories, which must land before a group can be /// created. Upsert, never delete: a withdrawn category is still on the - /// entries that used it. + /// entries that used it. Asked for with the last copy's tag, so an + /// unchanged list is a 304 with no body. Future pullReferenceData() async { try { - final reference = await fetch( - client.getReferenceApi().getReference(), - ).timeout(requestTimeout); + final held = await hasReferenceData() + ? await _readCursor(_referenceTag) + : null; + final response = await client + .getReferenceApi() + .getReference( + ifNoneMatch: held, + validateStatus: (status) => + status != null && (status ~/ 100 == 2 || status == 304), + ) + .timeout(requestTimeout); + final reference = response.data; + if (response.statusCode == 304 || reference == null) return; + await db.batch((batch) { for (final currency in reference.currencies) { final row = currency.toRow().toCompanion(false); @@ -63,6 +76,9 @@ class SharedFeeds { batch.insert(db.categories, row, onConflict: DoUpdate((_) => row)); } }); + if (response.headers.value('etag') case final tag?) { + await _writeCursor(_referenceTag, tag); + } } catch (error, stackTrace) { _log('Reference data was not refreshed', error, stackTrace); } @@ -129,28 +145,28 @@ class SharedFeeds { } } - /// Everybody this account shares a group with, cursored by the server. + /// Everybody this account shares a group with, past the newest version + /// already read. Future pullProfiles() async { - var cursor = await _readCursor(_profileFeed); + var since = int.tryParse(await _readCursor(_profileFeed) ?? '') ?? 0; var applied = 0; while (true) { await assertActive(); final page = await fetch( - client.getSyncApi().getProfiles(after: cursor, limit: pageSize), + client.getSyncApi().getProfiles(since: since, limit: pageSize), ).timeout(requestTimeout); if (page.profiles.isEmpty) break; - final next = page.cursor; applied += await db.transaction(() async { await assertActive(); final count = await applyProfiles(db, page.profiles); - if (next != null) await _writeCursor(_profileFeed, next); + await _writeCursor(_profileFeed, '${page.seq}'); return count; }); - if (next == null || !page.hasMore) break; - cursor = next; + if (!page.hasMore || page.seq == since) break; + since = page.seq; } return applied; } diff --git a/lib/data/sync/sync_engine.dart b/lib/data/sync/sync_engine.dart index 4886585..ffea910 100644 --- a/lib/data/sync/sync_engine.dart +++ b/lib/data/sync/sync_engine.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:developer' as developer; import 'package:dio/dio.dart' as dio; import 'package:drift/drift.dart'; @@ -116,25 +117,38 @@ class SyncEngine { Future _call(Future> request) => fetch(request).timeout(requestTimeout); - /// The server's groups for this account, plus local ones it has not seen, - /// less those this account has left for good. Throws when the server cannot - /// be asked: the local list alone cannot show that an account has no other - /// groups. + /// The groups this account is in, by the server's account and this + /// device's, less those [_unreadable] says not to ask about. Throws when the + /// server cannot be asked: the local list alone cannot show that an account + /// has no other groups. Future> discoverGroups() async { - final local = await db.select(db.groups).get(); - final remote = await _call(client.getSyncApi().listGroups()); - final left = await _leftForGood(); + final answer = await _call( + client.getSyncApi().pull( + pullRequest: api.PullRequest(groups: const [], limit: pageSize), + ), + ); + final skipped = await _unreadable(); return { - for (final row in local) - if (!left.contains(row.id)) row.id, - ...remote.groupIds, - }.toList()..sort(); + for (final row in await db.select(db.groups).get()) row.id, + ...answer.groupIds, + }.difference(skipped).toList()..sort(); } + /// Groups a pull can only waste a request on: ones the server has never + /// acknowledged (their creation is still in the outbox, and the server + /// would answer `no_group`), and ones this account has left for good. + Future> _unreadable() async => { + for (final group in await (db.select( + db.groups, + )..where((t) => t.seq.isNull())).get()) + group.id, + ...await _leftForGood(), + }; + /// Groups this account has left or been removed from, and this device has /// already read that back from the server. A pull of one can only ever come - /// back empty, so asking again on every sync is a request for nothing. An - /// invite link brings the group back through [ListGroups] once it is joined. + /// back empty. An invite link brings the group back, and the server then + /// names it among the account's groups again. Future> _leftForGood() async { final account = accountId; if (account == null) return const {}; @@ -149,10 +163,7 @@ class SyncEngine { .get(); if (departed.isEmpty) return const {}; - final cursors = { - for (final cursor in await db.select(db.groupCursors).get()) - cursor.groupId: cursor.seq, - }; + final cursors = await _groupCursors(); // Unsent, the leave is this device's word alone, and the server may still // refuse it. final unsent = await outbox.unsentIds(OutboxTarget.member); @@ -164,26 +175,33 @@ class SyncEngine { }; } - Future syncGroup(String groupId) => - _serialized(() => _run(() => pull(groupId))); + /// Pushes what is waiting, then reads [groupIds] and every group the push + /// wrote to. What a screen, a local write or a push message asks for: the + /// shared feeds are left to [syncEverything]. + Future syncGroups(Set groupIds) => + _serialized(() => _run(groupIds: groupIds)); - /// Every group in one run: the outbox and the shared feeds once. A group - /// that cannot be pulled ends the sweep; the cause is likely the connection. - Future syncEverything() => _serialized( - () => _run(() async { - var pulled = 0; - for (final groupId in await discoverGroups()) { - pulled += await pull(groupId); - } - return pulled; - }), - ); + Future syncGroup(String groupId) => syncGroups({groupId}); - Future _run(Future Function() pullGroups) async { + /// The outbox, the shared feeds, and every group the account is in. + Future syncEverything() => + _serialized(() => _run(groupIds: const {}, everything: true)); + + Future _run({ + required Set groupIds, + bool everything = false, + }) async { final pushed = await push(); try { - await shared.pullAll(); - final pulled = await pullGroups(); + if (everything) { + await shared.pullAll(); + } else { + await shared.pullFxRates(); + } + final pulled = await _pull({ + ...groupIds, + ...pushed.groups, + }, everything: everything); return SyncReport( pushed: pushed.sent, pulled: pulled, @@ -214,6 +232,8 @@ class SyncEngine { return await _gate.synchronized(() async { _activeEpoch = (await readSyncSession(db)).epoch; await _assertActive(); + // Another isolate may have held the gate, and written, just now. + await db.noticeWritesElsewhere(); final report = await operation(); return report.withNextPushAt(await outbox.nextAttemptAt()); }); @@ -236,9 +256,12 @@ class SyncEngine { /// dead-lettered, so the loop ends unless the queue refills as it drains. static const int _maxPushRounds = 50; - Future<({int sent, int failed})> push() async { + /// Sends the outbox in order. [groups] are the ones it wrote to, or found + /// it needs to read back. + Future<({int sent, int failed, Set groups})> push() async { var sent = 0; var failed = 0; + final groups = {}; for (var round = 0; round < _maxPushRounds; round++) { await _assertActive(); @@ -247,16 +270,18 @@ class SyncEngine { for (final item in due) { try { - await _pushOne(item); + if (await _pushOne(item) case final groupId?) groups.add(groupId); await outbox.complete(item); sent++; } on ApiFailure catch (e) { switch (e.retry) { case api.Retry.stale: - await _parkConflict(item); + if (await _parkConflict(item) case final groupId?) { + groups.add(groupId); + } case api.Retry.transient: await outbox.fail(item, e.message); - return (sent: sent, failed: failed + 1); + return (sent: sent, failed: failed + 1, groups: groups); // Refused identically next time; retrying would wedge the queue. case api.Retry.permanent: case api.Retry.unknownDefaultOpenApi: @@ -266,21 +291,21 @@ class SyncEngine { } catch (e) { // A connection failure affects the whole batch. await outbox.fail(item, '$e'); - return (sent: sent, failed: failed + 1); + return (sent: sent, failed: failed + 1, groups: groups); } } } - return (sent: sent, failed: failed); + return (sent: sent, failed: failed, groups: groups); } /// Parks an edit the server refused as stale for a person to read, and /// rewinds the cursor to its base so the next pull brings the server's - /// version (skipped while the row was dirty). - Future _parkConflict(OutboxRow item) async { - await db.transaction(() async { - if (!await outbox.isCurrent(item)) return; + /// version (skipped while the row was dirty). Answers the group to read. + Future _parkConflict(OutboxRow item) async { + return db.transaction(() async { + if (!await outbox.isCurrent(item)) return null; final entry = await _loadEntry(item.targetId); - if (entry == null) return; + if (entry == null) return null; await db .into(db.entryConflicts) @@ -295,6 +320,7 @@ class SyncEngine { ); await _rewindCursor(entry.row.groupId, to: entry.row.seq ?? 0); await outbox.complete(item); + return entry.row.groupId; }); } @@ -346,9 +372,9 @@ class SyncEngine { if (row.seq case final seq?) return _rewindCursor(id, to: seq - 1); await (db.delete(db.groups)..where((t) => t.id.equals(id))).go(); case OutboxTarget.profile: - // Cursored on time, not seq: forget this copy's timestamp and re-read. + // Not on a group's feed: forget this copy's version and re-read. await (db.update(db.profiles)..where((t) => t.id.equals(id))).write( - const ProfilesCompanion(updatedAt: Value(null)), + const ProfilesCompanion(version: Value(null)), ); await shared.resetProfileFeed(); } @@ -391,13 +417,14 @@ class SyncEngine { /// Sends one dirty row, read now rather than at queue time, and records the /// sequence number the server gave it: its version and the next edit's base. - Future _pushOne(OutboxRow item) async { + /// Answers the group it wrote to, if it sent anything that has one. + Future _pushOne(OutboxRow item) async { switch (item.target) { case OutboxTarget.entry: final entry = await _snapshot(item, () => _loadEntry(item.targetId)); - if (entry == null) return; + if (entry == null) return null; // Created and deleted before its first push: nothing remote to delete. - if (entry.isDeleted && entry.row.seq == null) return; + if (entry.isDeleted && entry.row.seq == null) return null; final stored = await _call( client.getEntriesApi().putEntry( @@ -411,6 +438,7 @@ class SyncEngine { await (db.update(db.entries)..where((t) => t.id.equals(stored.id))) .write(EntriesCompanion(seq: Value(stored.seq))); }); + return entry.row.groupId; case OutboxTarget.group: final group = await _snapshot( @@ -419,7 +447,7 @@ class SyncEngine { db.groups, )..where((t) => t.id.equals(item.targetId))).getSingleOrNull(), ); - if (group == null) return; + if (group == null) return null; // A group the server has never seen is created with its creator. final creator = await _creatorOf(group); @@ -439,6 +467,7 @@ class SyncEngine { .write(MembersCompanion(seq: Value(stored.seq))); } }); + return group.id; case OutboxTarget.member: final member = await _snapshot( @@ -447,7 +476,7 @@ class SyncEngine { db.members, )..where((t) => t.id.equals(item.targetId))).getSingleOrNull(), ); - if (member == null) return; + if (member == null) return null; final stored = await _call( client.getGroupsApi().putMember( @@ -461,6 +490,7 @@ class SyncEngine { await (db.update(db.members)..where((t) => t.id.equals(member.id))) .write(MembersCompanion(seq: Value(stored.seq))); }); + return member.groupId; case OutboxTarget.profile: final profile = await _snapshot( @@ -469,7 +499,7 @@ class SyncEngine { db.profiles, )..where((t) => t.id.equals(item.targetId))).getSingleOrNull(), ); - if (profile == null) return; + if (profile == null) return null; final stored = await _call( client.getSyncApi().updateProfile(profileUpdate: profile.toUpdate()), @@ -477,8 +507,9 @@ class SyncEngine { await db.transaction(() async { if (!await outbox.isCurrent(item)) return; await (db.update(db.profiles)..where((t) => t.id.equals(profile.id))) - .write(ProfilesCompanion(updatedAt: Value(stored.updatedAt))); + .write(ProfilesCompanion(version: Value(stored.version))); }); + return null; } } @@ -499,35 +530,80 @@ class SyncEngine { } /// One group's changes after its cursor, page by page. - Future pull(String groupId) async { - var cursor = await _readGroupCursor(groupId); - var applied = 0; + Future pull(String groupId) => _pull({groupId}, everything: false); + + /// The most groups the server reads in one pull. + static const _groupsPerPull = 50; + + /// Reads [groupIds] past their cursors, many groups to a request, until each + /// is caught up or refused. With [everything], also every group this + /// account is in, and the server's answer names any it joined elsewhere. + /// + /// A group the server refuses is logged and left as it is here: one group + /// it cannot read says nothing about the others. + Future _pull(Set groupIds, {required bool everything}) async { + final cursors = await _groupCursors(); + final skipped = await _unreadable(); + final wanted = {}; + final done = {}; + void want(String groupId) { + if (done.contains(groupId) || skipped.contains(groupId)) return; + wanted.putIfAbsent(groupId, () => cursors[groupId] ?? 0); + } - while (true) { + if (everything) { + for (final group in await db.select(db.groups).get()) { + want(group.id); + } + } + groupIds.forEach(want); + + var applied = 0; + var asked = false; + while (wanted.isNotEmpty || (everything && !asked)) { await _assertActive(); - final page = await _call( - client.getSyncApi().getChanges( - groupId: groupId, - since: cursor, - limit: pageSize, + final batch = [ + for (final MapEntry(key: groupId, value: since) in wanted.entries.take( + _groupsPerPull, + )) + api.GroupCursor(groupId: groupId, since: since), + ]; + final answer = await _call( + client.getSyncApi().pull( + pullRequest: api.PullRequest(groups: batch, limit: pageSize), ), ); + asked = true; - applied += await applyGroupChanges(db, page, now: _clock()); - - if (page.purgedAt != null) return applied; - if (page.seq == cursor) break; - cursor = page.seq; - if (!page.hasMore) break; + for (final cursor in batch) { + wanted.remove(cursor.groupId); + done.add(cursor.groupId); + } + for (final page in answer.pages) { + applied += await applyGroupChanges(db, page, now: _clock()); + final since = cursors[page.groupId] ?? 0; + cursors[page.groupId] = page.seq; + if (page.hasMore && page.purgedAt == null && page.seq > since) { + done.remove(page.groupId); + want(page.groupId); + } + } + for (final refusal in answer.refusals) { + developer.log( + 'Group ${refusal.groupId} was not read: ${refusal.message}', + name: 'opensplit.sync', + level: 900, + ); + } + if (everything) answer.groupIds.forEach(want); } return applied; } - Future _readGroupCursor(String groupId) async => - (await (db.select( - db.groupCursors, - )..where((t) => t.groupId.equals(groupId))).getSingleOrNull())?.seq ?? - 0; + Future> _groupCursors() async => { + for (final cursor in await db.select(db.groupCursors).get()) + cursor.groupId: cursor.seq, + }; Future _loadEntry(String entryId) async { final row = await (db.select( diff --git a/lib/data/sync/wire.dart b/lib/data/sync/wire.dart index 8b48b05..dc1e571 100644 --- a/lib/data/sync/wire.dart +++ b/lib/data/sync/wire.dart @@ -99,7 +99,7 @@ extension ProfileFromWire on api.Profile { avatarEmoji: avatarEmoji, avatarIcon: avatarIcon, avatarPhoto: avatarPhoto, - updatedAt: updatedAt, + version: version, ); } diff --git a/lib/presentation/app.dart b/lib/presentation/app.dart index 6b7b13f..3136cd3 100644 --- a/lib/presentation/app.dart +++ b/lib/presentation/app.dart @@ -100,7 +100,7 @@ class _OpenSplitAppState extends ConsumerState { /// the group been doing, and is there a new version of the app. void _onResume() { if (ref.read(signedInProvider)) { - ref.read(appDatabaseProvider).refreshAfterExternalSync(); + unawaited(ref.read(appDatabaseProvider).noticeWritesElsewhere()); } ref.read(syncControllerProvider.notifier).resumed(); _offerUpdate(); diff --git a/lib/presentation/screens/members_screen.dart b/lib/presentation/screens/members_screen.dart index d081ecf..6d7c89d 100644 --- a/lib/presentation/screens/members_screen.dart +++ b/lib/presentation/screens/members_screen.dart @@ -224,21 +224,15 @@ class MembersScreen extends ConsumerWidget { Member member, GroupLedger ledger, ) async { - final owes = ledger.balances.any( - (b) => b.memberId == member.id && b.balanceMinor != 0, - ); - + // Only offered once they are settled: the server refuses anything else, + // since a balance would be left with somebody who can no longer see it. final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text('Remove ${ledger.nameOfMember(member)}?'), - content: Text( - owes - ? 'They still have an unsettled balance. Removing them keeps ' - 'every expense they were part of, and their balance stays ' - 'visible so it can be settled.' - : 'Their past expenses stay exactly as they are. They just stop ' - 'being included in new ones.', + content: const Text( + 'Their past expenses stay exactly as they are. They just stop ' + 'being included in new ones.', ), actions: [ TextButton( diff --git a/packages/opensplit_api/lib/opensplit_api.dart b/packages/opensplit_api/lib/opensplit_api.dart index 9f7acbe..d1c55c3 100644 --- a/packages/opensplit_api/lib/opensplit_api.dart +++ b/packages/opensplit_api/lib/opensplit_api.dart @@ -50,10 +50,11 @@ export 'package:opensplit_api/src/model/google_identity_request.dart'; export 'package:opensplit_api/src/model/google_redirect.dart'; export 'package:opensplit_api/src/model/google_redirect_request.dart'; export 'package:opensplit_api/src/model/group.dart'; +export 'package:opensplit_api/src/model/group_cursor.dart'; export 'package:opensplit_api/src/model/group_event_payload.dart'; -export 'package:opensplit_api/src/model/group_ids.dart'; export 'package:opensplit_api/src/model/group_input.dart'; export 'package:opensplit_api/src/model/group_link.dart'; +export 'package:opensplit_api/src/model/group_refusal.dart'; export 'package:opensplit_api/src/model/health.dart'; export 'package:opensplit_api/src/model/identity_outcome.dart'; export 'package:opensplit_api/src/model/identity_outcome_kind.dart'; @@ -76,6 +77,8 @@ export 'package:opensplit_api/src/model/platform.dart'; export 'package:opensplit_api/src/model/profile.dart'; export 'package:opensplit_api/src/model/profile_page.dart'; export 'package:opensplit_api/src/model/profile_update.dart'; +export 'package:opensplit_api/src/model/pull.dart'; +export 'package:opensplit_api/src/model/pull_request.dart'; export 'package:opensplit_api/src/model/push_data.dart'; export 'package:opensplit_api/src/model/reauth_start.dart'; export 'package:opensplit_api/src/model/reauth_verify_request.dart'; diff --git a/packages/opensplit_api/lib/src/api/entries_api.dart b/packages/opensplit_api/lib/src/api/entries_api.dart index d08544f..a9c5338 100644 --- a/packages/opensplit_api/lib/src/api/entries_api.dart +++ b/packages/opensplit_api/lib/src/api/entries_api.dart @@ -19,7 +19,7 @@ class EntriesApi { const EntriesApi(this._dio); /// Record, edit, delete or restore an expense, whole - /// A stale `baseSeq` is refused only when the write would move money, so two people fixing a typo never arbitrate. Deleting and restoring always move money. + /// `baseSeq` must be the version stored now (null for a new expense), or the write is refused as `stale_base`: the expense is replaced whole, so a write composed on an older version would undo the edit in between. Sending the stored contents again is answered with the stored row, whatever the base. /// /// Parameters: /// * [groupId] diff --git a/packages/opensplit_api/lib/src/api/sync_api.dart b/packages/opensplit_api/lib/src/api/sync_api.dart index 315a194..02dbc27 100644 --- a/packages/opensplit_api/lib/src/api/sync_api.dart +++ b/packages/opensplit_api/lib/src/api/sync_api.dart @@ -9,23 +9,22 @@ import 'dart:convert'; import 'package:opensplit_api/src/deserialize.dart'; import 'package:dio/dio.dart'; -import 'package:opensplit_api/src/model/change_page.dart'; import 'package:opensplit_api/src/model/error.dart'; -import 'package:opensplit_api/src/model/group_ids.dart'; import 'package:opensplit_api/src/model/profile.dart'; import 'package:opensplit_api/src/model/profile_page.dart'; import 'package:opensplit_api/src/model/profile_update.dart'; +import 'package:opensplit_api/src/model/pull.dart'; +import 'package:opensplit_api/src/model/pull_request.dart'; class SyncApi { final Dio _dio; const SyncApi(this._dio); - /// Everything that changed in one group since a cursor - /// `limit` counts changes, not rows, and a page is cut only between sequence numbers, so a device sees a whole write or none of it. Send `seq` back as `since` next time. + /// Everybody you share a group with, plus yourself + /// Cursored on each profile's `version`, which D1 numbers in commit order. Send `seq` back as `since` next time. /// /// Parameters: - /// * [groupId] /// * [since] /// * [limit] /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation @@ -35,10 +34,9 @@ class SyncApi { /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress /// - /// Returns a [Future] containing a [Response] with a [ChangePage] as data + /// Returns a [Future] containing a [Response] with a [ProfilePage] as data /// Throws [DioException] if API call or serialization fails - Future> getChanges({ - required String groupId, + Future> getProfiles({ int? since, int? limit, CancelToken? cancelToken, @@ -48,12 +46,7 @@ class SyncApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/groups/{groupId}/changes'.replaceAll( - '{' - r'groupId' - '}', - groupId.toString(), - ); + final _path = r'/api/profiles'; final _options = Options( method: r'GET', headers: {...?headers}, @@ -86,15 +79,15 @@ class SyncApi { onReceiveProgress: onReceiveProgress, ); - ChangePage? _responseData; + ProfilePage? _responseData; try { final rawData = _response.data; _responseData = rawData == null ? null - : deserialize( + : deserialize( rawData, - 'ChangePage', + 'ProfilePage', growable: true, ); } catch (error, stackTrace) { @@ -107,7 +100,7 @@ class SyncApi { ); } - return Response( + return Response( data: _responseData, headers: _response.headers, isRedirect: _response.isRedirect, @@ -119,12 +112,11 @@ class SyncApi { ); } - /// Everybody you share a group with, plus yourself - /// Cursored on time, since profiles have no single writer to number them. Send `cursor` back as `after`. + /// Every group this account is in, and what changed in the ones asked for + /// Each group asked for gets its own page past its own cursor, read in parallel; a group that cannot be read is listed in `refusals` and the rest are still answered. `groupIds` names every group the account is in now, so one with no cursor yet is asked for next time, from 0. A page is cut only between sequence numbers, so a device sees a whole write or none of it; send each page's `seq` back as that group's `since`. /// /// Parameters: - /// * [after] - /// * [limit] + /// * [pullRequest] /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation /// * [headers] - Can be used to add additional headers to the request /// * [extras] - Can be used to add flags to the request @@ -132,11 +124,10 @@ class SyncApi { /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress /// - /// Returns a [Future] containing a [Response] with a [ProfilePage] as data + /// Returns a [Future] containing a [Response] with a [Pull] as data /// Throws [DioException] if API call or serialization fails - Future> getProfiles({ - String? after, - int? limit, + Future> pull({ + required PullRequest pullRequest, CancelToken? cancelToken, Map? headers, Map? extra, @@ -144,9 +135,9 @@ class SyncApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/api/profiles'; + final _path = r'/api/sync'; final _options = Options( - method: r'GET', + method: r'POST', headers: {...?headers}, extra: { 'secure': >[ @@ -160,115 +151,39 @@ class SyncApi { ], ...?extra, }, + contentType: 'application/json', validateStatus: validateStatus, ); - final _queryParameters = { - if (after != null) r'after': after, - if (limit != null) r'limit': limit, - }; - - final _response = await _dio.request( - _path, - options: _options, - queryParameters: _queryParameters, - cancelToken: cancelToken, - onSendProgress: onSendProgress, - onReceiveProgress: onReceiveProgress, - ); - - ProfilePage? _responseData; + dynamic _bodyData; try { - final rawData = _response.data; - _responseData = rawData == null - ? null - : deserialize( - rawData, - 'ProfilePage', - growable: true, - ); + _bodyData = jsonEncode(pullRequest); } catch (error, stackTrace) { throw DioException( - requestOptions: _response.requestOptions, - response: _response, + requestOptions: _options.compose(_dio.options, _path), type: DioExceptionType.unknown, error: error, stackTrace: stackTrace, ); } - return Response( - data: _responseData, - headers: _response.headers, - isRedirect: _response.isRedirect, - requestOptions: _response.requestOptions, - redirects: _response.redirects, - statusCode: _response.statusCode, - statusMessage: _response.statusMessage, - extra: _response.extra, - ); - } - - /// The groups this account is still in - /// - /// - /// Parameters: - /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation - /// * [headers] - Can be used to add additional headers to the request - /// * [extras] - Can be used to add flags to the request - /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response - /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress - /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress - /// - /// Returns a [Future] containing a [Response] with a [GroupIds] as data - /// Throws [DioException] if API call or serialization fails - Future> listGroups({ - CancelToken? cancelToken, - Map? headers, - Map? extra, - ValidateStatus? validateStatus, - ProgressCallback? onSendProgress, - ProgressCallback? onReceiveProgress, - }) async { - final _path = r'/api/groups'; - final _options = Options( - method: r'GET', - headers: {...?headers}, - extra: { - 'secure': >[ - { - 'type': 'apiKey', - 'name': 'cookie', - 'keyName': 'better-auth.session_token', - 'where': '', - }, - {'type': 'http', 'scheme': 'bearer', 'name': 'bearer'}, - ], - ...?extra, - }, - validateStatus: validateStatus, - ); - final _response = await _dio.request( _path, + data: _bodyData, options: _options, cancelToken: cancelToken, onSendProgress: onSendProgress, onReceiveProgress: onReceiveProgress, ); - GroupIds? _responseData; + Pull? _responseData; try { final rawData = _response.data; _responseData = rawData == null ? null - : deserialize( - rawData, - 'GroupIds', - growable: true, - ); + : deserialize(rawData, 'Pull', growable: true); } catch (error, stackTrace) { throw DioException( requestOptions: _response.requestOptions, @@ -279,7 +194,7 @@ class SyncApi { ); } - return Response( + return Response( data: _responseData, headers: _response.headers, isRedirect: _response.isRedirect, diff --git a/packages/opensplit_api/lib/src/deserialize.dart b/packages/opensplit_api/lib/src/deserialize.dart index 7905904..537fdd3 100644 --- a/packages/opensplit_api/lib/src/deserialize.dart +++ b/packages/opensplit_api/lib/src/deserialize.dart @@ -22,10 +22,11 @@ import 'package:opensplit_api/src/model/google_identity_request.dart'; import 'package:opensplit_api/src/model/google_redirect.dart'; import 'package:opensplit_api/src/model/google_redirect_request.dart'; import 'package:opensplit_api/src/model/group.dart'; +import 'package:opensplit_api/src/model/group_cursor.dart'; import 'package:opensplit_api/src/model/group_event_payload.dart'; -import 'package:opensplit_api/src/model/group_ids.dart'; import 'package:opensplit_api/src/model/group_input.dart'; import 'package:opensplit_api/src/model/group_link.dart'; +import 'package:opensplit_api/src/model/group_refusal.dart'; import 'package:opensplit_api/src/model/health.dart'; import 'package:opensplit_api/src/model/identity_outcome.dart'; import 'package:opensplit_api/src/model/invite.dart'; @@ -45,6 +46,8 @@ import 'package:opensplit_api/src/model/placeholder_list.dart'; import 'package:opensplit_api/src/model/profile.dart'; import 'package:opensplit_api/src/model/profile_page.dart'; import 'package:opensplit_api/src/model/profile_update.dart'; +import 'package:opensplit_api/src/model/pull.dart'; +import 'package:opensplit_api/src/model/pull_request.dart'; import 'package:opensplit_api/src/model/push_data.dart'; import 'package:opensplit_api/src/model/reauth_start.dart'; import 'package:opensplit_api/src/model/reauth_verify_request.dart'; @@ -141,15 +144,17 @@ ReturnType deserialize( as ReturnType; case 'Group': return Group.fromJson(value as Map) as ReturnType; + case 'GroupCursor': + return GroupCursor.fromJson(value as Map) as ReturnType; case 'GroupEventPayload': return GroupEventPayload.fromJson(value as Map) as ReturnType; - case 'GroupIds': - return GroupIds.fromJson(value as Map) as ReturnType; case 'GroupInput': return GroupInput.fromJson(value as Map) as ReturnType; case 'GroupLink': return GroupLink.fromJson(value as Map) as ReturnType; + case 'GroupRefusal': + return GroupRefusal.fromJson(value as Map) as ReturnType; case 'Health': return Health.fromJson(value as Map) as ReturnType; case 'IdentityOutcome': @@ -197,6 +202,10 @@ ReturnType deserialize( case 'ProfileUpdate': return ProfileUpdate.fromJson(value as Map) as ReturnType; + case 'Pull': + return Pull.fromJson(value as Map) as ReturnType; + case 'PullRequest': + return PullRequest.fromJson(value as Map) as ReturnType; case 'PushData': return PushData.fromJson(value as Map) as ReturnType; case 'ReauthStart': diff --git a/packages/opensplit_api/lib/src/model/group_cursor.dart b/packages/opensplit_api/lib/src/model/group_cursor.dart new file mode 100644 index 0000000..6ecc2df --- /dev/null +++ b/packages/opensplit_api/lib/src/model/group_cursor.dart @@ -0,0 +1,44 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:json_annotation/json_annotation.dart'; + +part 'group_cursor.g.dart'; + +@JsonSerializable( + checked: true, + createToJson: true, + disallowUnrecognizedKeys: false, + explicitToJson: true, +) +class GroupCursor { + /// Returns a new [GroupCursor] instance. + GroupCursor({required this.groupId, required this.since}); + + @JsonKey(name: r'groupId', required: true, includeIfNull: false) + final String groupId; + + // minimum: 0 + @JsonKey(name: r'since', required: true, includeIfNull: false) + final int since; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GroupCursor && other.groupId == groupId && other.since == since; + + @override + int get hashCode => groupId.hashCode + since.hashCode; + + factory GroupCursor.fromJson(Map json) => + _$GroupCursorFromJson(json); + + Map toJson() => _$GroupCursorToJson(this); + + @override + String toString() { + return toJson().toString(); + } +} diff --git a/packages/opensplit_api/lib/src/model/group_cursor.g.dart b/packages/opensplit_api/lib/src/model/group_cursor.g.dart new file mode 100644 index 0000000..e1adf52 --- /dev/null +++ b/packages/opensplit_api/lib/src/model/group_cursor.g.dart @@ -0,0 +1,20 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'group_cursor.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GroupCursor _$GroupCursorFromJson(Map json) => + $checkedCreate('GroupCursor', json, ($checkedConvert) { + $checkKeys(json, requiredKeys: const ['groupId', 'since']); + final val = GroupCursor( + groupId: $checkedConvert('groupId', (v) => v as String), + since: $checkedConvert('since', (v) => (v as num).toInt()), + ); + return val; + }); + +Map _$GroupCursorToJson(GroupCursor instance) => + {'groupId': instance.groupId, 'since': instance.since}; diff --git a/packages/opensplit_api/lib/src/model/group_ids.dart b/packages/opensplit_api/lib/src/model/group_ids.dart deleted file mode 100644 index a8d56aa..0000000 --- a/packages/opensplit_api/lib/src/model/group_ids.dart +++ /dev/null @@ -1,39 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// - -// ignore_for_file: unused_element -import 'package:json_annotation/json_annotation.dart'; - -part 'group_ids.g.dart'; - -@JsonSerializable( - checked: true, - createToJson: true, - disallowUnrecognizedKeys: false, - explicitToJson: true, -) -class GroupIds { - /// Returns a new [GroupIds] instance. - GroupIds({required this.groupIds}); - - @JsonKey(name: r'groupIds', required: true, includeIfNull: false) - final List groupIds; - - @override - bool operator ==(Object other) => - identical(this, other) || other is GroupIds && other.groupIds == groupIds; - - @override - int get hashCode => groupIds.hashCode; - - factory GroupIds.fromJson(Map json) => - _$GroupIdsFromJson(json); - - Map toJson() => _$GroupIdsToJson(this); - - @override - String toString() { - return toJson().toString(); - } -} diff --git a/packages/opensplit_api/lib/src/model/group_ids.g.dart b/packages/opensplit_api/lib/src/model/group_ids.g.dart deleted file mode 100644 index 22c1ba0..0000000 --- a/packages/opensplit_api/lib/src/model/group_ids.g.dart +++ /dev/null @@ -1,23 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'group_ids.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -GroupIds _$GroupIdsFromJson(Map json) => - $checkedCreate('GroupIds', json, ($checkedConvert) { - $checkKeys(json, requiredKeys: const ['groupIds']); - final val = GroupIds( - groupIds: $checkedConvert( - 'groupIds', - (v) => (v as List).map((e) => e as String).toList(), - ), - ); - return val; - }); - -Map _$GroupIdsToJson(GroupIds instance) => { - 'groupIds': instance.groupIds, -}; diff --git a/packages/opensplit_api/lib/src/model/group_refusal.dart b/packages/opensplit_api/lib/src/model/group_refusal.dart new file mode 100644 index 0000000..03c2904 --- /dev/null +++ b/packages/opensplit_api/lib/src/model/group_refusal.dart @@ -0,0 +1,61 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:opensplit_api/src/model/error_code.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'group_refusal.g.dart'; + +@JsonSerializable( + checked: true, + createToJson: true, + disallowUnrecognizedKeys: false, + explicitToJson: true, +) +class GroupRefusal { + /// Returns a new [GroupRefusal] instance. + GroupRefusal({ + required this.groupId, + + required this.code, + + required this.message, + }); + + @JsonKey(name: r'groupId', required: true, includeIfNull: false) + final String groupId; + + @JsonKey( + name: r'code', + required: true, + includeIfNull: false, + unknownEnumValue: ErrorCode.unknownDefaultOpenApi, + ) + final ErrorCode code; + + @JsonKey(name: r'message', required: true, includeIfNull: false) + final String message; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GroupRefusal && + other.groupId == groupId && + other.code == code && + other.message == message; + + @override + int get hashCode => groupId.hashCode + code.hashCode + message.hashCode; + + factory GroupRefusal.fromJson(Map json) => + _$GroupRefusalFromJson(json); + + Map toJson() => _$GroupRefusalToJson(this); + + @override + String toString() { + return toJson().toString(); + } +} diff --git a/packages/opensplit_api/lib/src/model/group_refusal.g.dart b/packages/opensplit_api/lib/src/model/group_refusal.g.dart new file mode 100644 index 0000000..0d372d6 --- /dev/null +++ b/packages/opensplit_api/lib/src/model/group_refusal.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'group_refusal.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GroupRefusal _$GroupRefusalFromJson(Map json) => + $checkedCreate('GroupRefusal', json, ($checkedConvert) { + $checkKeys(json, requiredKeys: const ['groupId', 'code', 'message']); + final val = GroupRefusal( + groupId: $checkedConvert('groupId', (v) => v as String), + code: $checkedConvert( + 'code', + (v) => $enumDecode( + _$ErrorCodeEnumMap, + v, + unknownValue: ErrorCode.unknownDefaultOpenApi, + ), + ), + message: $checkedConvert('message', (v) => v as String), + ); + return val; + }); + +Map _$GroupRefusalToJson(GroupRefusal instance) => + { + 'groupId': instance.groupId, + 'code': _$ErrorCodeEnumMap[instance.code]!, + 'message': instance.message, + }; + +const _$ErrorCodeEnumMap = { + ErrorCode.notMember: 'not_member', + ErrorCode.noGroup: 'no_group', + ErrorCode.groupPurged: 'group_purged', + ErrorCode.noSuchEntry: 'no_such_entry', + ErrorCode.noSuchMember: 'no_such_member', + ErrorCode.unbalanced: 'unbalanced', + ErrorCode.staleBase: 'stale_base', + ErrorCode.forbidden: 'forbidden', + ErrorCode.notSettled: 'not_settled', + ErrorCode.inviteInvalid: 'invite_invalid', + ErrorCode.inviteSpent: 'invite_spent', + ErrorCode.inviteExpired: 'invite_expired', + ErrorCode.alreadyMember: 'already_member', + ErrorCode.slotTaken: 'slot_taken', + ErrorCode.malformed: 'malformed', + ErrorCode.noSession: 'no_session', + ErrorCode.notFound: 'not_found', + ErrorCode.internal: 'internal', + ErrorCode.identityAlreadyInUse: 'identity_already_in_use', + ErrorCode.authFailed: 'auth_failed', + ErrorCode.rateLimited: 'rate_limited', + ErrorCode.reauthRequired: 'reauth_required', + ErrorCode.unknownDefaultOpenApi: 'unknown_default_open_api', +}; diff --git a/packages/opensplit_api/lib/src/model/profile.dart b/packages/opensplit_api/lib/src/model/profile.dart index bb1852c..2ab7655 100644 --- a/packages/opensplit_api/lib/src/model/profile.dart +++ b/packages/opensplit_api/lib/src/model/profile.dart @@ -38,6 +38,8 @@ class Profile { required this.updatedAt, required this.deletedAt, + + required this.version, }); @JsonKey(name: r'id', required: true, includeIfNull: false) @@ -85,6 +87,10 @@ class Profile { @JsonKey(name: r'deletedAt', required: true, includeIfNull: true) final DateTime? deletedAt; + // minimum: 0 + @JsonKey(name: r'version', required: true, includeIfNull: false) + final int version; + @override bool operator ==(Object other) => identical(this, other) || @@ -98,7 +104,8 @@ class Profile { other.avatarIcon == avatarIcon && other.avatarPhoto == avatarPhoto && other.updatedAt == updatedAt && - other.deletedAt == deletedAt; + other.deletedAt == deletedAt && + other.version == version; @override int get hashCode => @@ -111,7 +118,8 @@ class Profile { (avatarIcon == null ? 0 : avatarIcon.hashCode) + (avatarPhoto == null ? 0 : avatarPhoto.hashCode) + updatedAt.hashCode + - (deletedAt == null ? 0 : deletedAt.hashCode); + (deletedAt == null ? 0 : deletedAt.hashCode) + + version.hashCode; factory Profile.fromJson(Map json) => _$ProfileFromJson(json); diff --git a/packages/opensplit_api/lib/src/model/profile.g.dart b/packages/opensplit_api/lib/src/model/profile.g.dart index 3c5d9e6..1c9c65e 100644 --- a/packages/opensplit_api/lib/src/model/profile.g.dart +++ b/packages/opensplit_api/lib/src/model/profile.g.dart @@ -21,6 +21,7 @@ Profile _$ProfileFromJson(Map json) => 'avatarPhoto', 'updatedAt', 'deletedAt', + 'version', ], ); final val = Profile( @@ -61,6 +62,7 @@ Profile _$ProfileFromJson(Map json) => 'deletedAt', (v) => v == null ? null : DateTime.parse(v as String), ), + version: $checkedConvert('version', (v) => (v as num).toInt()), ); return val; }); @@ -76,6 +78,7 @@ Map _$ProfileToJson(Profile instance) => { 'avatarPhoto': instance.avatarPhoto, 'updatedAt': instance.updatedAt.toIso8601String(), 'deletedAt': instance.deletedAt?.toIso8601String(), + 'version': instance.version, }; const _$AvatarKindEnumMap = { diff --git a/packages/opensplit_api/lib/src/model/profile_page.dart b/packages/opensplit_api/lib/src/model/profile_page.dart index 2d25ba4..d0ef568 100644 --- a/packages/opensplit_api/lib/src/model/profile_page.dart +++ b/packages/opensplit_api/lib/src/model/profile_page.dart @@ -19,7 +19,7 @@ class ProfilePage { ProfilePage({ required this.profiles, - required this.cursor, + required this.seq, required this.hasMore, }); @@ -27,8 +27,9 @@ class ProfilePage { @JsonKey(name: r'profiles', required: true, includeIfNull: false) final List profiles; - @JsonKey(name: r'cursor', required: true, includeIfNull: true) - final String? cursor; + // minimum: 0 + @JsonKey(name: r'seq', required: true, includeIfNull: false) + final int seq; @JsonKey(name: r'hasMore', required: true, includeIfNull: false) final bool hasMore; @@ -38,14 +39,11 @@ class ProfilePage { identical(this, other) || other is ProfilePage && other.profiles == profiles && - other.cursor == cursor && + other.seq == seq && other.hasMore == hasMore; @override - int get hashCode => - profiles.hashCode + - (cursor == null ? 0 : cursor.hashCode) + - hasMore.hashCode; + int get hashCode => profiles.hashCode + seq.hashCode + hasMore.hashCode; factory ProfilePage.fromJson(Map json) => _$ProfilePageFromJson(json); diff --git a/packages/opensplit_api/lib/src/model/profile_page.g.dart b/packages/opensplit_api/lib/src/model/profile_page.g.dart index 8d1ac40..0fadb9a 100644 --- a/packages/opensplit_api/lib/src/model/profile_page.g.dart +++ b/packages/opensplit_api/lib/src/model/profile_page.g.dart @@ -8,7 +8,7 @@ part of 'profile_page.dart'; ProfilePage _$ProfilePageFromJson(Map json) => $checkedCreate('ProfilePage', json, ($checkedConvert) { - $checkKeys(json, requiredKeys: const ['profiles', 'cursor', 'hasMore']); + $checkKeys(json, requiredKeys: const ['profiles', 'seq', 'hasMore']); final val = ProfilePage( profiles: $checkedConvert( 'profiles', @@ -16,7 +16,7 @@ ProfilePage _$ProfilePageFromJson(Map json) => .map((e) => Profile.fromJson(e as Map)) .toList(), ), - cursor: $checkedConvert('cursor', (v) => v as String?), + seq: $checkedConvert('seq', (v) => (v as num).toInt()), hasMore: $checkedConvert('hasMore', (v) => v as bool), ); return val; @@ -25,6 +25,6 @@ ProfilePage _$ProfilePageFromJson(Map json) => Map _$ProfilePageToJson(ProfilePage instance) => { 'profiles': instance.profiles.map((e) => e.toJson()).toList(), - 'cursor': instance.cursor, + 'seq': instance.seq, 'hasMore': instance.hasMore, }; diff --git a/packages/opensplit_api/lib/src/model/pull.dart b/packages/opensplit_api/lib/src/model/pull.dart new file mode 100644 index 0000000..65d4a4e --- /dev/null +++ b/packages/opensplit_api/lib/src/model/pull.dart @@ -0,0 +1,50 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:opensplit_api/src/model/group_refusal.dart'; +import 'package:opensplit_api/src/model/change_page.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'pull.g.dart'; + +@JsonSerializable( + checked: true, + createToJson: true, + disallowUnrecognizedKeys: false, + explicitToJson: true, +) +class Pull { + /// Returns a new [Pull] instance. + Pull({required this.groupIds, required this.pages, required this.refusals}); + + @JsonKey(name: r'groupIds', required: true, includeIfNull: false) + final List groupIds; + + @JsonKey(name: r'pages', required: true, includeIfNull: false) + final List pages; + + @JsonKey(name: r'refusals', required: true, includeIfNull: false) + final List refusals; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Pull && + other.groupIds == groupIds && + other.pages == pages && + other.refusals == refusals; + + @override + int get hashCode => groupIds.hashCode + pages.hashCode + refusals.hashCode; + + factory Pull.fromJson(Map json) => _$PullFromJson(json); + + Map toJson() => _$PullToJson(this); + + @override + String toString() { + return toJson().toString(); + } +} diff --git a/packages/opensplit_api/lib/src/model/pull.g.dart b/packages/opensplit_api/lib/src/model/pull.g.dart new file mode 100644 index 0000000..b0f76a5 --- /dev/null +++ b/packages/opensplit_api/lib/src/model/pull.g.dart @@ -0,0 +1,37 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'pull.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Pull _$PullFromJson(Map json) => + $checkedCreate('Pull', json, ($checkedConvert) { + $checkKeys(json, requiredKeys: const ['groupIds', 'pages', 'refusals']); + final val = Pull( + groupIds: $checkedConvert( + 'groupIds', + (v) => (v as List).map((e) => e as String).toList(), + ), + pages: $checkedConvert( + 'pages', + (v) => (v as List) + .map((e) => ChangePage.fromJson(e as Map)) + .toList(), + ), + refusals: $checkedConvert( + 'refusals', + (v) => (v as List) + .map((e) => GroupRefusal.fromJson(e as Map)) + .toList(), + ), + ); + return val; + }); + +Map _$PullToJson(Pull instance) => { + 'groupIds': instance.groupIds, + 'pages': instance.pages.map((e) => e.toJson()).toList(), + 'refusals': instance.refusals.map((e) => e.toJson()).toList(), +}; diff --git a/packages/opensplit_api/lib/src/model/pull_request.dart b/packages/opensplit_api/lib/src/model/pull_request.dart new file mode 100644 index 0000000..3726363 --- /dev/null +++ b/packages/opensplit_api/lib/src/model/pull_request.dart @@ -0,0 +1,46 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:opensplit_api/src/model/group_cursor.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'pull_request.g.dart'; + +@JsonSerializable( + checked: true, + createToJson: true, + disallowUnrecognizedKeys: false, + explicitToJson: true, +) +class PullRequest { + /// Returns a new [PullRequest] instance. + PullRequest({required this.groups, required this.limit}); + + @JsonKey(name: r'groups', required: true, includeIfNull: false) + final List groups; + + // minimum: 1 + // maximum: 500 + @JsonKey(name: r'limit', required: true, includeIfNull: false) + final int limit; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PullRequest && other.groups == groups && other.limit == limit; + + @override + int get hashCode => groups.hashCode + limit.hashCode; + + factory PullRequest.fromJson(Map json) => + _$PullRequestFromJson(json); + + Map toJson() => _$PullRequestToJson(this); + + @override + String toString() { + return toJson().toString(); + } +} diff --git a/packages/opensplit_api/lib/src/model/pull_request.g.dart b/packages/opensplit_api/lib/src/model/pull_request.g.dart new file mode 100644 index 0000000..597d7d7 --- /dev/null +++ b/packages/opensplit_api/lib/src/model/pull_request.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'pull_request.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PullRequest _$PullRequestFromJson(Map json) => + $checkedCreate('PullRequest', json, ($checkedConvert) { + $checkKeys(json, requiredKeys: const ['groups', 'limit']); + final val = PullRequest( + groups: $checkedConvert( + 'groups', + (v) => (v as List) + .map((e) => GroupCursor.fromJson(e as Map)) + .toList(), + ), + limit: $checkedConvert('limit', (v) => (v as num).toInt()), + ); + return val; + }); + +Map _$PullRequestToJson(PullRequest instance) => + { + 'groups': instance.groups.map((e) => e.toJson()).toList(), + 'limit': instance.limit, + }; diff --git a/server/migrations/20261003013707_profile_versions/migration.sql b/server/migrations/20261003013707_profile_versions/migration.sql new file mode 100644 index 0000000..b4c75d9 --- /dev/null +++ b/server/migrations/20261003013707_profile_versions/migration.sql @@ -0,0 +1,12 @@ +CREATE TABLE `counters` ( + `name` text PRIMARY KEY, + `value` integer NOT NULL +); +--> statement-breakpoint +-- Existing rows are numbered in the order the old timestamp cursor read them; every write after +-- this sets `version` itself, so the default is never relied on. +ALTER TABLE `profiles` ADD `version` integer NOT NULL DEFAULT 0;--> statement-breakpoint +UPDATE `profiles` SET `version` = (SELECT count(*) FROM `profiles` AS `earlier` WHERE (`earlier`.`updated_at`, `earlier`.`id`) <= (`profiles`.`updated_at`, `profiles`.`id`));--> statement-breakpoint +INSERT INTO `counters` (`name`, `value`) SELECT 'profiles', coalesce(max(`version`), 0) FROM `profiles`;--> statement-breakpoint +DROP INDEX IF EXISTS `profiles_updated`;--> statement-breakpoint +CREATE UNIQUE INDEX `profiles_version` ON `profiles` (`version`); diff --git a/server/migrations/20261003013707_profile_versions/snapshot.json b/server/migrations/20261003013707_profile_versions/snapshot.json new file mode 100644 index 0000000..ce070ab --- /dev/null +++ b/server/migrations/20261003013707_profile_versions/snapshot.json @@ -0,0 +1,936 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "e5a00bfa-2376-4aa5-a7e9-6bbca7d850e4", + "prevIds": [ + "6ac080d4-f4b5-4906-880a-e81a1df1e0e1" + ], + "ddl": [ + { + "name": "counters", + "entityType": "tables" + }, + { + "name": "device_tokens", + "entityType": "tables" + }, + { + "name": "link_tokens", + "entityType": "tables" + }, + { + "name": "memberships", + "entityType": "tables" + }, + { + "name": "profiles", + "entityType": "tables" + }, + { + "name": "purged_groups", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "verification", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "counters" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "counters" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "device_tokens" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "profile_id", + "entityType": "columns", + "table": "device_tokens" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "platform", + "entityType": "columns", + "table": "device_tokens" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "device_tokens" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "link_tokens" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "group_id", + "entityType": "columns", + "table": "link_tokens" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "link_tokens" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "link_tokens" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "profile_id", + "entityType": "columns", + "table": "memberships" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "group_id", + "entityType": "columns", + "table": "memberships" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "left_at", + "entityType": "columns", + "table": "memberships" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "memberships" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "memberships" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "profiles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "display_name", + "entityType": "columns", + "table": "profiles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "upi_vpa", + "entityType": "columns", + "table": "profiles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'initials'", + "generated": null, + "name": "avatar_kind", + "entityType": "columns", + "table": "profiles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "avatar_color", + "entityType": "columns", + "table": "profiles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "avatar_emoji", + "entityType": "columns", + "table": "profiles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "avatar_icon", + "entityType": "columns", + "table": "profiles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "avatar_photo", + "entityType": "columns", + "table": "profiles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "profiles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "deleted_at", + "entityType": "columns", + "table": "profiles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "profiles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "group_id", + "entityType": "columns", + "table": "purged_groups" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "purged_at", + "entityType": "columns", + "table": "purged_groups" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token_expires_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token_expires_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scope", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ip_address", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "email_verified", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "image", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_anonymous", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "identifier", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "verification" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_account_user_id_user_id_fk", + "entityType": "fks", + "table": "account" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_user_id_user_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "profile_id", + "group_id" + ], + "nameExplicit": false, + "name": "memberships_pk", + "entityType": "pks", + "table": "memberships" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "counters_pk", + "table": "counters", + "entityType": "pks" + }, + { + "columns": [ + "token" + ], + "nameExplicit": false, + "name": "device_tokens_pk", + "table": "device_tokens", + "entityType": "pks" + }, + { + "columns": [ + "token" + ], + "nameExplicit": false, + "name": "link_tokens_pk", + "table": "link_tokens", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "profiles_pk", + "table": "profiles", + "entityType": "pks" + }, + { + "columns": [ + "group_id" + ], + "nameExplicit": false, + "name": "purged_groups_pk", + "table": "purged_groups", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "user_pk", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "verification_pk", + "table": "verification", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "profile_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "device_tokens_profile", + "entityType": "indexes", + "table": "device_tokens" + }, + { + "columns": [ + { + "value": "group_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "link_tokens_group", + "entityType": "indexes", + "table": "link_tokens" + }, + { + "columns": [ + { + "value": "group_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "memberships_group", + "entityType": "indexes", + "table": "memberships" + }, + { + "columns": [ + { + "value": "version", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "profiles_version", + "entityType": "indexes", + "table": "profiles" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "account_userId_idx", + "entityType": "indexes", + "table": "account" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_userId_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "identifier", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "verification_identifier_idx", + "entityType": "indexes", + "table": "verification" + }, + { + "columns": [ + "token" + ], + "nameExplicit": false, + "name": "session_token_unique", + "entityType": "uniques", + "table": "session" + }, + { + "columns": [ + "email" + ], + "nameExplicit": false, + "name": "user_email_unique", + "entityType": "uniques", + "table": "user" + }, + { + "value": "(\"avatar_kind\" = 'emoji') = (\"avatar_emoji\" is not null)", + "name": "profiles_avatar_emoji", + "entityType": "checks", + "table": "profiles" + }, + { + "value": "(\"avatar_kind\" = 'icon') = (\"avatar_icon\" is not null)", + "name": "profiles_avatar_icon", + "entityType": "checks", + "table": "profiles" + }, + { + "value": "(\"avatar_kind\" = 'photo') = (\"avatar_photo\" is not null)", + "name": "profiles_avatar_photo", + "entityType": "checks", + "table": "profiles" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/server/src/api/account.ts b/server/src/api/account.ts index 664faba..681c677 100644 --- a/server/src/api/account.ts +++ b/server/src/api/account.ts @@ -1,14 +1,15 @@ import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi"; -import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, eq, gt, inArray, isNull, or } from "drizzle-orm"; import type { DrizzleD1Database } from "drizzle-orm/d1"; import { user } from "../auth-schema"; import { type AppEnv, mayChangeIdentity } from "../context"; +import { nextProfileVersion, profileVersion } from "../db/d1/profile-version"; import { deviceTokens, memberships, profiles } from "../db/d1/schema"; import { forgetAccount } from "../forget"; import { AccountDeletionSchema, DeviceForgottenSchema, DeviceSchema, ProfilePageSchema, ProfileSchema, ProfileUpdateSchema } from "../schemas/account"; import { apiError, errorResponse, jsonBody, jsonResponse } from "../schemas/common"; -import { refusals, signedIn } from "./routing"; +import { refusals, SeqQuerySchema, signedIn } from "./routing"; /** The person, their devices, and ending the account. */ @@ -21,13 +22,10 @@ const profileFeedRoute = createRoute({ path: "/profiles", tags: ["sync"], summary: "Everybody you share a group with, plus yourself", - description: "Cursored on time, since profiles have no single writer to number them. Send `cursor` back as `after`.", + description: "Cursored on each profile's `version`, which D1 numbers in commit order. Send `seq` back as `since` next time.", request: { query: z.object({ - after: z - .string() - .optional() - .openapi({ param: { name: "after", in: "query" } }), + since: SeqQuerySchema.default(0), limit: z.coerce.number().int().min(1).max(PAGE_MAX).default(100).openapi({ type: "integer", example: 100 }), }), }, @@ -87,30 +85,30 @@ const deleteAccountRoute = createRoute({ export function accountRoutes(routes: OpenAPIHono) { routes.openapi(profileFeedRoute, async (c) => { - const { after, limit } = c.req.valid("query"); - const position = after === undefined ? null : decodeCursor(after); - if (position === undefined) return c.json(apiError("malformed", "That is not a cursor from this feed."), 400); - + const { since, limit } = c.req.valid("query"); const rows = await c.var.db .select() .from(profiles) - .where(and(visibleTo(c.var.db, c.var.session.userId), position ? sql`(${profiles.updatedAt}, ${profiles.id}) > (${position.updatedAt}, ${position.id})` : undefined)) - .orderBy(profiles.updatedAt, profiles.id) + .where(and(visibleTo(c.var.db, c.var.session.userId), gt(profiles.version, since))) + .orderBy(profiles.version) .limit(limit + 1) .all(); const page = rows.slice(0, limit); - const last = page.at(-1); - return c.json({ profiles: page, cursor: last ? encodeCursor(last) : null, hasMore: rows.length > limit }, 200); + return c.json({ profiles: page, seq: page.at(-1)?.version ?? since, hasMore: rows.length > limit }, 200); }); routes.openapi(updateProfileRoute, async (c) => { const update = c.req.valid("json"); - const [row] = await c.var.db - .update(profiles) - .set({ ...update, updatedAt: new Date().toISOString() }) - .where(eq(profiles.id, c.var.session.userId)) - .returning(); + const db = c.var.db; + const [, [row]] = await db.batch([ + nextProfileVersion(db), + db + .update(profiles) + .set({ ...update, updatedAt: new Date().toISOString(), version: profileVersion }) + .where(eq(profiles.id, c.var.session.userId)) + .returning(), + ]); // Created by a databaseHook with the account, so a missing row is a bug, not a case. if (!row) throw new Error(`No profile for account ${c.var.session.userId}`); return c.json(row, 200); @@ -163,14 +161,3 @@ function visibleTo(db: DrizzleD1Database, self: string) { .where(and(inArray(memberships.groupId, mine), isNull(memberships.leftAt))); return or(eq(profiles.id, self), inArray(profiles.id, together)); } - -type Position = { updatedAt: string; id: string }; - -const encodeCursor = ({ updatedAt, id }: Position) => `${updatedAt}|${id}`; - -/** A position, or undefined for something this feed did not issue. */ -function decodeCursor(cursor: string): Position | undefined { - const [updatedAt, id, ...rest] = cursor.split("|"); - if (!updatedAt || !id || rest.length > 0 || Number.isNaN(Date.parse(updatedAt))) return undefined; - return { updatedAt, id }; -} diff --git a/server/src/api/invites.ts b/server/src/api/invites.ts index e31f7ec..b4546d3 100644 --- a/server/src/api/invites.ts +++ b/server/src/api/invites.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import type { DrizzleD1Database } from "drizzle-orm/d1"; import type { AppEnv } from "../context"; +import { nextProfileVersion, profileVersion } from "../db/d1/profile-version"; import { linkTokens, profiles } from "../db/d1/schema"; import { apiError, jsonBody, jsonResponse } from "../schemas/common"; import { GroupLinkSchema, InviteSchema, JoinedSchema, JoinRequestSchema, LinkPreviewSchema, LinkRevocationSchema, LiveLinkSchema, PlaceholderListSchema } from "../schemas/ledger"; @@ -154,7 +155,8 @@ export function inviteRoutes(routes: OpenAPIHono) { const result = await group(c, groupId).join(token, viewer, { ...request, displayName: request.displayName ?? profile?.displayName ?? null }); if (result.ok && !profile?.displayName) { - await c.var.db.update(profiles).set({ displayName: result.value.member.displayName, updatedAt: new Date().toISOString() }).where(eq(profiles.id, viewer)); + const db = c.var.db; + await db.batch([nextProfileVersion(db), db.update(profiles).set({ displayName: result.value.member.displayName, updatedAt: new Date().toISOString(), version: profileVersion }).where(eq(profiles.id, viewer))]); } return respond(c, result); }); diff --git a/server/src/api/ledger.ts b/server/src/api/ledger.ts index d05e974..2f83ab3 100644 --- a/server/src/api/ledger.ts +++ b/server/src/api/ledger.ts @@ -1,14 +1,14 @@ -import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi"; +import { createRoute, type OpenAPIHono } from "@hono/zod-openapi"; import { and, eq, inArray, isNull } from "drizzle-orm"; import type { DrizzleD1Database } from "drizzle-orm/d1"; import { chunked } from "../chunked"; import type { AppEnv } from "../context"; import { memberships, profiles } from "../db/d1/schema"; -import { GroupIdsSchema } from "../schemas/account"; +import type { Profile } from "../schemas/account"; import { jsonBody, jsonResponse } from "../schemas/common"; -import { ChangePageSchema, EntryInputSchema, EntrySchema, GroupInputSchema, GroupSchema, type Member, MemberInputSchema, MemberSchema } from "../schemas/ledger"; -import { EntryPathSchema, GroupPathSchema, group, MemberPathSchema, refusals, refused, respond, SeqQuerySchema, signedIn } from "./routing"; +import { EntryInputSchema, EntrySchema, type GroupChanges, GroupInputSchema, type GroupRefusal, GroupSchema, type Member, MemberInputSchema, MemberSchema, PullRequestSchema, PullSchema } from "../schemas/ledger"; +import { EntryPathSchema, GroupPathSchema, group, MemberPathSchema, refusals, respond, signedIn } from "./routing"; /** * The sync surface. Handlers only route: rules about shape are the Zod @@ -17,32 +17,17 @@ import { EntryPathSchema, GroupPathSchema, group, MemberPathSchema, refusals, re * answers `not_member` itself. */ -const listGroupsRoute = createRoute({ +const pullRoute = createRoute({ ...signedIn, - method: "get", - operationId: "listGroups", - path: "/groups", + method: "post", + operationId: "pull", + path: "/sync", tags: ["sync"], - summary: "The groups this account is still in", - responses: { 200: jsonResponse(GroupIdsSchema, "The group ids"), 401: refusals[401] }, -}); - -const changesRoute = createRoute({ - ...signedIn, - method: "get", - operationId: "getChanges", - path: "/groups/{groupId}/changes", - tags: ["sync"], - summary: "Everything that changed in one group since a cursor", - description: "`limit` counts changes, not rows, and a page is cut only between sequence numbers, so a device sees a whole write or none of it. Send `seq` back as `since` next time.", - request: { - params: GroupPathSchema, - query: z.object({ - since: SeqQuerySchema.default(0), - limit: z.coerce.number().int().min(1).max(500).default(200).openapi({ type: "integer", example: 200 }), - }), - }, - responses: { 200: jsonResponse(ChangePageSchema, "The page, and the cursor to send next time"), ...refusals }, + summary: "Every group this account is in, and what changed in the ones asked for", + description: + "Each group asked for gets its own page past its own cursor, read in parallel; a group that cannot be read is listed in `refusals` and the rest are still answered. `groupIds` names every group the account is in now, so one with no cursor yet is asked for next time, from 0. A page is cut only between sequence numbers, so a device sees a whole write or none of it; send each page's `seq` back as that group's `since`.", + request: { body: jsonBody(PullRequestSchema) }, + responses: { 200: jsonResponse(PullSchema, "The account's groups, and a page or a refusal for each group asked for"), 400: refusals[400], 401: refusals[401] }, }); const putGroupRoute = createRoute({ @@ -76,27 +61,36 @@ const putEntryRoute = createRoute({ path: "/groups/{groupId}/entries/{entryId}", tags: ["entries"], summary: "Record, edit, delete or restore an expense, whole", - description: "A stale `baseSeq` is refused only when the write would move money, so two people fixing a typo never arbitrate. Deleting and restoring always move money.", + description: "`baseSeq` must be the version stored now (null for a new expense), or the write is refused as `stale_base`: the expense is replaced whole, so a write composed on an older version would undo the edit in between. Sending the stored contents again is answered with the stored row, whatever the base.", request: { params: EntryPathSchema, body: jsonBody(EntryInputSchema) }, responses: { 200: jsonResponse(EntrySchema, "The expense as stored"), ...refusals }, }); export function ledgerRoutes(routes: OpenAPIHono) { - routes.openapi(listGroupsRoute, async (c) => { - const rows = await c.var.db - .select({ groupId: memberships.groupId }) - .from(memberships) - .where(and(eq(memberships.profileId, c.var.session.userId), isNull(memberships.leftAt))) - .all(); - return c.json({ groupIds: rows.map((row) => row.groupId) }, 200); - }); + routes.openapi(pullRoute, async (c) => { + const { groups, limit } = c.req.valid("json"); + const viewer = c.var.session.userId; + const [groupIds, results] = await Promise.all([groupsOf(c.var.db, viewer), Promise.all(groups.map(({ groupId, since }) => group(c, groupId).changes(viewer, since, limit)))]); - routes.openapi(changesRoute, async (c) => { - const { groupId } = c.req.valid("param"); - const { since, limit } = c.req.valid("query"); - const result = await group(c, groupId).changes(c.var.session.userId, since, limit); - if (!result.ok) return refused(c, result.error); - return c.json({ ...result.value, profiles: await profilesOf(c.var.db, result.value.members) }, 200); + const pages: GroupChanges[] = []; + const refused: GroupRefusal[] = []; + results.forEach((result, index) => { + if (result.ok) pages.push(result.value); + else refused.push({ groupId: groups[index]?.groupId ?? "", ...result.error }); + }); + + const profilesById = await profilesOf( + c.var.db, + pages.flatMap((page) => page.members), + ); + return c.json( + { + groupIds, + pages: pages.map((page) => ({ ...page, profiles: currentProfiles(page.members).flatMap((id) => profilesById.get(id) ?? []) })), + refusals: refused, + }, + 200, + ); }); routes.openapi(putGroupRoute, async (c) => { @@ -115,9 +109,23 @@ export function ledgerRoutes(routes: OpenAPIHono) { }); } -/** The accounts holding the page's current places: ids the group named, never ones a client asked for. */ -async function profilesOf(db: DrizzleD1Database, members: Member[]) { - const ids = [...new Set(members.flatMap((member) => (member.profileId !== null && member.leftAt === null ? [member.profileId] : [])))]; - const pages = await Promise.all(chunked(ids).map((chunk) => db.select().from(profiles).where(inArray(profiles.id, chunk)).all())); - return pages.flat(); +/** The groups an account is in now, from D1's index of them. */ +async function groupsOf(db: DrizzleD1Database, profileId: string): Promise { + const rows = await db + .select({ groupId: memberships.groupId }) + .from(memberships) + .where(and(eq(memberships.profileId, profileId), isNull(memberships.leftAt))) + .all(); + return rows.map((row) => row.groupId); +} + +/** The accounts holding a page's current places: ids the group named, never ones a client asked for. */ +function currentProfiles(members: Member[]): string[] { + return [...new Set(members.flatMap((member) => (member.profileId !== null && member.leftAt === null ? [member.profileId] : [])))]; +} + +/** Every profile any page names, read once for the whole pull. */ +async function profilesOf(db: DrizzleD1Database, members: Member[]): Promise> { + const pages = await Promise.all(chunked(currentProfiles(members)).map((chunk) => db.select().from(profiles).where(inArray(profiles.id, chunk)).all())); + return new Map(pages.flat().map((profile) => [profile.id, profile])); } diff --git a/server/src/auth.ts b/server/src/auth.ts index 4b94cd8..d9c3716 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -6,6 +6,7 @@ import { emailOTP } from "better-auth/plugins/email-otp"; import { drizzle } from "drizzle-orm/d1"; import * as authSchema from "./auth-schema"; +import { nextProfileVersion, profileVersion } from "./db/d1/profile-version"; import { profiles } from "./db/d1/schema"; import { createEmailSender } from "./email/sender"; import { signInCodeMessage } from "./email/sign-in-code"; @@ -102,7 +103,8 @@ export function build(env: Env) { */ async function createProfile(env: Env, user: { id: string; name?: string | null; isAnonymous?: boolean | null }): Promise { const chosen = user.isAnonymous ? null : user.name?.trim() || null; - await drizzle(env.DB).insert(profiles).values({ id: user.id, displayName: chosen, upiVpa: null, updatedAt: new Date().toISOString() }).onConflictDoNothing(); + const db = drizzle(env.DB); + await db.batch([nextProfileVersion(db), db.insert(profiles).values({ id: user.id, displayName: chosen, upiVpa: null, updatedAt: new Date().toISOString(), version: profileVersion }).onConflictDoNothing()]); } export type Auth = ReturnType; diff --git a/server/src/db/d1/profile-version.ts b/server/src/db/d1/profile-version.ts new file mode 100644 index 0000000..5fe9cae --- /dev/null +++ b/server/src/db/d1/profile-version.ts @@ -0,0 +1,22 @@ +import { eq, sql } from "drizzle-orm"; +import type { DrizzleD1Database } from "drizzle-orm/d1"; + +import { counters } from "./schema"; + +/** + * Every write to `profiles` goes in one `db.batch` (a transaction) behind + * `nextProfileVersion`, and sets `version: profileVersion`: + * + * ```ts + * await db.batch([nextProfileVersion(db), db.update(profiles).set({ ...changes, version: profileVersion })]); + * ``` + */ +export function nextProfileVersion(db: DrizzleD1Database) { + return db + .insert(counters) + .values({ name: "profiles", value: 1 }) + .onConflictDoUpdate({ target: counters.name, set: { value: sql`${counters.value} + 1` } }); +} + +/** The version `nextProfileVersion` just took, read inside the same batch. */ +export const profileVersion = sql`(select ${counters.value} from ${counters} where ${eq(counters.name, "profiles")})`; diff --git a/server/src/db/d1/schema.ts b/server/src/db/d1/schema.ts index d8d0a75..8380282 100644 --- a/server/src/db/d1/schema.ts +++ b/server/src/db/d1/schema.ts @@ -1,4 +1,4 @@ -import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { index, integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; import { avatarChecks, avatarColumns } from "../appearance"; @@ -25,10 +25,27 @@ export const profiles = sqliteTable( updatedAt: text("updated_at").notNull(), /** Set by account deletion; the row stays so history still resolves. */ deletedAt: text("deleted_at"), + /** + * The profile feed's cursor: the `profiles` counter's value when this row + * was last written, taken in the same transaction (`profileVersion`). + * D1 commits one write at a time, so versions follow commit order, which + * a timestamp from the Worker's clock does not. + */ + version: integer("version").notNull(), }, - (table) => [index("profiles_updated").on(table.updatedAt, table.id), ...avatarChecks("profiles", table)], + (table) => [uniqueIndex("profiles_version").on(table.version), ...avatarChecks("profiles", table)], ); +/** + * Named counters that only go up, for rows D1 numbers itself. A counter + * rather than `max(version) + 1`, which would hand a deleted row's number out + * again, behind a cursor that has already passed it. + */ +export const counters = sqliteTable("counters", { + name: text("name").primaryKey(), + value: integer("value").notNull(), +}); + /** * Which groups a person is in. Derived from the group objects, which send * every change in order and retry until D1 takes it. diff --git a/server/src/db/group/schema.ts b/server/src/db/group/schema.ts index 6b822db..8a5757e 100644 --- a/server/src/db/group/schema.ts +++ b/server/src/db/group/schema.ts @@ -72,6 +72,7 @@ export const meta = sqliteTable( .notNull() .references(() => members.id), createdAt: text("created_at").notNull(), + lastActivityAt: integer("last_activity_at").notNull().default(0), archivedAt: text("archived_at"), updatedAt: text("updated_at").notNull(), seq: integer("seq").notNull(), diff --git a/server/src/do/group/index.ts b/server/src/do/group/index.ts index a1908ac..73de5ed 100644 --- a/server/src/do/group/index.ts +++ b/server/src/do/group/index.ts @@ -16,7 +16,7 @@ import { pendingNotices } from "./notices"; import { attempt, type Result } from "./refusal"; import { createGroup, forgetProfile, handOver, putMember, updateGroup } from "./roster"; import { currentSeq, findMeta, findTombstone, type GroupDb, nowIso, requireActiveMember, requireMeta, type Tx, type WriteContext } from "./store"; -import { backoffOutbox, clearOutbox, nextDue, type OutboxRow, pendingOutbox, runDormancy, type UpkeepOutcome } from "./upkeep"; +import { backoffOutbox, clearOutbox, nextDue, type OutboxRow, pendingOutbox, runDormancy, touchDormancy, type UpkeepOutcome } from "./upkeep"; /** * One group's ledger and its authorization boundary. The object runs one @@ -131,7 +131,8 @@ export class Group extends DurableObject { /** * One change: run it, flush what it owes D1, and wake the other members if it * committed something. A write that changed nothing spends no sequence - * number and so notifies nobody, which is what keeps retries quiet. + * number, so it notifies nobody and does not count as use of the group, + * which is what keeps retries quiet. */ private async write(profileId: string, body: (tx: Tx, now: string) => T): Promise> { const now = nowIso(); @@ -140,6 +141,7 @@ export class Group extends DurableObject { const before = currentSeq(tx); const value = body(tx, now); const after = currentSeq(tx); + if (after > before) touchDormancy(tx, now); return { value, spent: after > before ? after : null }; }), ); diff --git a/server/src/do/group/ledger.ts b/server/src/do/group/ledger.ts index b764414..ee3517c 100644 --- a/server/src/do/group/ledger.ts +++ b/server/src/do/group/ledger.ts @@ -7,7 +7,6 @@ import { positionsByMember } from "./balances"; import { appendSnapshot, byMember, moneyRows } from "./events"; import { refuse } from "./refusal"; import { type EntryRow, nextSeq, requireMeta, type Tx, type WriteContext } from "./store"; -import { touchDormancy } from "./upkeep"; /** * The expense write path. Every rule is checked here against the finished @@ -55,11 +54,11 @@ export function putEntry(tx: Tx, id: string, input: EntryInput, { now, actor }: assertBalanced(input); assertMembersExist(tx, input); - // The id is client-minted, so a retry after a lost response finds its own row here. + // The id is client-minted, so a retry after a lost response finds its own row here, unchanged. const stored = readEntry(tx, id); if (stored) { - assertBaseIsCurrent(stored, input); if (JSON.stringify(comparable(stored)) === JSON.stringify(comparable(input))) return stored; + assertBaseIsCurrent(stored, input); } assertDepartedOnlySettle(tx, stored, input); @@ -97,7 +96,6 @@ export function putEntry(tx: Tx, id: string, input: EntryInput, { now, actor }: const entry = requireEntry(tx, id); appendSnapshot(tx, entry, entry.payers, entry.shares, { seq, now, actorId: actor.id }); - touchDormancy(tx, now); return entry; } @@ -169,14 +167,12 @@ function assertDepartedOnlySettle(tx: Tx, stored: Entry | undefined, input: Entr } /** - * A stale base is refused only when the write would move money; two people - * fixing a typo never arbitrate. A null base claims no version. + * An edit replaces the whole expense, so it must be composed on the version + * stored now; anything else would silently undo the edit in between. A null + * base claims the row is new, which a stored row contradicts. */ function assertBaseIsCurrent(stored: Entry, input: EntryInput): void { - if (input.baseSeq === null || stored.seq === input.baseSeq) return; - if (JSON.stringify(money(stored)) !== JSON.stringify(money(input))) { - refuse("stale_base", "This expense changed since you opened it."); - } + if (stored.seq !== input.baseSeq) refuse("stale_base", "This expense changed since you opened it."); } type Comparable = Omit; diff --git a/server/src/do/group/migrations/20261004204449_wooden_phil_sheldon/migration.sql b/server/src/do/group/migrations/20261004204449_wooden_phil_sheldon/migration.sql new file mode 100644 index 0000000..0399c72 --- /dev/null +++ b/server/src/do/group/migrations/20261004204449_wooden_phil_sheldon/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `meta` ADD `last_activity_at` integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/server/src/do/group/migrations/20261004204449_wooden_phil_sheldon/snapshot.json b/server/src/do/group/migrations/20261004204449_wooden_phil_sheldon/snapshot.json new file mode 100644 index 0000000..695f354 --- /dev/null +++ b/server/src/do/group/migrations/20261004204449_wooden_phil_sheldon/snapshot.json @@ -0,0 +1,1379 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "51a0ca86-32d6-451c-9c96-a734e7831190", + "prevIds": [ + "f612090c-d005-4921-9693-cb1974b0d8db" + ], + "ddl": [ + { + "name": "counter", + "entityType": "tables" + }, + { + "name": "entries", + "entityType": "tables" + }, + { + "name": "entry_payers", + "entityType": "tables" + }, + { + "name": "entry_shares", + "entityType": "tables" + }, + { + "name": "events", + "entityType": "tables" + }, + { + "name": "group_link", + "entityType": "tables" + }, + { + "name": "invites", + "entityType": "tables" + }, + { + "name": "members", + "entityType": "tables" + }, + { + "name": "meta", + "entityType": "tables" + }, + { + "name": "outbox", + "entityType": "tables" + }, + { + "name": "schedule", + "entityType": "tables" + }, + { + "name": "tombstone", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "counter" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "counter" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "entries" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'expense'", + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "entries" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "description", + "entityType": "columns", + "table": "entries" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "category_id", + "entityType": "columns", + "table": "entries" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "currency", + "entityType": "columns", + "table": "entries" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "amount_minor", + "entityType": "columns", + "table": "entries" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "entry_date", + "entityType": "columns", + "table": "entries" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "occurred_at", + "entityType": "columns", + "table": "entries" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_zone", + "entityType": "columns", + "table": "entries" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'equal'", + "generated": null, + "name": "split_kind", + "entityType": "columns", + "table": "entries" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "fx_rate", + "entityType": "columns", + "table": "entries" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "fx_source", + "entityType": "columns", + "table": "entries" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "fx_at", + "entityType": "columns", + "table": "entries" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "notes", + "entityType": "columns", + "table": "entries" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_by", + "entityType": "columns", + "table": "entries" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "entries" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "entries" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "deleted_at", + "entityType": "columns", + "table": "entries" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "entries" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "entry_id", + "entityType": "columns", + "table": "entry_payers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "member_id", + "entityType": "columns", + "table": "entry_payers" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "amount_minor", + "entityType": "columns", + "table": "entry_payers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "entry_id", + "entityType": "columns", + "table": "entry_shares" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "member_id", + "entityType": "columns", + "table": "entry_shares" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "amount_minor", + "entityType": "columns", + "table": "entry_shares" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "weight_micros", + "entityType": "columns", + "table": "entry_shares" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "events" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_id", + "entityType": "columns", + "table": "events" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "events" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "events" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "subject_id", + "entityType": "columns", + "table": "events" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "entry", + "entityType": "columns", + "table": "events" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "member", + "entityType": "columns", + "table": "events" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "group", + "entityType": "columns", + "table": "events" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link", + "entityType": "columns", + "table": "events" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "events" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "ordinal", + "entityType": "columns", + "table": "events" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "group_link" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "group_link" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_by", + "entityType": "columns", + "table": "group_link" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "group_link" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "group_link" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revoked_at", + "entityType": "columns", + "table": "group_link" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "invites" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "member_id", + "entityType": "columns", + "table": "invites" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_by", + "entityType": "columns", + "table": "invites" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "invites" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "invites" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "redeemed_at", + "entityType": "columns", + "table": "invites" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "redeemed_by", + "entityType": "columns", + "table": "invites" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "members" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "profile_id", + "entityType": "columns", + "table": "members" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "display_name", + "entityType": "columns", + "table": "members" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "upi_vpa", + "entityType": "columns", + "table": "members" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "joined_at", + "entityType": "columns", + "table": "members" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "left_at", + "entityType": "columns", + "table": "members" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "members" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "members" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "meta" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "meta" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "default_currency", + "entityType": "columns", + "table": "meta" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_direct", + "entityType": "columns", + "table": "meta" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "simplify_debts", + "entityType": "columns", + "table": "meta" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'initials'", + "generated": null, + "name": "avatar_kind", + "entityType": "columns", + "table": "meta" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "avatar_color", + "entityType": "columns", + "table": "meta" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "avatar_emoji", + "entityType": "columns", + "table": "meta" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "avatar_icon", + "entityType": "columns", + "table": "meta" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "avatar_photo", + "entityType": "columns", + "table": "meta" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'generated'", + "generated": null, + "name": "cover_kind", + "entityType": "columns", + "table": "meta" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "cover_photo", + "entityType": "columns", + "table": "meta" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_by", + "entityType": "columns", + "table": "meta" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "meta" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "last_activity_at", + "entityType": "columns", + "table": "meta" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "archived_at", + "entityType": "columns", + "table": "meta" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "meta" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "meta" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "outbox" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "outbox" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "payload", + "entityType": "columns", + "table": "outbox" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "attempts", + "entityType": "columns", + "table": "outbox" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "outbox" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "schedule" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "due_at", + "entityType": "columns", + "table": "schedule" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "tombstone" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "group_id", + "entityType": "columns", + "table": "tombstone" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "purged_at", + "entityType": "columns", + "table": "tombstone" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "tombstone" + }, + { + "columns": [ + "created_by" + ], + "tableTo": "members", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_entries_created_by_members_id_fk", + "entityType": "fks", + "table": "entries" + }, + { + "columns": [ + "entry_id" + ], + "tableTo": "entries", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_entry_payers_entry_id_entries_id_fk", + "entityType": "fks", + "table": "entry_payers" + }, + { + "columns": [ + "member_id" + ], + "tableTo": "members", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_entry_payers_member_id_members_id_fk", + "entityType": "fks", + "table": "entry_payers" + }, + { + "columns": [ + "entry_id" + ], + "tableTo": "entries", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_entry_shares_entry_id_entries_id_fk", + "entityType": "fks", + "table": "entry_shares" + }, + { + "columns": [ + "member_id" + ], + "tableTo": "members", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_entry_shares_member_id_members_id_fk", + "entityType": "fks", + "table": "entry_shares" + }, + { + "columns": [ + "actor_id" + ], + "tableTo": "members", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_events_actor_id_members_id_fk", + "entityType": "fks", + "table": "events" + }, + { + "columns": [ + "created_by" + ], + "tableTo": "members", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_group_link_created_by_members_id_fk", + "entityType": "fks", + "table": "group_link" + }, + { + "columns": [ + "member_id" + ], + "tableTo": "members", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_invites_member_id_members_id_fk", + "entityType": "fks", + "table": "invites" + }, + { + "columns": [ + "created_by" + ], + "tableTo": "members", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_invites_created_by_members_id_fk", + "entityType": "fks", + "table": "invites" + }, + { + "columns": [ + "created_by" + ], + "tableTo": "members", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_meta_created_by_members_id_fk", + "entityType": "fks", + "table": "meta" + }, + { + "columns": [ + "entry_id", + "member_id" + ], + "nameExplicit": false, + "name": "entry_payers_pk", + "entityType": "pks", + "table": "entry_payers" + }, + { + "columns": [ + "entry_id", + "member_id" + ], + "nameExplicit": false, + "name": "entry_shares_pk", + "entityType": "pks", + "table": "entry_shares" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "counter_pk", + "table": "counter", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "entries_pk", + "table": "entries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "events_pk", + "table": "events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "group_link_pk", + "table": "group_link", + "entityType": "pks" + }, + { + "columns": [ + "token" + ], + "nameExplicit": false, + "name": "invites_pk", + "table": "invites", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "members_pk", + "table": "members", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "meta_pk", + "table": "meta", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "outbox_pk", + "table": "outbox", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "schedule_pk", + "table": "schedule", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tombstone_pk", + "table": "tombstone", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "entries_seq", + "entityType": "indexes", + "table": "entries" + }, + { + "columns": [ + { + "value": "seq", + "isExpression": false + }, + { + "value": "ordinal", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "events_position", + "entityType": "indexes", + "table": "events" + }, + { + "columns": [ + { + "value": "subject_id", + "isExpression": false + }, + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "events_subject", + "entityType": "indexes", + "table": "events" + }, + { + "columns": [ + { + "value": "member_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "invites_member", + "entityType": "indexes", + "table": "invites" + }, + { + "columns": [ + { + "value": "profile_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "members_profile", + "entityType": "indexes", + "table": "members" + }, + { + "columns": [ + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "members_seq", + "entityType": "indexes", + "table": "members" + }, + { + "value": "\"amount_minor\" > 0", + "name": "entries_amount_positive", + "entityType": "checks", + "table": "entries" + }, + { + "value": "\"fx_rate\" is null or \"fx_rate\" > 0", + "name": "entries_fx_rate_positive", + "entityType": "checks", + "table": "entries" + }, + { + "value": "(\"occurred_at\" is null) = (\"time_zone\" is null)", + "name": "entries_moment_complete", + "entityType": "checks", + "table": "entries" + }, + { + "value": "(\"fx_rate\" is null) = (\"fx_source\" is null) and (\"fx_rate\" is null) = (\"fx_at\" is null)", + "name": "entries_fx_complete", + "entityType": "checks", + "table": "entries" + }, + { + "value": "\"amount_minor\" > 0", + "name": "entry_payers_amount_positive", + "entityType": "checks", + "table": "entry_payers" + }, + { + "value": "\"amount_minor\" >= 0", + "name": "entry_shares_amount_not_negative", + "entityType": "checks", + "table": "entry_shares" + }, + { + "value": "(\"entry\" is not null) + (\"member\" is not null) + (\"group\" is not null) + (\"link\" is not null) = 1", + "name": "events_one_payload", + "entityType": "checks", + "table": "events" + }, + { + "value": "length(trim(\"display_name\")) > 0", + "name": "members_name_not_blank", + "entityType": "checks", + "table": "members" + }, + { + "value": "length(trim(\"name\")) > 0", + "name": "meta_name_not_blank", + "entityType": "checks", + "table": "meta" + }, + { + "value": "(\"avatar_kind\" = 'emoji') = (\"avatar_emoji\" is not null)", + "name": "meta_avatar_emoji", + "entityType": "checks", + "table": "meta" + }, + { + "value": "(\"avatar_kind\" = 'icon') = (\"avatar_icon\" is not null)", + "name": "meta_avatar_icon", + "entityType": "checks", + "table": "meta" + }, + { + "value": "(\"avatar_kind\" = 'photo') = (\"avatar_photo\" is not null)", + "name": "meta_avatar_photo", + "entityType": "checks", + "table": "meta" + }, + { + "value": "(\"cover_kind\" = 'photo') = (\"cover_photo\" is not null)", + "name": "meta_cover_photo", + "entityType": "checks", + "table": "meta" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/server/src/do/group/migrations/migrations.js b/server/src/do/group/migrations/migrations.js index 8152145..ffe81ec 100644 --- a/server/src/do/group/migrations/migrations.js +++ b/server/src/do/group/migrations/migrations.js @@ -1,8 +1,10 @@ import m0000 from './20261002071250_fair_hemingway/migration.sql'; +import m0001 from './20261004204449_wooden_phil_sheldon/migration.sql'; export default { migrations: { - "20261002071250_fair_hemingway": m0000 + "20261002071250_fair_hemingway": m0000, +"20261004204449_wooden_phil_sheldon": m0001 } } \ No newline at end of file diff --git a/server/src/do/group/roster.ts b/server/src/do/group/roster.ts index 528d217..cd28156 100644 --- a/server/src/do/group/roster.ts +++ b/server/src/do/group/roster.ts @@ -6,7 +6,7 @@ import { isSettled } from "./balances"; import { append } from "./events"; import { refuse } from "./refusal"; import { findMemberByProfile, findMeta, nextSeq, purge, requireMember, requireMeta, type Tx, type WriteContext } from "./store"; -import { stageMembership, stagePurge, touchDormancy } from "./upkeep"; +import { stageMembership, stagePurge } from "./upkeep"; /** * The group and its people. Membership is the only permission: no owner, no @@ -33,7 +33,6 @@ export function createGroup(tx: Tx, groupId: string, input: GroupInput, profileI .run(); stageMembership(tx, profileId, null, now); - touchDormancy(tx, now); return requireMeta(tx); } diff --git a/server/src/do/group/upkeep.ts b/server/src/do/group/upkeep.ts index c5338c4..3327b84 100644 --- a/server/src/do/group/upkeep.ts +++ b/server/src/do/group/upkeep.ts @@ -41,22 +41,14 @@ export function nextDue(tx: Tx): number | null { ); } -/** Called on every ledger write: a group in use is not dormant. */ +/** Called in the transaction of every change a member makes: a group in use is not dormant. */ export function touchDormancy(tx: Tx, now: string): void { - scheduleAt(tx, "archive", Date.parse(now) + DORMANCY.archiveAfter); + const at = Date.parse(now); + tx.update(schema.meta).set({ lastActivityAt: at }).run(); + scheduleAt(tx, "archive", at + DORMANCY.archiveAfter); unschedule(tx, "purge"); } -/** Last genuine use, recomputed from the ledger so an early alarm cannot archive a busy group. */ -function lastActivity(tx: Tx, createdAt: string): number { - const latest = - tx - .select({ at: sql`max(${schema.entries.createdAt})` }) - .from(schema.entries) - .get()?.at ?? createdAt; - return Date.parse(latest); -} - export interface UpkeepOutcome { archived: boolean; purged: boolean; @@ -68,7 +60,7 @@ export function runDormancy(tx: Tx, now: number): UpkeepOutcome { const meta = findMeta(tx); if (!meta) return outcome; - const quietSince = lastActivity(tx, meta.createdAt); + const quietSince = meta.lastActivityAt > 0 ? meta.lastActivityAt : Date.parse(meta.createdAt); if (meta.archivedAt === null) { if (quietSince + DORMANCY.archiveAfter > now) { @@ -81,8 +73,10 @@ export function runDormancy(tx: Tx, now: number): UpkeepOutcome { tx.update(schema.meta).set({ archivedAt: at, updatedAt: at, seq }).where(eq(schema.meta.id, meta.id)).run(); append(tx, { seq, now: at, actorId: null, kind: "group_archived", group: { name: meta.name, previousName: null } }); outcome.archived = true; - unschedule(tx, "archive"); } + // Archived, by this run or by a member: only collecting is left to wait for, and a spent + // archive deadline left behind would hold the alarm in the past. + unschedule(tx, "archive"); // Nobody left with an account: collect as soon as it is quiet. const due = hasAccountHolders(tx) ? quietSince + DORMANCY.purgeAfter : quietSince; @@ -99,7 +93,6 @@ export function runDormancy(tx: Tx, now: number): UpkeepOutcome { purge(tx, nowIso(now)); stagePurge(tx, meta.id, nowIso(now)); unschedule(tx, "purge"); - unschedule(tx, "archive"); outcome.purged = true; return outcome; } diff --git a/server/src/forget.ts b/server/src/forget.ts index ec7dd20..b60f90c 100644 --- a/server/src/forget.ts +++ b/server/src/forget.ts @@ -3,6 +3,7 @@ import { drizzle } from "drizzle-orm/d1"; import { user } from "./auth-schema"; import { defaultAvatar } from "./db/appearance"; +import { nextProfileVersion, profileVersion } from "./db/d1/profile-version"; import { deviceTokens, memberships, profiles } from "./db/d1/schema"; /** @@ -35,10 +36,13 @@ export async function forgetAccount(env: Env, profileId: string, { heir }: { hei // Kept, emptied, so co-members' history still resolves. const now = new Date().toISOString(); - await db - .update(profiles) - .set({ displayName: null, upiVpa: null, ...defaultAvatar, deletedAt: now, updatedAt: now }) - .where(eq(profiles.id, profileId)); + await db.batch([ + nextProfileVersion(db), + db + .update(profiles) + .set({ displayName: null, upiVpa: null, ...defaultAvatar, deletedAt: now, updatedAt: now, version: profileVersion }) + .where(eq(profiles.id, profileId)), + ]); return { forgotten, purged }; } diff --git a/server/src/schemas/account.ts b/server/src/schemas/account.ts index 2415252..fad4deb 100644 --- a/server/src/schemas/account.ts +++ b/server/src/schemas/account.ts @@ -2,7 +2,7 @@ import { z } from "@hono/zod-openapi"; import { deviceTokens, platforms, profiles } from "../db/d1/schema"; import { avatarFields, avatarRefinements, refineAvatar } from "./appearance"; -import { createSelectSchema, IdSchema, NameSchema, TimestampSchema, UpiVpaSchema } from "./common"; +import { createSelectSchema, IdSchema, NameSchema, SeqSchema, TimestampSchema, UpiVpaSchema } from "./common"; /** The person behind an account, their devices, and deleting it. None of it is group-scoped. */ @@ -15,6 +15,7 @@ const profileRow = createSelectSchema(profiles, { ...avatarRefinements, updatedAt: () => TimestampSchema, deletedAt: () => TimestampSchema, + version: () => SeqSchema, }); /** A null `displayName` means nobody chose one; a set `deletedAt` means the account is gone. */ @@ -26,22 +27,16 @@ export const ProfileUpdateSchema = profileRow .superRefine(refineAvatar) .openapi("ProfileUpdate"); -/** - * One page of the profile feed, the one feed cursored on time: D1 has no - * single writer to hand out a sequence number. - */ +/** One page of the profile feed, oldest version first. */ export const ProfilePageSchema = z .object({ profiles: z.array(ProfileSchema), - /** Opaque. Send it back as `after`; null when the page is empty. */ - cursor: z.string().nullable(), + /** The newest version on the page, or the cursor sent when it is empty. Send it back as `since`. */ + seq: SeqSchema, hasMore: z.boolean(), }) .openapi("ProfilePage"); -/** The groups this account is still in. */ -export const GroupIdsSchema = z.object({ groupIds: z.array(IdSchema) }).openapi("GroupIds"); - /** An FCM registration token. Registering claims it for this session, since phones change hands. */ export const DeviceSchema = createSelectSchema(deviceTokens, { token: () => z.string().min(1).max(4096), platform: () => PlatformSchema }) .pick({ token: true, platform: true }) @@ -61,6 +56,5 @@ export const AccountDeletionSchema = z export type Profile = z.infer; export type ProfileUpdate = z.infer; export type ProfilePage = z.infer; -export type GroupIds = z.infer; export type Device = z.infer; export type AccountDeletion = z.infer; diff --git a/server/src/schemas/common.ts b/server/src/schemas/common.ts index 14f9c81..739fc8f 100644 --- a/server/src/schemas/common.ts +++ b/server/src/schemas/common.ts @@ -62,7 +62,7 @@ export const refusalCodes = [ "no_such_member", /** `sum(payers) = sum(shares) = amount` does not hold. */ "unbalanced", - /** Composed against a version that has since moved money. */ + /** Composed against a version that has since changed. */ "stale_base", /** A column rule: whose name, whose payment handle. */ "forbidden", diff --git a/server/src/schemas/ledger.ts b/server/src/schemas/ledger.ts index 25a7c1e..cfd0762 100644 --- a/server/src/schemas/ledger.ts +++ b/server/src/schemas/ledger.ts @@ -4,7 +4,7 @@ import { linkKinds } from "../db/d1/schema"; import * as tables from "../db/group/schema"; import { ProfileSchema } from "./account"; import { avatarFields, avatarRefinements, coverFields, coverRefinements, refineAvatar, refineCover } from "./appearance"; -import { CurrencyCodeSchema, createSelectSchema, DateSchema, IdSchema, NameSchema, SeqSchema, TimestampSchema, TimeZoneSchema, UpiVpaSchema } from "./common"; +import { CurrencyCodeSchema, createSelectSchema, DateSchema, ErrorCodeSchema, IdSchema, NameSchema, SeqSchema, TimestampSchema, TimeZoneSchema, UpiVpaSchema } from "./common"; /** * The ledger on the wire. Rows are the group object's tables; request bodies @@ -230,6 +230,35 @@ export const ChangePageSchema = z /** A page as the group's object answers it; the Worker adds the profiles, which live in D1. */ export type GroupChanges = Omit; +/** Where a device stands in one group's history. */ +export const GroupCursorSchema = z.object({ groupId: IdSchema, since: SeqSchema }).openapi("GroupCursor"); + +/** The most groups one pull reads. A device with more asks again with the rest. */ +export const MAX_GROUPS_PER_PULL = 50; + +/** Groups to read past their cursors, each its own page. */ +export const PullRequestSchema = z + .object({ + groups: z.array(GroupCursorSchema).max(MAX_GROUPS_PER_PULL), + /** Per group, counted in changes as on a page. */ + limit: z.int().min(1).max(500), + }) + .refine((request) => new Set(request.groups.map((cursor) => cursor.groupId)).size === request.groups.length, { path: ["groups"], message: "Each group is asked for once." }) + .openapi("PullRequest"); + +/** A group in the request that could not be read, and why. Every other group in the same pull is still answered. */ +export const GroupRefusalSchema = z.object({ groupId: IdSchema, code: ErrorCodeSchema, message: z.string() }).openapi("GroupRefusal"); + +export const PullSchema = z + .object({ + /** Every group this account is in now, asked for or not: how a device finds a group joined elsewhere. */ + groupIds: z.array(IdSchema), + /** One page per group read, in the order asked. */ + pages: z.array(ChangePageSchema), + refusals: z.array(GroupRefusalSchema), + }) + .openapi("Pull"); + /** The kinds that wake a device: an expense, somebody arriving, somebody leaving. */ export const notifiableKinds = ["entry", "member_joined", "member_left"] as const satisfies readonly (typeof tables.eventKinds)[number][]; @@ -267,4 +296,8 @@ export type GroupEventPayload = z.infer; export type LinkEventPayload = z.infer; export type Event = z.infer; export type ChangePage = z.infer; +export type GroupCursor = z.infer; +export type PullRequest = z.infer; +export type GroupRefusal = z.infer; +export type Pull = z.infer; export type PushData = z.infer; diff --git a/server/test/account.test.ts b/server/test/account.test.ts index 3a63c4e..09d05d3 100644 --- a/server/test/account.test.ts +++ b/server/test/account.test.ts @@ -3,9 +3,9 @@ import { beforeAll, describe, expect, it } from "vitest"; import { defaultAvatar, defaultGroupLook } from "../src/db/appearance"; import type { ApiError } from "../src/schemas/common"; -import type { AccountDeletion, ChangePage, GroupIds, GroupLink, Joined, LinkPreview, Profile, ProfilePage } from "./api-types"; +import type { AccountDeletion, GroupLink, Joined, LinkPreview, Profile, ProfilePage } from "./api-types"; import { freshId } from "./group"; -import { type Guest, signInAsGuest } from "./session"; +import { changes, type Guest, pull, pullResponse, signInAsGuest } from "./session"; /** The person, rather than the ledger. */ @@ -166,7 +166,7 @@ describe("the profile feed", () => { expect(other.profiles.map((row) => row.id)).toContain(host.id); }); - it("pages on the pair, so two renames in one millisecond cannot hide one", async () => { + it("pages on the version, delivering every row once", async () => { const host = await signInAsGuest(); const friends = [await signInAsGuest(), await signInAsGuest(), await signInAsGuest()]; @@ -178,17 +178,16 @@ describe("the profile feed", () => { } const collected: string[] = []; - let cursor: ProfilePage = { profiles: [], cursor: null, hasMore: true }; + let page: ProfilePage = { profiles: [], seq: 0, hasMore: true }; let pages = 0; - while (cursor.hasMore && pages < 10) { - const query = cursor.cursor ? `?limit=2&after=${encodeURIComponent(cursor.cursor)}` : "?limit=2"; - cursor = await json(await call(`/api/profiles${query}`, host)); - collected.push(...cursor.profiles.map((row) => row.id)); + while (page.hasMore && pages < 10) { + page = await json(await call(`/api/profiles?limit=2&since=${page.seq}`, host)); + collected.push(...page.profiles.map((row) => row.id)); pages += 1; } - expect(cursor.hasMore).toBe(false); + expect(page.hasMore).toBe(false); expect(new Set(collected)).toEqual(new Set([host.id, ...friends.map((friend) => friend.id)])); // No row twice. @@ -204,7 +203,7 @@ describe("a group's change page", () => { await named(stranger, "Zara"); const { groupId } = await share(host, friend, "Priya"); - const page = await json(await call(`/api/groups/${groupId}/changes?since=0`, host)); + const page = await changes(host, groupId); expect(page.profiles.map((row) => row.id).sort()).toEqual([host.id, friend.id].sort()); }); @@ -220,7 +219,7 @@ describe("a group's change page", () => { await call(`/api/links/${link.token}/join`, guest, { method: "POST", body: JSON.stringify({ memberId: null, displayName: `Friend ${index}` }) }); } - const page = await json(await call(`/api/groups/${groupId}/changes?since=0`, host)); + const page = await changes(host, groupId); expect(page.profiles).toHaveLength(121); }, 30_000); @@ -239,7 +238,7 @@ describe("a group's change page", () => { const { groupId } = await share(host, friend, "Priya"); - const page = await json(await call(`/api/groups/${groupId}/changes?since=0`, host)); + const page = await changes(host, groupId); expect(page.profiles).toContainEqual(expect.objectContaining({ id: friend.id, displayName: "Priya" })); }); }); @@ -283,7 +282,7 @@ describe("deleting an account", () => { // The group is untouched for everybody else, and the member row keeps its // name: money Ravi paid is a fact about Priya's group as much as his. - const page = await json(await call(`/api/groups/${groupId}/changes?since=0`, staying)); + const page = await changes(staying, groupId); const row = page.members.find((each) => each.id === member.id); expect(row?.displayName).toBe("Ravi"); @@ -300,7 +299,7 @@ describe("deleting an account", () => { await call("/api/account", leaving, { method: "DELETE" }); - const page = await json(await call(`/api/groups/${groupId}/changes?since=0`, staying)); + const page = await changes(staying, groupId); expect(page.members.find((each) => each.id === member.id)?.displayName).toBe("Ravi Kumar"); }); @@ -314,7 +313,7 @@ describe("deleting an account", () => { // Holding somebody's expense descriptions forever in a group with no living // reader is the opposite of what deleting an account asks for. const after = await signInAsGuest(); - const grave = await json(await call(`/api/groups/${groupId}/changes?since=0`, after)); + const grave = await changes(after, groupId); expect(grave.purgedAt).not.toBeNull(); expect(grave.members).toEqual([]); @@ -339,10 +338,10 @@ describe("deleting an account", () => { // The session is gone with the account, so the device is signed out by // consequence rather than by a second call. - expect((await call("/api/groups", going)).status).toBe(401); + expect((await pullResponse(going, [])).status).toBe(401); // The place is a placeholder now, so the group no longer names the account at all. - const page = await json(await call(`/api/groups/${groupId}/changes?since=0`, friend)); + const page = await changes(friend, groupId); expect(page.profiles.map((row) => row.id)).not.toContain(going.id); }); @@ -354,7 +353,7 @@ describe("deleting an account", () => { describe("a guest who never became anybody", () => { it("still gets a profile, no groups and an empty feed", async () => { const guest = await signInAsGuest(); - expect(await json(await call("/api/groups", guest))).toEqual({ groupIds: [] }); + expect((await pull(guest, [])).groupIds).toEqual([]); // Visible only to themselves, which is the degenerate case of the rule // rather than a special one: nobody shares a group with them yet. diff --git a/server/test/activity.test.ts b/server/test/activity.test.ts index 3467126..5727734 100644 --- a/server/test/activity.test.ts +++ b/server/test/activity.test.ts @@ -52,7 +52,7 @@ describe("recording an expense", () => { const { groupId, ravi, priya } = await makeGroup(); const id = freshId("e"); - ok(await saveEntry(stub(groupId), evenly(id, ravi.id, [ravi.id, priya.id], 1000), RAVI)); + const first = ok(await saveEntry(stub(groupId), evenly(id, ravi.id, [ravi.id, priya.id], 1000), RAVI)); const before = (await eventsOf(groupId, RAVI)).filter((event) => event.kind === "entry").length; ok( @@ -60,6 +60,7 @@ describe("recording an expense", () => { stub(groupId), expense({ id, + baseSeq: first.seq, amountMinor: 1000, payers: [{ memberId: ravi.id, amountMinor: 1000 }], shares: [ diff --git a/server/test/api-types.ts b/server/test/api-types.ts index e6b76ad..80dc2a2 100644 --- a/server/test/api-types.ts +++ b/server/test/api-types.ts @@ -1,3 +1,3 @@ /** The wire types, as a client sees them. */ -export type { AccountDeletion, GroupIds, Profile, ProfilePage } from "../src/schemas/account"; +export type { AccountDeletion, Profile, ProfilePage } from "../src/schemas/account"; export type { ChangePage, Entry, Group, GroupLink, Invite, Joined, LinkPreview, LinkRevocation, LiveLink, Member, PlaceholderList } from "../src/schemas/ledger"; diff --git a/server/test/api.test.ts b/server/test/api.test.ts index 23f82ed..75dddd7 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -3,9 +3,9 @@ import { beforeAll, describe, expect, it } from "vitest"; import { defaultGroupLook } from "../src/db/appearance"; import type { ApiError } from "../src/schemas/common"; -import type { ChangePage, Entry, Group, GroupIds, Member } from "./api-types"; +import type { Entry, Group, Member } from "./api-types"; import { freshId } from "./group"; -import { type Guest, signInAsGuest } from "./session"; +import { changes, type Guest, pull, pullResponse, signInAsGuest } from "./session"; /** The sync surface over HTTP. */ @@ -72,30 +72,27 @@ beforeAll(async () => { describe("without a session", () => { it("refuses every route in the sync surface", async () => { - const paths = ["/api/groups", `/api/groups/${freshId()}/changes`]; - - for (const path of paths) { - const response = await call(path, null); - expect(response.status, path).toBe(401); - expect((await json(response)).error.code).toBe("no_session"); - } + const pulled = await pullResponse(null, [{ groupId: freshId(), since: 0 }]); + expect(pulled.status).toBe(401); + expect((await json(pulled)).error.code).toBe("no_session"); const write = await call(`/api/groups/${freshId()}`, null, { method: "PUT", body: JSON.stringify({}) }); expect(write.status).toBe(401); }); }); -describe("listing groups", () => { - it("names the groups the account is still in", async () => { +describe("finding the account's groups", () => { + it("names the groups the account is still in, though none was asked for", async () => { const { id } = await makeGroup(ravi); - const body = await json(await call("/api/groups", ravi)); + const body = await pull(ravi, []); expect(body.groupIds).toContain(id); + expect(body.pages).toEqual([]); }); it("does not name somebody else's groups", async () => { const { id } = await makeGroup(ravi); - const body = await json(await call("/api/groups", zara)); + const body = await pull(zara, []); expect(body.groupIds).not.toContain(id); }); @@ -104,39 +101,66 @@ describe("listing groups", () => { describe("the change feed", () => { it("answers with a cursor, and pages on it", async () => { const { id, group } = await makeGroup(ravi); - const page = await json(await call(`/api/groups/${id}/changes?since=0`, ravi)); + const page = await changes(ravi, id); expect(page.group?.id).toBe(group.id); expect(page.members).toHaveLength(1); expect(page.hasMore).toBe(false); - const nothing = await json(await call(`/api/groups/${id}/changes?since=${page.seq}`, ravi)); + const nothing = await changes(ravi, id, page.seq); expect(nothing.seq).toBe(page.seq); expect(nothing.members).toHaveLength(0); }); - it("refuses a stranger with 403, and says the refusal is permanent", async () => { + it("refuses a stranger one group, and still answers the rest of the pull", async () => { const { id } = await makeGroup(ravi); - const response = await call(`/api/groups/${id}/changes`, zara); + const { id: zaras } = await makeGroup(zara); + const body = await pull(zara, [ + { groupId: id, since: 0 }, + { groupId: zaras, since: 0 }, + ]); + + expect(body.refusals).toEqual([{ groupId: id, code: "not_member", message: expect.any(String) }]); + expect(body.pages.map((page) => page.groupId)).toEqual([zaras]); + }); - expect(response.status).toBe(403); - const body = await json(response); - expect(body.error.code).toBe("not_member"); - expect(body.error.retry).toBe("permanent"); + it("tells a device that a group id names nothing", async () => { + const ghost = freshId("ghost"); + const body = await pull(ravi, [{ groupId: ghost, since: 0 }]); + + expect(body.refusals).toEqual([{ groupId: ghost, code: "no_group", message: expect.any(String) }]); }); - it("tells a device that a group id names nothing, rather than refusing it", async () => { - const response = await call(`/api/groups/${freshId("ghost")}/changes`, ravi); + it("reads many groups in one request, each past its own cursor", async () => { + const first = await makeGroup(ravi); + const second = await makeGroup(ravi); + const read = await changes(ravi, first.id); - expect(response.status).toBe(404); - expect((await json(response)).error.code).toBe("no_group"); + const body = await pull(ravi, [ + { groupId: first.id, since: read.seq }, + { groupId: second.id, since: 0 }, + ]); + + expect(body.pages.map((page) => [page.groupId, page.members.length])).toEqual([ + [first.id, 0], + [second.id, 1], + ]); + }); + + it("refuses a pull that asks for one group twice", async () => { + const { id } = await makeGroup(ravi); + const response = await pullResponse(ravi, [ + { groupId: id, since: 0 }, + { groupId: id, since: 0 }, + ]); + expect(response.status).toBe(400); }); }); describe("recording an expense over HTTP", () => { it("lands, and comes back in the feed", async () => { const { id } = await makeGroup(ravi); - const page = await json(await call(`/api/groups/${id}/changes`, ravi)); + const page = await changes(ravi, id); const me = page.members[0]; if (!me) expect.unreachable("The group has no members."); @@ -147,7 +171,7 @@ describe("recording an expense over HTTP", () => { expect(entry.amountMinor).toBe(1000); expect(entry.createdBy).toBe(me.id); - const after = await json(await call(`/api/groups/${id}/changes?since=${page.seq}`, ravi)); + const after = await changes(ravi, id, page.seq); expect(after.entries.map((row) => row.id)).toContain(entry.id); }); @@ -158,7 +182,7 @@ describe("recording an expense over HTTP", () => { */ it("is refused with 422 when it does not add up", async () => { const { id } = await makeGroup(ravi); - const page = await json(await call(`/api/groups/${id}/changes`, ravi)); + const page = await changes(ravi, id); const me = page.members[0]; if (!me) expect.unreachable("The group has no members."); @@ -174,9 +198,9 @@ describe("recording an expense over HTTP", () => { * The one refusal a device should act on by re-reading and composing again, * and the only one of six 409s that says so. */ - it("is refused with 409 and retry 'stale' when somebody else moved the money", async () => { + it("is refused with 409 and retry 'stale' when somebody else changed it first", async () => { const { id } = await makeGroup(ravi); - const page = await json(await call(`/api/groups/${id}/changes`, ravi)); + const page = await changes(ravi, id); const me = page.members[0]; if (!me) expect.unreachable("The group has no members."); @@ -184,7 +208,7 @@ describe("recording an expense over HTTP", () => { const entryId = freshId("api-e"); const first = await json(await putEntry(id, body(1000))); - await putEntry(id, body(2000)); + await putEntry(id, { ...body(2000), baseSeq: first.seq }); const stale = await putEntry(id, { ...body(1500), baseSeq: first.seq }); @@ -204,7 +228,7 @@ describe("recording an expense over HTTP", () => { it("keeps when and where it happened, both or neither", async () => { const { id } = await makeGroup(ravi); - const page = await json(await call(`/api/groups/${id}/changes`, ravi)); + const page = await changes(ravi, id); const me = page.members[0]; if (!me) expect.unreachable("The group has no members."); const post = (moment: Record) => putEntry(id, expense({ payers: [{ memberId: me.id, amountMinor: 1000 }], shares: [{ memberId: me.id, amountMinor: 1000, weightMicros: null }], ...moment })); @@ -218,7 +242,7 @@ describe("recording an expense over HTTP", () => { it("soft-deletes and restores by writing the row, and stamps the server's own time", async () => { const { id } = await makeGroup(ravi); - const page = await json(await call(`/api/groups/${id}/changes`, ravi)); + const page = await changes(ravi, id); const me = page.members[0]; if (!me) expect.unreachable("The group has no members."); @@ -265,9 +289,9 @@ describe("the roster over HTTP", () => { it("changes how a group looks without telling the activity feed", async () => { const { id } = await makeGroup(ravi); - const look = (changes: Record) => call(`/api/groups/${id}`, ravi, { method: "PUT", body: JSON.stringify({ name: "Goa trip", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: "unused", creatorName: "Ravi", ...changes }) }); + const look = (look: Record) => call(`/api/groups/${id}`, ravi, { method: "PUT", body: JSON.stringify({ name: "Goa trip", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: null, ...defaultGroupLook, creatorId: "unused", creatorName: "Ravi", ...look }) }); - const events = async () => (await json(await call(`/api/groups/${id}/changes?since=0`, ravi))).events.length; + const events = async () => (await changes(ravi, id)).events.length; const before = await events(); const iconed = await look({ avatarKind: "icon", avatarIcon: "beach_access", avatarColor: "amber" }); @@ -299,7 +323,7 @@ describe("which group a page is about", () => { */ it("is on the page, and not repeated on every row", async () => { const { id } = await makeGroup(ravi); - const page = await json(await call(`/api/groups/${id}/changes`, ravi)); + const page = await changes(ravi, id); expect(page.groupId).toBe(id); @@ -312,7 +336,7 @@ describe("which group a page is about", () => { const { id } = await makeGroup(ravi); await env.GROUP.getByName(id).runUpkeep(Date.now() + 800 * 24 * 60 * 60 * 1000); - const page = await json(await call(`/api/groups/${id}/changes`, ravi)); + const page = await changes(ravi, id); expect(page.groupId).toBe(id); expect(page.purgedAt).not.toBeNull(); expect(page.group).toBeNull(); diff --git a/server/test/dormancy.test.ts b/server/test/dormancy.test.ts index ca838f2..2728174 100644 --- a/server/test/dormancy.test.ts +++ b/server/test/dormancy.test.ts @@ -1,7 +1,10 @@ +import { runInDurableObject } from "cloudflare:test"; import { env } from "cloudflare:workers"; import { describe, expect, it } from "vitest"; -import { deleteEntry, editMember, evenly, freshId, makeGroup, makeGroupOfTwo, ok, PRIYA, RAVI, refusal, saveEntry, stub } from "./group"; +import { defaultGroupLook } from "../src/db/appearance"; +import type { Group } from "../src/do/group"; +import { deleteEntry, draftOf, editMember, evenly, freshId, makeGroup, makeGroupOfTwo, ok, PRIYA, RAVI, refusal, saveEntry, stub } from "./group"; /** Dormancy and account deletion: what an idle group does to itself, and what survives somebody deleting the account that made it. */ @@ -42,6 +45,55 @@ describe("a group that goes quiet", () => { expect(ok(await object.changes(RAVI, 0, 500)).group?.archivedAt).toBeNull(); }); + /** + * Quiet is measured from the last change anybody made, not from the newest + * expense: a group whose people spend months correcting old expenses, or + * only joining and renaming, is in use. + */ + it("counts an edit to an old expense as use", async () => { + const { groupId, ravi, priya } = await makeGroup(); + const object = stub(groupId); + const entry = ok(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id, priya.id], 400), RAVI)); + + // Recorded, and last touched, half a year ago. + const longAgo = Date.now() - 180 * DAYS; + await runInDurableObject(object, async (_instance: Group, state) => { + state.storage.sql.exec("update entries set created_at = ?", new Date(longAgo).toISOString()); + state.storage.sql.exec("update meta set last_activity_at = ?", longAgo); + }); + ok(await saveEntry(object, draftOf(entry, { description: "Dinner, corrected" }), RAVI)); + + expect((await object.runUpkeep(Date.now() + 10 * DAYS)).archived).toBe(false); + }); + + it("does not count being archived as use", async () => { + const { groupId } = await makeGroup(); + const object = stub(groupId); + + await object.runUpkeep(Date.now() + 100 * DAYS); + const activity = await runInDurableObject(object, async (_instance: Group, state) => state.storage.sql.exec<{ last_activity_at: number }>("select last_activity_at from meta").one().last_activity_at); + expect(activity).toBeLessThan(Date.now() + DAYS); + }); + + /** A deadline already spent would hold the object's one alarm in the past, firing again as soon as it returns. */ + it("leaves no archive deadline behind once it is archived, by anybody", async () => { + const { groupId, ravi, priya } = await makeGroup(); + const object = stub(groupId); + const entry = ok(await saveEntry(object, evenly(freshId("e"), ravi.id, [ravi.id, priya.id], 400), RAVI)); + ok(await object.putGroup(groupId, { name: "Goa trip", defaultCurrency: "INR", isDirect: false, simplifyDebts: true, archivedAt: new Date().toISOString(), ...defaultGroupLook, creatorId: ravi.id, creatorName: "Ravi" }, RAVI)); + // Deleting is a change, so it arms the archive deadline again, in an archived group. + ok(await deleteEntry(object, entry, RAVI)); + + await object.runUpkeep(Date.now() + 100 * DAYS); + const chores = await runInDurableObject(object, async (_instance: Group, state) => + state.storage.sql + .exec<{ name: string }>("select name from schedule") + .toArray() + .map((row) => row.name), + ); + expect(chores).not.toContain("archive"); + }); + it("tells the record that nobody archived it", async () => { const { groupId } = await makeGroup(); await stub(groupId).runUpkeep(Date.now() + 100 * DAYS); diff --git a/server/test/group.ts b/server/test/group.ts index c9cd14c..107c522 100644 --- a/server/test/group.ts +++ b/server/test/group.ts @@ -14,7 +14,11 @@ export const ZARA = "99999999-9999-4999-8999-999999999999"; /** A real account (a `user` row) at a fresh id: D1 refuses memberships for any other kind. */ export async function makeAccount(displayName: string | null = null): Promise { const id = crypto.randomUUID(); - await env.DB.batch([env.DB.prepare("insert into user (id, name, email, updated_at) values (?, ?, ?, ?)").bind(id, "", `${id}@fixture.invalid`, Date.now()), env.DB.prepare("insert into profiles (id, display_name, updated_at) values (?, ?, ?)").bind(id, displayName, new Date().toISOString())]); + await env.DB.batch([ + env.DB.prepare("insert into user (id, name, email, updated_at) values (?, ?, ?, ?)").bind(id, "", `${id}@fixture.invalid`, Date.now()), + env.DB.prepare("insert into counters (name, value) values ('profiles', 1) on conflict (name) do update set value = value + 1"), + env.DB.prepare("insert into profiles (id, display_name, updated_at, version) values (?, ?, ?, (select value from counters where name = 'profiles'))").bind(id, displayName, new Date().toISOString()), + ]); return id; } diff --git a/server/test/ledger.test.ts b/server/test/ledger.test.ts index 6e311cd..c75ecbc 100644 --- a/server/test/ledger.test.ts +++ b/server/test/ledger.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { deleteEntry, draftOf, editGroup, editMember, evenly, expense, freshId, makeGroup, ok, PRIYA, RAVI, refusal, restoreEntry, saveEntry, stub, sumOf } from "./group"; -/** The invariant and the write path: an expense that does not add up, a hard delete, an idempotent retry, and the stale-base predicate. */ +/** The invariant and the write path: an expense that does not add up, a hard delete, an idempotent retry, and the stale-base rule. */ describe("an expense has to add up", () => { it("accepts one that does", async () => { const { groupId, ravi, priya } = await makeGroup(); @@ -106,8 +106,8 @@ describe("writing an expense twice", () => { const object = stub(groupId); const id = freshId("e"); - ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 1200), ravi.profileId ?? "")); - const edited = ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 1600), ravi.profileId ?? "")); + const first = ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 1200), ravi.profileId ?? "")); + const edited = ok(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1600), baseSeq: first.seq }, ravi.profileId ?? "")); expect(edited.amountMinor).toBe(1600); expect(edited.shares).toHaveLength(2); @@ -139,7 +139,7 @@ describe("writing an expense twice", () => { // Priya is a placeholder with no account, so Ravi is the only possible // author. The point is that there is no parameter to say otherwise. - const edited = ok(await saveEntry(object, evenly(id, priya.id, [ravi.id, priya.id], 800), ravi.profileId ?? "")); + const edited = ok(await saveEntry(object, { ...evenly(id, priya.id, [ravi.id, priya.id], 800), baseSeq: first.seq }, ravi.profileId ?? "")); expect(edited.createdBy).toBe(first.createdBy); expect(edited.createdAt).toBe(first.createdAt); @@ -159,62 +159,58 @@ describe("an edit composed against a version that has moved", () => { }); /** - * Two people fixing a typo should not have to arbitrate. The predicate is - * about money, not about staleness. + * The write replaces the whole expense, so a stale one carries the old + * value of every field it did not touch. Accepting it would silently undo + * the other edit, whatever the edit was about. */ - it("is applied when it is stale but changes nothing about the money", async () => { + it("is refused when it is stale, even if it changes nothing about the money", async () => { const { groupId, ravi, priya } = await makeGroup(); const object = stub(groupId); const id = freshId("e"); const first = ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 1000), ravi.profileId ?? "")); - ok(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1000), description: "Dinner at Britto's" }, ravi.profileId ?? "")); + ok(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1000), notes: "Paid on the card", baseSeq: first.seq }, ravi.profileId ?? "")); - const late = ok(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1000), description: "Dinner, Britto's", baseSeq: first.seq }, ravi.profileId ?? "")); + const late = refusal(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1000), description: "Dinner, Britto's", baseSeq: first.seq }, ravi.profileId ?? "")); + expect(late.code).toBe("stale_base"); - expect(late.description).toBe("Dinner, Britto's"); + const page = ok(await object.changes(ravi.profileId ?? "", 0, 100)); + expect(page.entries.find((entry) => entry.id === id)?.notes).toBe("Paid on the card"); }); - it("is refused when it would move money away from where the server has it", async () => { + it("is refused when it claims to be new but the row exists", async () => { const { groupId, ravi, priya } = await makeGroup(); const object = stub(groupId); const id = freshId("e"); - const first = ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 1000), ravi.profileId ?? "")); - ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 2000), ravi.profileId ?? "")); + ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 1000), ravi.profileId ?? "")); + expect(refusal(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1000), description: "Lunch" }, ravi.profileId ?? "")).code).toBe("stale_base"); + }); - const late = refusal(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1000), baseSeq: first.seq }, ravi.profileId ?? "")); - expect(late.code).toBe("stale_base"); + it("is answered with the stored row when it sends what is already stored, whatever its base", async () => { + const { groupId, ravi, priya } = await makeGroup(); + const object = stub(groupId); + const input = evenly(freshId("e"), ravi.id, [ravi.id, priya.id], 1000); - // And the correction somebody else made is still standing. - const page = ok(await object.changes(ravi.profileId ?? "", 0, 100)); - expect(page.entries.find((entry) => entry.id === id)?.amountMinor).toBe(2000); + // A retry whose first response was lost still holds no base. + const first = ok(await saveEntry(object, input, ravi.profileId ?? "")); + expect(ok(await saveEntry(object, input, ravi.profileId ?? "")).seq).toBe(first.seq); }); - it("is refused when only the shares moved, even though the total did not", async () => { + it("is refused when it would move money away from where the server has it", async () => { const { groupId, ravi, priya } = await makeGroup(); const object = stub(groupId); const id = freshId("e"); const first = ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 1000), ravi.profileId ?? "")); + ok(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 2000), baseSeq: first.seq }, ravi.profileId ?? "")); + + const late = refusal(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1000), baseSeq: first.seq }, ravi.profileId ?? "")); + expect(late.code).toBe("stale_base"); - ok( - await saveEntry( - object, - expense({ - id, - amountMinor: 1000, - payers: [{ memberId: ravi.id, amountMinor: 1000 }], - shares: [ - { memberId: ravi.id, amountMinor: 900, weightMicros: null }, - { memberId: priya.id, amountMinor: 100, weightMicros: null }, - ], - }), - ravi.profileId ?? "", - ), - ); - - expect(refusal(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 1000), baseSeq: first.seq }, ravi.profileId ?? "")).code).toBe("stale_base"); + // And the correction somebody else made is still standing. + const page = ok(await object.changes(ravi.profileId ?? "", 0, 100)); + expect(page.entries.find((entry) => entry.id === id)?.amountMinor).toBe(2000); }); }); @@ -244,7 +240,7 @@ describe("deleting an expense", () => { const id = freshId("e"); const entry = ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 600), ravi.profileId ?? "")); - ok(await saveEntry(object, evenly(id, ravi.id, [ravi.id, priya.id], 900), ravi.profileId ?? "")); + ok(await saveEntry(object, { ...evenly(id, ravi.id, [ravi.id, priya.id], 900), baseSeq: entry.seq }, ravi.profileId ?? "")); expect(refusal(await deleteEntry(object, entry, ravi.profileId ?? "")).code).toBe("stale_base"); }); diff --git a/server/test/links.test.ts b/server/test/links.test.ts index b51240f..144ef88 100644 --- a/server/test/links.test.ts +++ b/server/test/links.test.ts @@ -3,9 +3,9 @@ import { beforeAll, describe, expect, it } from "vitest"; import { defaultAvatar, defaultGroupLook } from "../src/db/appearance"; import type { ApiError } from "../src/schemas/common"; -import type { ChangePage, GroupLink, Invite, Joined, LinkPreview, LinkRevocation, LiveLink, PlaceholderList } from "./api-types"; +import type { GroupLink, Invite, Joined, LinkPreview, LinkRevocation, LiveLink, PlaceholderList } from "./api-types"; import { freshId } from "./group"; -import { type Guest, signInAsGuest } from "./session"; +import { changes, type Guest, signInAsGuest } from "./session"; /** Arriving, over HTTP. */ @@ -216,7 +216,7 @@ describe("the name, travelling the other way", () => { // Somebody arriving on an invite signed in as a guest a moment earlier and // has no name at all, while the group already knows them as whatever was // typed on the placeholder. - const { profiles } = await json(await call(`/api/groups/${id}/changes?since=0`, ravi)); + const { profiles } = await changes(ravi, id); expect(profiles).toContainEqual(expect.objectContaining({ id: arriving.id, displayName: "Priya" })); }); @@ -231,7 +231,7 @@ describe("the name, travelling the other way", () => { // The slot keeps the name the group knows, and the account keeps its own. expect(claimed.member.displayName).toBe("P"); - const { profiles } = await json(await call(`/api/groups/${id}/changes?since=0`, ravi)); + const { profiles } = await changes(ravi, id); expect(profiles.find((row) => row.id === arriving.id)?.displayName).toBe("Priya Sharma"); }); diff --git a/server/test/session.ts b/server/test/session.ts index 8a81901..70a94e7 100644 --- a/server/test/session.ts +++ b/server/test/session.ts @@ -1,6 +1,7 @@ import { exports as workerExports } from "cloudflare:workers"; import type { IdentityOutcome } from "../src/schemas/identity"; +import type { ChangePage, GroupCursor, Pull } from "../src/schemas/ledger"; /** A guest account and its bearer token. */ export interface Guest { @@ -22,3 +23,28 @@ export async function signInAsGuest(): Promise { export function randomIp(): string { return Array.from({ length: 4 }, () => Math.floor(Math.random() * 256)).join("."); } + +const ORIGIN = "https://opensplit.test"; + +/** The sync route, as the app calls it. */ +export function pullResponse(guest: Guest | null, groups: GroupCursor[], limit = 200): Promise { + const headers = new Headers({ "Content-Type": "application/json" }); + if (guest) headers.set("Authorization", `Bearer ${guest.token}`); + return workerExports.default.fetch(`${ORIGIN}/api/sync`, { method: "POST", headers, body: JSON.stringify({ groups, limit }) }); +} + +export async function pull(guest: Guest, groups: GroupCursor[], limit = 200): Promise { + const response = await pullResponse(guest, groups, limit); + if (response.status !== 200) throw new Error(`Pull failed with ${response.status}: ${await response.text()}`); + return (await response.json()) as Pull; +} + +/** One group's page past `since`, failing the test with the refusal's own words if it is refused. */ +export async function changes(guest: Guest, groupId: string, since = 0, limit = 200): Promise { + const { pages, refusals } = await pull(guest, [{ groupId, since }], limit); + const [refused] = refusals; + if (refused) throw new Error(`Refused with ${refused.code}: ${refused.message}`); + const [page] = pages; + if (!page) throw new Error("The pull answered no page."); + return page; +} diff --git a/test/application/sync_coordinator_test.dart b/test/application/sync_coordinator_test.dart index 3a329f4..9586ff6 100644 --- a/test/application/sync_coordinator_test.dart +++ b/test/application/sync_coordinator_test.dart @@ -22,8 +22,8 @@ void main() { fullRuns++; return first.future; }, - syncGroup: (id) async { - groups.add(id); + syncGroups: (ids) async { + groups.addAll(ids); return _clean; }, ); @@ -50,7 +50,7 @@ void main() { var fullRuns = 0; final coordinator = SyncCoordinator( syncAll: () async => ++fullRuns == 1 ? await first.future : _clean, - syncGroup: (_) async => throw StateError('full sync should cover this'), + syncGroups: (_) async => throw StateError('full sync should cover this'), ); addTearDown(coordinator.dispose); @@ -66,13 +66,72 @@ void main() { expect(coordinator.status.error, isNull); }); + testWidgets('a local write sends and reads back only what it touched', ( + tester, + ) async { + final writes = StreamController(); + addTearDown(writes.close); + var fullRuns = 0; + final partialRuns = >[]; + final coordinator = SyncCoordinator( + syncAll: () async { + fullRuns++; + return _clean; + }, + syncGroups: (ids) async { + partialRuns.add(ids); + return _clean; + }, + writes: writes.stream, + ); + addTearDown(coordinator.dispose); + coordinator.start(); + await tester.pump(); + expect(fullRuns, 1); + + writes + ..add(null) + ..add(null); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + + expect(fullRuns, 1); + expect(partialRuns, [{}], reason: 'two writes, one debounced run'); + }); + + testWidgets('groups asked for during a run go out together', (tester) async { + final first = Completer(); + final partialRuns = >[]; + final coordinator = SyncCoordinator( + syncAll: () async => _clean, + syncGroups: (ids) async { + partialRuns.add(ids); + return partialRuns.length == 1 ? await first.future : _clean; + }, + ); + addTearDown(coordinator.dispose); + await coordinator.syncAll(); + + final running = coordinator.syncGroup('home'); + await tester.pump(); + final second = coordinator.syncGroup('trip'); + final third = coordinator.syncGroup('flat'); + first.complete(_clean); + await Future.wait([running, second, third]); + + expect(partialRuns, [ + {'home'}, + {'trip', 'flat'}, + ]); + }); + testWidgets('a failure stays visible and retries without another trigger', ( tester, ) async { var attempts = 0; final coordinator = SyncCoordinator( syncAll: () async => ++attempts == 1 ? _failed : _clean, - syncGroup: (_) async => _clean, + syncGroups: (_) async => _clean, ); addTearDown(coordinator.dispose); @@ -100,7 +159,7 @@ void main() { attempts++; return fail ? _failed : _clean; }, - syncGroup: (_) async => _clean, + syncGroups: (_) async => _clean, ); await coordinator.syncAll(); for (final seconds in [5, 10, 20, 40, 80, 160, 300, 300]) { @@ -131,10 +190,17 @@ void main() { failed: 0, nextPushAt: now.add(const Duration(seconds: 30)), ); - var attempts = 0; + var fullRuns = 0; + final partialRuns = >[]; final coordinator = SyncCoordinator( - syncAll: () async => ++attempts == 1 ? waiting : _clean, - syncGroup: (_) async => _clean, + syncAll: () async { + fullRuns++; + return waiting; + }, + syncGroups: (ids) async { + partialRuns.add(ids); + return _clean; + }, clock: () => now, ); addTearDown(coordinator.dispose); @@ -144,9 +210,11 @@ void main() { expect(coordinator.status.hasCompletedFullSync, isTrue); expect(coordinator.status.retryAt, waiting.nextPushAt); await tester.pump(const Duration(seconds: 29)); - expect(attempts, 1); + expect(partialRuns, isEmpty); await tester.pump(const Duration(seconds: 1)); - expect(attempts, 2); + // Only the write is owed, so only the write is retried. + expect(partialRuns, [{}]); + expect(fullRuns, 1); expect(coordinator.status.retryAt, isNull); }); @@ -155,7 +223,7 @@ void main() { ) async { final coordinator = SyncCoordinator( syncAll: () async => const SyncReport(pushed: 0, pulled: 1, failed: 1), - syncGroup: (_) async => _clean, + syncGroups: (_) async => _clean, ); addTearDown(coordinator.dispose); await coordinator.syncAll(); @@ -172,7 +240,7 @@ void main() { var groupRuns = 0; final coordinator = SyncCoordinator( syncAll: () async => fail ? _failed : _clean, - syncGroup: (_) async { + syncGroups: (_) async { groupRuns++; return _failed; }, @@ -203,7 +271,7 @@ void main() { attempts++; return attempts == 1 ? _failed : await pending.future; }, - syncGroup: (_) async => _clean, + syncGroups: (_) async => _clean, ); var updates = 0; coordinator.addListener(() => updates++); @@ -226,7 +294,7 @@ void main() { ) async { final coordinator = SyncCoordinator( syncAll: () => throw StateError('database unavailable'), - syncGroup: (_) async => _clean, + syncGroups: (_) async => _clean, ); await coordinator.syncAll(); expect(coordinator.status.error, isA()); diff --git a/test/application/sync_triggers_test.dart b/test/application/sync_triggers_test.dart index 2aafcf3..9fdde51 100644 --- a/test/application/sync_triggers_test.dart +++ b/test/application/sync_triggers_test.dart @@ -12,6 +12,9 @@ void main() { late List runs; late StreamController online; late StreamController writes; + + /// Partial runs: what a local write sends, and the groups it touched. + late List> partial; late DateTime now; late SyncCoordinator scheduler; @@ -31,6 +34,7 @@ void main() { setUp(() { runs = []; + partial = []; online = StreamController.broadcast(); writes = StreamController.broadcast(); now = DateTime.utc(2026, 8, 27, 9); @@ -39,7 +43,10 @@ void main() { runs.add(now); return _clean; }, - syncGroup: (_) async => _clean, + syncGroups: (ids) async { + partial.add(ids); + return _clean; + }, online: online.stream, writes: writes.stream, clock: () => now, @@ -154,7 +161,8 @@ void main() { writes.add(null); await settleWrite(); - expect(runs, hasLength(2)); + expect(runs, hasLength(1)); + expect(partial, [{}], reason: 'the write, sent and read back'); }); test('one action that queues several rows is one sync', () async { @@ -169,8 +177,8 @@ void main() { await settleWrite(); expect( - runs, - hasLength(2), + partial, + hasLength(1), reason: 'a burst from one user action collects into a single sync', ); }); @@ -184,7 +192,11 @@ void main() { await gate.future; return _clean; }, - syncGroup: (_) async => _clean, + syncGroups: (_) async { + started.add(started.length); + await gate.future; + return _clean; + }, online: online.stream, writes: writes.stream, clock: () => now, @@ -226,7 +238,7 @@ void main() { await gate.future; return _clean; }, - syncGroup: (_) async => _clean, + syncGroups: (_) async => _clean, online: online.stream, writes: writes.stream, clock: () => now, @@ -255,7 +267,7 @@ void main() { attempts++; throw StateError('offline'); }, - syncGroup: (_) async => _clean, + syncGroups: (_) async => _clean, online: online.stream, writes: writes.stream, clock: () => now, @@ -280,7 +292,7 @@ void main() { runs.add(now); return _clean; }, - syncGroup: (_) async => _clean, + syncGroups: (_) async => _clean, online: online.stream, clock: () => now, ); diff --git a/test/data/apply_changes_test.dart b/test/data/apply_changes_test.dart index cc4f778..d2e808d 100644 --- a/test/data/apply_changes_test.dart +++ b/test/data/apply_changes_test.dart @@ -15,25 +15,26 @@ void main() { setUp(() async => db = await testDatabase()); tearDown(() => db.close()); - api.Group group({DateTime? archivedAt, int seq = 1}) => api.Group( - avatarKind: api.AvatarKind.initials, - avatarColor: null, - avatarEmoji: null, - avatarIcon: null, - avatarPhoto: null, - coverKind: api.CoverKind.generated, - coverPhoto: null, - id: 'g', - name: 'Goa', - defaultCurrency: 'INR', - isDirect: false, - simplifyDebts: true, - createdBy: 'm', - createdAt: at, - archivedAt: archivedAt, - updatedAt: at, - seq: seq, - ); + api.Group group({String id = 'g', DateTime? archivedAt, int seq = 1}) => + api.Group( + avatarKind: api.AvatarKind.initials, + avatarColor: null, + avatarEmoji: null, + avatarIcon: null, + avatarPhoto: null, + coverKind: api.CoverKind.generated, + coverPhoto: null, + id: id, + name: 'Goa', + defaultCurrency: 'INR', + isDirect: false, + simplifyDebts: true, + createdBy: 'm', + createdAt: at, + archivedAt: archivedAt, + updatedAt: at, + seq: seq, + ); api.Member member({String? upiVpa, DateTime? leftAt, int seq = 1}) => api.Member( @@ -47,33 +48,34 @@ void main() { seq: seq, ); - api.Profile profile({String? upiVpa, required DateTime updatedAt}) => - api.Profile( - avatarKind: api.AvatarKind.initials, - avatarColor: null, - avatarEmoji: null, - avatarIcon: null, - avatarPhoto: null, - id: 'p', - displayName: 'Ravi', - upiVpa: upiVpa, - updatedAt: updatedAt, - deletedAt: null, - ); + api.Profile profile({String? upiVpa, required int version}) => api.Profile( + avatarKind: api.AvatarKind.initials, + avatarColor: null, + avatarEmoji: null, + avatarIcon: null, + avatarPhoto: null, + id: 'p', + displayName: 'Ravi', + upiVpa: upiVpa, + updatedAt: at, + deletedAt: null, + version: version, + ); api.ChangePage page({ required int seq, required api.Group group, required api.Member member, required api.Profile profile, + List entries = const [], }) => api.ChangePage( - groupId: 'g', + groupId: group.id, seq: seq, hasMore: false, group: group, members: [member], profiles: [profile], - entries: const [], + entries: entries, events: const [], purgedAt: null, ); @@ -85,7 +87,7 @@ void main() { seq: 1, group: group(archivedAt: at), member: member(upiVpa: 'ravi@okaxis', leftAt: at), - profile: profile(upiVpa: 'ravi@okaxis', updatedAt: at), + profile: profile(upiVpa: 'ravi@okaxis', version: 1), ), now: at, ); @@ -96,7 +98,7 @@ void main() { seq: 2, group: group(seq: 2), member: member(seq: 2), - profile: profile(updatedAt: at.add(const Duration(minutes: 1))), + profile: profile(version: 2), ), now: at, ); @@ -109,4 +111,62 @@ void main() { expect(ravi.upiVpa, isNull, reason: 'the group handle was cleared'); expect(account.upiVpa, isNull, reason: 'the account handle was cleared'); }); + + /// Ids are minted on devices and checked by each group's server only within + /// that group, so another group can name a row this device already holds. + test('a row of another group is neither moved nor named', () async { + await applyGroupChanges( + db, + page( + seq: 1, + group: group(), + member: member(), + profile: profile(version: 1), + ), + now: at, + ); + + final elsewhere = page( + seq: 1, + group: group(id: 'h'), + member: member(), + profile: profile(version: 1), + entries: [ + api.Entry( + id: 'e', + kind: api.EntryKind.expense, + description: 'Taken', + categoryId: null, + currency: 'INR', + amountMinor: 100, + entryDate: '2026-09-27', + occurredAt: null, + timeZone: null, + splitKind: api.SplitKind.exact, + fxRate: null, + fxSource: null, + fxAt: null, + notes: null, + createdBy: 'm', + createdAt: at, + updatedAt: at, + deletedAt: null, + seq: 1, + payers: [api.Payer(memberId: 'm', amountMinor: 100)], + shares: [ + api.Share(memberId: 'm', amountMinor: 100, weightMicros: null), + ], + ), + ], + ); + await applyGroupChanges(db, elsewhere, now: at); + + final ravi = await db.select(db.members).getSingle(); + expect(ravi.groupId, 'g', reason: 'still in the group it came from'); + expect(await db.select(db.entries).get(), isEmpty); + final cursor = await (db.select( + db.groupCursors, + )..where((t) => t.groupId.equals('h'))).getSingle(); + expect(cursor.seq, 1, reason: 'the rest of the page still lands'); + }); } diff --git a/test/data/cloudflare_integration_test.dart b/test/data/cloudflare_integration_test.dart index f9ea025..108e972 100644 --- a/test/data/cloudflare_integration_test.dart +++ b/test/data/cloudflare_integration_test.dart @@ -155,7 +155,7 @@ void main() { reason: 'what gates the destructive account actions', ); expect( - (await fetch(client.getSyncApi().listGroups())).groupIds, + await groupsOf(client), isEmpty, reason: 'a brand-new account belongs to nothing', ); @@ -355,9 +355,7 @@ void main() { ); await sync.syncGroup(g.groupId); - final page = await fetch( - client.getSyncApi().getChanges(groupId: g.groupId), - ); + final page = await changesOf(client, g.groupId); final recorded = page.events .lastWhere((event) => event.subjectId == entry.id) .entry; @@ -365,7 +363,7 @@ void main() { expect(snapshotOf(local).toJson(), recorded?.toJson()); }); - test('a stale edit is refused only when it moves money', () async { + test('a stale edit is refused, whatever it changes', () async { if (!backendUp) return; final g = await seeded(); @@ -418,20 +416,22 @@ void main() { ), ); - // The same stale base, leaving the money exactly where the server has it, - // is not refused: arbitrating a typo would cost two people a decision for - // nothing. + // The same stale base is refused for a rename too: the write carries + // every field, so applying it would silently undo whatever else changed. final moved = at(150000); - final prose = await push( - moved.copyWith( - row: moved.row.copyWith( - description: 'Renamed', - seq: Value(base.row.seq), + await expectLater( + push( + moved.copyWith( + row: moved.row.copyWith( + description: 'Renamed', + seq: Value(base.row.seq), + ), ), ), + throwsA( + isA().having((e) => e.retry, 'retry', api.Retry.stale), + ), ); - expect(prose.description, 'Renamed'); - expect(prose.amountMinor, 150000); }); test('deleting carries the exact version, and propagates', () async { @@ -787,9 +787,7 @@ void main() { // And the name travelled the other way: a guest has none of its own, so // it adopts the one a friend typed on the placeholder. The group's own // page carries it, since the place is what made it visible. - final page = await fetch( - ravi.client.getSyncApi().getChanges(groupId: g.groupId), - ); + final page = await changesOf(ravi.client, g.groupId); expect( page.profiles.singleWhere((p) => p.id == priya.profileId).displayName, 'Priya', @@ -944,9 +942,7 @@ void main() { // Sharing a group is the whole of the rule, and it is symmetric: a // settle-up needs Ravi's handle exactly as much as it needs Priya's. - final seen = (await fetch( - stranger.client.getSyncApi().getChanges(groupId: g.groupId), - )).profiles; + final seen = (await changesOf(stranger.client, g.groupId)).profiles; expect( seen.singleWhere((p) => p.id == ravi.profileId).upiVpa, 'ravi@okhdfcbank', @@ -1068,10 +1064,10 @@ void main() { ); expect(cleared.upiVpa, isNull); expect(cleared.displayName, 'Ravi K'); - expect(cleared.updatedAt, isNotNull, reason: 'the feed cursors on it'); + expect(cleared.version, greaterThan(0), reason: 'the feed cursors on it'); }); - test('the profile feed pages on the pair, not the timestamp', () async { + test('the profile feed pages on the version', () async { if (!backendUp) return; final g = await seededGroup(ravi); @@ -1099,7 +1095,7 @@ void main() { var guard = 0; while (page.hasMore && guard++ < 10) { page = await fetch( - ravi.client.getSyncApi().getProfiles(after: page.cursor, limit: 1), + ravi.client.getSyncApi().getProfiles(since: page.seq, limit: 1), ); collected.addAll(page.profiles.map((row) => row.id)); } @@ -1147,12 +1143,11 @@ void main() { final report = await engine.syncEverything(); expect(report.isClean, isTrue, reason: '${report.error}'); - // Mine, pushed and read back with the server's timestamp on it — which - // is the value the feed cursors on, so a null here would mean the next - // sweep started from the beginning forever. + // Mine, pushed and read back with the server's version on it, which + // is what decides whether a copy arriving later is older. final mine = await profiles.byId(ravi.profileId); expect(mine?.upiVpa, 'ravi@okhdfcbank'); - expect(mine?.updatedAt, isNotNull); + expect(mine?.version, isNotNull); // And theirs, which this device never wrote. It arrived because they // share a group, which is the whole of the visibility rule. @@ -1196,13 +1191,7 @@ void main() { // Money Priya paid is a fact about Ravi's group as much as hers, so the // member row keeps its name and loses its account — exactly the state of // somebody a friend added who never signed up. - final page = await fetch( - ravi.client.getSyncApi().getChanges( - groupId: g.groupId, - since: 0, - limit: 200, - ), - ); + final page = await changesOf(ravi.client, g.groupId); final row = page.members.firstWhere((member) => member.id == g.priya); expect(row.displayName, 'Priya'); expect(row.profileId, isNull); @@ -1210,10 +1199,7 @@ void main() { // And the session went with it, rather than lingering until something // else happened to fail. expect(priya.auth.currentUser, isNull); - await expectLater( - fetch(priya.client.getSyncApi().listGroups()), - throwsA(isA()), - ); + await expectLater(groupsOf(priya.client), throwsA(isA())); }); test('a group nobody left could read is collected outright', () async { @@ -1227,13 +1213,7 @@ void main() { // Holding somebody's expense descriptions forever in a group with no // living reader is the opposite of what deleting an account asks for. final onlooker = await _Device.guest(); - final grave = await fetch( - onlooker.client.getSyncApi().getChanges( - groupId: g.groupId, - since: 0, - limit: 200, - ), - ); + final grave = await changesOf(onlooker.client, g.groupId); expect(grave.purgedAt, isNotNull); expect(grave.group, isNull); expect(grave.members, isEmpty); diff --git a/test/data/external_sync_visibility_test.dart b/test/data/external_sync_visibility_test.dart index c58c0a5..a5d72f8 100644 --- a/test/data/external_sync_visibility_test.dart +++ b/test/data/external_sync_visibility_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:drift/native.dart'; import 'package:opensplit/data/local/database.dart'; @@ -6,29 +7,65 @@ import 'package:test/test.dart'; import '../harness.dart'; +/// The push handler wakes in its own Flutter engine and writes through its +/// own connection to the same file, which Drift on this connection never sees. void main() { - test('external sync refresh re-runs live database queries', () async { - final db = AppDatabase(NativeDatabase.memory()); - await seedReferenceData(db); - addTearDown(db.close); + late Directory directory; + late AppDatabase app; + late AppDatabase handler; - final rows = StreamIterator(db.select(db.groups).watch()); + setUp(() async { + directory = await Directory.systemTemp.createTemp('opensplit'); + final file = File('${directory.path}/ledger.sqlite'); + app = AppDatabase(NativeDatabase(file)); + await seedReferenceData(app); + handler = AppDatabase(NativeDatabase(file)); + }); + + tearDown(() async { + await app.close(); + await handler.close(); + await directory.delete(recursive: true); + }); + + Future writeGroupFromHandler() => handler.customStatement( + 'INSERT INTO groups (id, name, default_currency, created_at, seq) ' + 'VALUES (?, ?, ?, ?, ?)', + ['g1', 'Goa Trip', 'INR', DateTime.utc(2026, 8, 31).toIso8601String(), 7], + ); + + test('a write from another connection re-runs live queries', () async { + final rows = StreamIterator(app.select(app.groups).watch()); addTearDown(rows.cancel); expect(await rows.moveNext(), isTrue); expect(rows.current, isEmpty); - // customStatement stands in for the second connection used by Android's - // background isolate: the file changed, but this connection did not issue - // the write through a Drift statement and therefore emitted no update. - await db.customStatement( - 'INSERT INTO groups (id, name, default_currency, created_at, seq) ' - 'VALUES (?, ?, ?, ?, ?)', - ['g1', 'Goa Trip', 'INR', DateTime.utc(2026, 8, 31).toIso8601String(), 7], - ); - - db.refreshAfterExternalSync(); + await writeGroupFromHandler(); + await app.noticeWritesElsewhere(); expect(await rows.moveNext(), isTrue); expect(rows.current.single.name, 'Goa Trip'); }); + + test('nothing is re-run when no other connection wrote', () async { + var emitted = 0; + final subscription = app + .select(app.groups) + .watch() + .listen((_) => emitted++); + addTearDown(subscription.cancel); + await pumpEventQueue(); + expect(emitted, 1); + + // A write of this connection's own, to a table the stream does not read: + // Drift already knows about these, and they leave data_version alone. + await app.customStatement( + "INSERT INTO fx_rates (as_of, currency, rate, source) " + "VALUES ('2026-09-01', 'EUR', 0.9, 'test')", + ); + await app.noticeWritesElsewhere(); + await pumpEventQueue(); + + expect(emitted, 1); + }); } diff --git a/test/data/generated_migrations/schema.dart b/test/data/generated_migrations/schema.dart index 3f1b776..e3e260c 100644 --- a/test/data/generated_migrations/schema.dart +++ b/test/data/generated_migrations/schema.dart @@ -13,6 +13,7 @@ import 'schema_v6.dart' as v6; import 'schema_v7.dart' as v7; import 'schema_v8.dart' as v8; import 'schema_v9.dart' as v9; +import 'schema_v10.dart' as v10; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -36,10 +37,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v8.DatabaseAtV8(db); case 9: return v9.DatabaseAtV9(db); + case 10: + return v10.DatabaseAtV10(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9]; + static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; } diff --git a/test/data/generated_migrations/schema_v10.dart b/test/data/generated_migrations/schema_v10.dart new file mode 100644 index 0000000..6629fa7 --- /dev/null +++ b/test/data/generated_migrations/schema_v10.dart @@ -0,0 +1,1663 @@ +// dart format width=80 +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Currencies extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Currencies(this.attachedDatabase, [this._alias]); + late final GeneratedColumn code = GeneratedColumn( + 'code', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn exponent = GeneratedColumn( + 'exponent', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn symbol = GeneratedColumn( + 'symbol', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [code, exponent, symbol, name]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'currencies'; + @override + Set get $primaryKey => {code}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Currencies createAlias(String alias) { + return Currencies(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(code)']; + @override + bool get dontWriteConstraints => true; +} + +class Groups extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Groups(this.attachedDatabase, [this._alias]); + late final GeneratedColumn avatarKind = GeneratedColumn( + 'avatar_kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'initials\'', + defaultValue: const CustomExpression('\'initials\''), + ); + late final GeneratedColumn avatarColor = GeneratedColumn( + 'avatar_color', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn avatarEmoji = GeneratedColumn( + 'avatar_emoji', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn avatarIcon = GeneratedColumn( + 'avatar_icon', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn avatarPhoto = GeneratedColumn( + 'avatar_photo', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn defaultCurrency = GeneratedColumn( + 'default_currency', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES currencies(code)', + ); + late final GeneratedColumn isDirect = GeneratedColumn( + 'is_direct', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_direct IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn simplifyDebts = GeneratedColumn( + 'simplify_debts', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1 CHECK (simplify_debts IN (0, 1))', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn coverKind = GeneratedColumn( + 'cover_kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'generated\'', + defaultValue: const CustomExpression('\'generated\''), + ); + late final GeneratedColumn coverPhoto = GeneratedColumn( + 'cover_photo', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn createdBy = GeneratedColumn( + 'created_by', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn archivedAt = GeneratedColumn( + 'archived_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn seq = GeneratedColumn( + 'seq', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + avatarKind, + avatarColor, + avatarEmoji, + avatarIcon, + avatarPhoto, + id, + name, + defaultCurrency, + isDirect, + simplifyDebts, + coverKind, + coverPhoto, + createdBy, + createdAt, + archivedAt, + seq, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'groups'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Groups createAlias(String alias) { + return Groups(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Entries extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Entries(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn groupId = GeneratedColumn( + 'group_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES "groups"(id)ON DELETE CASCADE', + ); + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'\'', + defaultValue: const CustomExpression('\'\''), + ); + late final GeneratedColumn categoryId = GeneratedColumn( + 'category_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn currency = GeneratedColumn( + 'currency', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn amountMinor = GeneratedColumn( + 'amount_minor', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn entryDate = GeneratedColumn( + 'entry_date', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn occurredAt = GeneratedColumn( + 'occurred_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn timeZone = GeneratedColumn( + 'time_zone', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn splitKind = GeneratedColumn( + 'split_kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn fxRate = GeneratedColumn( + 'fx_rate', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn fxSource = GeneratedColumn( + 'fx_source', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn fxAt = GeneratedColumn( + 'fx_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn createdBy = GeneratedColumn( + 'created_by', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn seq = GeneratedColumn( + 'seq', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + groupId, + kind, + description, + categoryId, + currency, + amountMinor, + entryDate, + occurredAt, + timeZone, + splitKind, + fxRate, + fxSource, + fxAt, + notes, + createdBy, + createdAt, + seq, + deletedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'entries'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Entries createAlias(String alias) { + return Entries(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class EntriesFts extends Table with TableInfo, VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + EntriesFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [description, notes]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'entries_fts'; + @override + Set get $primaryKey => const {}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + EntriesFts createAlias(String alias) { + return EntriesFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(description, notes, content=entries, content_rowid=rowid)'; +} + +class Profiles extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Profiles(this.attachedDatabase, [this._alias]); + late final GeneratedColumn avatarKind = GeneratedColumn( + 'avatar_kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'initials\'', + defaultValue: const CustomExpression('\'initials\''), + ); + late final GeneratedColumn avatarColor = GeneratedColumn( + 'avatar_color', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn avatarEmoji = GeneratedColumn( + 'avatar_emoji', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn avatarIcon = GeneratedColumn( + 'avatar_icon', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn avatarPhoto = GeneratedColumn( + 'avatar_photo', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn upiVpa = GeneratedColumn( + 'upi_vpa', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn version = GeneratedColumn( + 'version', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + avatarKind, + avatarColor, + avatarEmoji, + avatarIcon, + avatarPhoto, + id, + displayName, + upiVpa, + version, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'profiles'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Profiles createAlias(String alias) { + return Profiles(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Members extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Members(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn groupId = GeneratedColumn( + 'group_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES "groups"(id)ON DELETE CASCADE', + ); + late final GeneratedColumn profileId = GeneratedColumn( + 'profile_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn joinedAt = GeneratedColumn( + 'joined_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn leftAt = GeneratedColumn( + 'left_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn upiVpa = GeneratedColumn( + 'upi_vpa', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn seq = GeneratedColumn( + 'seq', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + groupId, + profileId, + displayName, + joinedAt, + leftAt, + upiVpa, + seq, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'members'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Members createAlias(String alias) { + return Members(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Categories extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Categories(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn icon = GeneratedColumn( + 'icon', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [id, name, icon]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'categories'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Categories createAlias(String alias) { + return Categories(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class EntryPayers extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + EntryPayers(this.attachedDatabase, [this._alias]); + late final GeneratedColumn entryId = GeneratedColumn( + 'entry_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES entries(id)ON DELETE CASCADE', + ); + late final GeneratedColumn memberId = GeneratedColumn( + 'member_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES members(id)', + ); + late final GeneratedColumn amountMinor = GeneratedColumn( + 'amount_minor', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [entryId, memberId, amountMinor]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'entry_payers'; + @override + Set get $primaryKey => {entryId, memberId}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + EntryPayers createAlias(String alias) { + return EntryPayers(attachedDatabase, alias); + } + + @override + List get customConstraints => const [ + 'PRIMARY KEY(entry_id, member_id)', + ]; + @override + bool get dontWriteConstraints => true; +} + +class EntryShares extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + EntryShares(this.attachedDatabase, [this._alias]); + late final GeneratedColumn entryId = GeneratedColumn( + 'entry_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES entries(id)ON DELETE CASCADE', + ); + late final GeneratedColumn memberId = GeneratedColumn( + 'member_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES members(id)', + ); + late final GeneratedColumn amountMinor = GeneratedColumn( + 'amount_minor', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn weightMicros = GeneratedColumn( + 'weight_micros', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + entryId, + memberId, + amountMinor, + weightMicros, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'entry_shares'; + @override + Set get $primaryKey => {entryId, memberId}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + EntryShares createAlias(String alias) { + return EntryShares(attachedDatabase, alias); + } + + @override + List get customConstraints => const [ + 'PRIMARY KEY(entry_id, member_id)', + ]; + @override + bool get dontWriteConstraints => true; +} + +class GroupEvents extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GroupEvents(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn groupId = GeneratedColumn( + 'group_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES "groups"(id)ON DELETE CASCADE', + ); + late final GeneratedColumn actorId = GeneratedColumn( + 'actor_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL REFERENCES members(id)ON DELETE CASCADE', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn subjectId = GeneratedColumn( + 'subject_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn entry = GeneratedColumn( + 'entry', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn member = GeneratedColumn( + 'member', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn group = GeneratedColumn( + 'group', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn link = GeneratedColumn( + 'link', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn seq = GeneratedColumn( + 'seq', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn ordinal = GeneratedColumn( + 'ordinal', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn isProvisional = GeneratedColumn( + 'is_provisional', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_provisional IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + id, + groupId, + actorId, + createdAt, + kind, + subjectId, + entry, + member, + group, + link, + seq, + ordinal, + isProvisional, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'group_events'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + GroupEvents createAlias(String alias) { + return GroupEvents(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class EntryConflicts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + EntryConflicts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn entryId = GeneratedColumn( + 'entry_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES entries(id)ON DELETE CASCADE', + ); + late final GeneratedColumn groupId = GeneratedColumn( + 'group_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES "groups"(id)ON DELETE CASCADE', + ); + late final GeneratedColumn attempted = GeneratedColumn( + 'attempted', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn baseSeq = GeneratedColumn( + 'base_seq', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn rejectedAt = GeneratedColumn( + 'rejected_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + entryId, + groupId, + attempted, + baseSeq, + rejectedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'entry_conflicts'; + @override + Set get $primaryKey => {entryId}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + EntryConflicts createAlias(String alias) { + return EntryConflicts(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(entry_id)']; + @override + bool get dontWriteConstraints => true; +} + +class FxRates extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + FxRates(this.attachedDatabase, [this._alias]); + late final GeneratedColumn asOf = GeneratedColumn( + 'as_of', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn currency = GeneratedColumn( + 'currency', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn rate = GeneratedColumn( + 'rate', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [asOf, currency, rate, source]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'fx_rates'; + @override + Set get $primaryKey => {asOf, currency}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + FxRates createAlias(String alias) { + return FxRates(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(as_of, currency)']; + @override + bool get dontWriteConstraints => true; +} + +class Outbox extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Outbox(this.attachedDatabase, [this._alias]); + late final GeneratedColumn target = GeneratedColumn( + 'target', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn targetId = GeneratedColumn( + 'target_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn revision = GeneratedColumn( + 'revision', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn position = GeneratedColumn( + 'position', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn attempts = GeneratedColumn( + 'attempts', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn nextAttemptAt = GeneratedColumn( + 'next_attempt_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn lastError = GeneratedColumn( + 'last_error', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn deadLetteredAt = GeneratedColumn( + 'dead_lettered_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + target, + targetId, + revision, + position, + attempts, + nextAttemptAt, + lastError, + deadLetteredAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'outbox'; + @override + Set get $primaryKey => {target, targetId}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Outbox createAlias(String alias) { + return Outbox(attachedDatabase, alias); + } + + @override + List get customConstraints => const [ + 'PRIMARY KEY(target, target_id)', + ]; + @override + bool get dontWriteConstraints => true; +} + +class GroupCursors extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GroupCursors(this.attachedDatabase, [this._alias]); + late final GeneratedColumn groupId = GeneratedColumn( + 'group_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES "groups"(id)ON DELETE CASCADE', + ); + late final GeneratedColumn seq = GeneratedColumn( + 'seq', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn lastSyncedAt = GeneratedColumn( + 'last_synced_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [groupId, seq, lastSyncedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'group_cursors'; + @override + Set get $primaryKey => {groupId}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + GroupCursors createAlias(String alias) { + return GroupCursors(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(group_id)']; + @override + bool get dontWriteConstraints => true; +} + +class FeedCursors extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + FeedCursors(this.attachedDatabase, [this._alias]); + late final GeneratedColumn feed = GeneratedColumn( + 'feed', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn cursor = GeneratedColumn( + 'cursor', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn lastSyncedAt = GeneratedColumn( + 'last_synced_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [feed, cursor, lastSyncedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'feed_cursors'; + @override + Set get $primaryKey => {feed}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + FeedCursors createAlias(String alias) { + return FeedCursors(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(feed)']; + @override + bool get dontWriteConstraints => true; +} + +class SyncLeases extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + SyncLeases(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn owner = GeneratedColumn( + 'owner', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn expiresAt = GeneratedColumn( + 'expires_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [name, owner, expiresAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sync_leases'; + @override + Set get $primaryKey => {name}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + SyncLeases createAlias(String alias) { + return SyncLeases(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(name)']; + @override + bool get dontWriteConstraints => true; +} + +class SyncSessions extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + SyncSessions(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn epoch = GeneratedColumn( + 'epoch', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn enabled = GeneratedColumn( + 'enabled', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL CHECK (enabled IN (0, 1))', + ); + @override + List get $columns => [id, epoch, enabled]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sync_sessions'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + SyncSessions createAlias(String alias) { + return SyncSessions(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class DatabaseAtV10 extends GeneratedDatabase { + DatabaseAtV10(QueryExecutor e) : super(e); + late final Currencies currencies = Currencies(this); + late final Groups groups = Groups(this); + late final Entries entries = Entries(this); + late final EntriesFts entriesFts = EntriesFts(this); + late final Profiles profiles = Profiles(this); + late final Members members = Members(this); + late final Categories categories = Categories(this); + late final EntryPayers entryPayers = EntryPayers(this); + late final EntryShares entryShares = EntryShares(this); + late final GroupEvents groupEvents = GroupEvents(this); + late final EntryConflicts entryConflicts = EntryConflicts(this); + late final FxRates fxRates = FxRates(this); + late final Outbox outbox = Outbox(this); + late final GroupCursors groupCursors = GroupCursors(this); + late final FeedCursors feedCursors = FeedCursors(this); + late final SyncLeases syncLeases = SyncLeases(this); + late final SyncSessions syncSessions = SyncSessions(this); + late final Index groupEventsGroup = Index( + 'group_events_group', + 'CREATE INDEX group_events_group ON group_events (group_id, seq, ordinal)', + ); + late final Index groupEventsSubject = Index( + 'group_events_subject', + 'CREATE INDEX group_events_subject ON group_events (subject_id)', + ); + late final Index groupEventsActor = Index( + 'group_events_actor', + 'CREATE INDEX group_events_actor ON group_events (actor_id)', + ); + late final Index membersGroup = Index( + 'members_group', + 'CREATE INDEX members_group ON members (group_id)', + ); + late final Index membersProfile = Index( + 'members_profile', + 'CREATE INDEX members_profile ON members (profile_id)', + ); + late final Index entriesGroup = Index( + 'entries_group', + 'CREATE INDEX entries_group ON entries (group_id, entry_date)', + ); + late final Index entryConflictsGroup = Index( + 'entry_conflicts_group', + 'CREATE INDEX entry_conflicts_group ON entry_conflicts (group_id)', + ); + late final Index entryPayersMember = Index( + 'entry_payers_member', + 'CREATE INDEX entry_payers_member ON entry_payers (member_id)', + ); + late final Index entrySharesMember = Index( + 'entry_shares_member', + 'CREATE INDEX entry_shares_member ON entry_shares (member_id)', + ); + late final Index fxRatesCurrency = Index( + 'fx_rates_currency', + 'CREATE INDEX fx_rates_currency ON fx_rates (currency, as_of)', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + currencies, + groups, + entries, + entriesFts, + profiles, + members, + categories, + entryPayers, + entryShares, + groupEvents, + entryConflicts, + fxRates, + outbox, + groupCursors, + feedCursors, + syncLeases, + syncSessions, + groupEventsGroup, + groupEventsSubject, + groupEventsActor, + membersGroup, + membersProfile, + entriesGroup, + entryConflictsGroup, + entryPayersMember, + entrySharesMember, + fxRatesCurrency, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'groups', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('entries', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'groups', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('members', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'entries', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('entry_payers', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'entries', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('entry_shares', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'groups', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('group_events', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'members', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('group_events', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'entries', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('entry_conflicts', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'groups', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('entry_conflicts', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'groups', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('group_cursors', kind: UpdateKind.delete)], + ), + ]); + @override + int get schemaVersion => 10; +} diff --git a/test/data/live_backend.dart b/test/data/live_backend.dart index eb13931..14a11cb 100644 --- a/test/data/live_backend.dart +++ b/test/data/live_backend.dart @@ -138,9 +138,41 @@ bool isEntryWrite(RequestOptions r) => r.method == 'PUT' && RegExp(r'/groups/[^/]+/entries/[^/]+$').hasMatch(r.path); -/// Matches the "which groups am I in" request. +/// Matches a pull, which also says which groups the account is in. bool isGroupList(RequestOptions r) => - r.method == 'GET' && r.path.endsWith('/api/groups'); + r.method == 'POST' && r.path.endsWith('/api/sync'); + +/// One group's page past [since], read the way the app reads it. +Future changesOf( + api.OpensplitApi client, + String groupId, { + int since = 0, + int limit = 200, +}) async { + final answer = await fetch( + client.getSyncApi().pull( + pullRequest: api.PullRequest( + groups: [api.GroupCursor(groupId: groupId, since: since)], + limit: limit, + ), + ), + ); + if (answer.refusals case [final refused, ...]) { + throw ApiFailure( + refused.message, + retry: api.Retry.permanent, + code: refused.code, + ); + } + return answer.pages.single; +} + +/// The groups the server says the account is in. +Future> groupsOf(api.OpensplitApi client) async => (await fetch( + client.getSyncApi().pull( + pullRequest: api.PullRequest(groups: const [], limit: 1), + ), +)).groupIds; /// One simulated device: its own local database, and a real session. class Device { diff --git a/test/data/outbox_test.dart b/test/data/outbox_test.dart index 94558db..15a0650 100644 --- a/test/data/outbox_test.dart +++ b/test/data/outbox_test.dart @@ -73,19 +73,67 @@ void main() { expect(await outbox.nextAttemptAt(), isNull); }); - test('re-dirtying a row keeps when it first went dirty', () async { - var at = DateTime.utc(2026, 8, 26); - final clocked = OutboxQueue( - db, - clock: () => at = at.add(const Duration(seconds: 1)), + group('the order changes are pushed in', () { + late DriftGroupRepository groups; + late DriftEntryRepository entries; + + setUp(() { + groups = DriftGroupRepository(db, outbox: outbox); + entries = DriftEntryRepository(db, outbox: outbox); + }); + + Future> order() async => [ + for (final item in await outbox.due()) item.target, + ]; + + /// The server checks a removal against the balances it holds, so the + /// settlement that makes it possible has to arrive first. + test('is the order they were made in', () async { + final made = await groups.createGroup( + name: 'Goa', + defaultCurrency: 'INR', + creatorDisplayName: 'Ravi', + ); + final priya = await groups.addMember(made.group.id, displayName: 'Priya'); + await _acknowledge(db); + + await groups.renameMember(priya.id, 'Priya S'); + await entries.create( + _draft(made.group.id, made.creator.id, priya.id), + createdBy: made.creator.id, + ); + await groups.removeMember(priya.id); + + expect(await order(), [OutboxTarget.entry, OutboxTarget.member]); + }); + + /// Until the server has a row, everything after its creation may name it. + test( + 'keeps a row the server has never seen where it was created', + () async { + final made = await groups.createGroup( + name: 'Goa', + defaultCurrency: 'INR', + creatorDisplayName: 'Ravi', + ); + final priya = await groups.addMember( + made.group.id, + displayName: 'Priya', + ); + await entries.create( + _draft(made.group.id, made.creator.id, priya.id), + createdBy: made.creator.id, + ); + await groups.renameMember(priya.id, 'Priya S'); + + expect(await order(), [ + OutboxTarget.group, + OutboxTarget.member, + OutboxTarget.member, + OutboxTarget.entry, + ]); + }, ); - addTearDown(clocked.dispose); - - await clocked.enqueue(OutboxTarget.group, 'g1'); - final first = (await clocked.due()).single.createdAt; - await clocked.enqueue(OutboxTarget.group, 'g1'); - - expect((await clocked.due()).single.createdAt, first); }); test('every enqueue announces itself', () async { @@ -141,3 +189,21 @@ void main() { expect(feed.single.isProvisional, isTrue); }); } + +/// Marks every row as one the server has, and empties the outbox, as a +/// successful push would. +Future _acknowledge(AppDatabase db) async { + await db.customStatement('UPDATE groups SET seq = 1'); + await db.customStatement('UPDATE members SET seq = 1'); + await db.customStatement('UPDATE entries SET seq = 1'); + await db.delete(db.outbox).go(); +} + +EntryDraft _draft(String groupId, String payer, String payee) => + EntryDraft.settlement( + groupId: groupId, + currency: 'INR', + amountMinor: 500, + fromMemberId: payer, + toMemberId: payee, + ); diff --git a/test/data/sync_test.dart b/test/data/sync_test.dart index 24c7a2b..92f8278 100644 --- a/test/data/sync_test.dart +++ b/test/data/sync_test.dart @@ -2,7 +2,9 @@ library; import 'dart:async'; +import 'dart:convert'; +import 'package:dio/dio.dart' show RequestOptions; import 'package:drift/drift.dart' show BooleanExpressionOperators, Value, driftRuntimeOptions; import 'package:opensplit/data/local/database.dart'; @@ -113,8 +115,14 @@ void main() { liveTest('a renamed row is corrected in place', () async { await start(); + await a.sync.syncEverything(); + // As if the server's list had changed since: the copy held is older, + // and so is the tag it was sent with. await (a.db.update(a.db.currencies)..where((t) => t.code.equals('INR'))) .write(const CurrenciesCompanion(name: Value('Stale name'))); + await (a.db.update(a.db.feedCursors) + ..where((t) => t.feed.equals('reference:etag'))) + .write(const FeedCursorsCompanion(cursor: Value('"older"'))); await a.sync.syncEverything(); final inr = await (a.db.select( @@ -122,6 +130,19 @@ void main() { )..where((t) => t.code.equals('INR'))).getSingle(); expect(inr.name, 'Indian Rupee'); }); + + liveTest('an unchanged list costs a 304 and no body', () async { + await start(); + await a.sync.syncEverything(); + a.tap.requests.clear(); + + final report = await a.sync.syncEverything(); + + final asked = a.tap.last('GET', '/api/reference'); + expect(asked?.headers['If-None-Match'], isNotNull); + expect(report.error, isNull); + expect(await a.sync.shared.hasReferenceData(), isTrue); + }); }); group('two devices converge', () { @@ -406,33 +427,48 @@ void main() { }, ); - liveTest('the last write wins, judged by the server', () async { - final g = await seedGroup(); - final entry = await a.entries.create( - draft(g, 100000, description: 'Original'), - createdBy: g.ravi, - ); - await a.sync.syncGroup(g.groupId); - await b.sync.syncGroup(g.groupId); + liveTest( + 'a second edit to one version is parked, not written over the first', + () async { + final g = await seedGroup(); + final entry = await a.entries.create( + draft(g, 100000, description: 'Original'), + createdBy: g.ravi, + ); + await a.sync.syncGroup(g.groupId); + await b.sync.syncGroup(g.groupId); - await a.entries.update( - entry.id, - draft(g, 100000, description: 'Edited on A'), - actorId: g.ravi, - ); - await b.entries.update( - entry.id, - draft(g, 100000, description: 'Edited on B'), - actorId: g.priya, - ); + await a.entries.update( + entry.id, + draft(g, 100000, description: 'Edited on A'), + actorId: g.ravi, + ); + await b.entries.update( + entry.id, + draft(g, 100000, description: 'Edited on B'), + actorId: g.priya, + ); - await a.sync.syncGroup(g.groupId); - await b.sync.syncGroup(g.groupId); // B pushes second, so B wins. - await a.sync.syncGroup(g.groupId); + await a.sync.syncGroup(g.groupId); + await b.sync.syncGroup( + g.groupId, + ); // Composed on the version A replaced. + await a.sync.syncGroup(g.groupId); - expect((await a.ledger(g.groupId)).single.row.description, 'Edited on B'); - expect((await b.ledger(g.groupId)).single.row.description, 'Edited on B'); - }); + expect( + (await a.ledger(g.groupId)).single.row.description, + 'Edited on A', + ); + expect( + (await b.ledger(g.groupId)).single.row.description, + 'Edited on A', + ); + final parked = (await DriftConflictRepository( + b.db, + ).watchAll().first).single; + expect(parked.attempted.description, 'Edited on B'); + }, + ); }); group('the server is the backstop', () { @@ -781,27 +817,27 @@ void main() { a.tap.last('GET', '/api/fx')?.queryParameters['since'] as String?; liveTest('arrive with sync and are then left alone', () async { - final g = await seedGroup(joined: false); + await seedGroup(joined: false); serveRates(a); publish('2026-08-20', 'USD', 1); publish('2026-08-20', 'INR', 95.43); - await a.sync.syncGroup(g.groupId); + await a.sync.syncEverything(); expect(await a.db.select(a.db.fxRates).get(), hasLength(2)); - await a.sync.syncGroup(g.groupId); + await a.sync.syncEverything(); expect(lastSince(), '2026-08-20', reason: 'from the newest date held'); expect(await a.db.select(a.db.fxRates).get(), hasLength(2)); }); liveTest('a later publication does not disturb the earlier one', () async { - final g = await seedGroup(joined: false); + await seedGroup(joined: false); serveRates(a); publish('2026-08-20', 'INR', 95.43); - await a.sync.syncGroup(g.groupId); + await a.sync.syncEverything(); publish('2026-08-21', 'INR', 95.70); - await a.sync.syncGroup(g.groupId); + await a.sync.syncEverything(); final stored = await a.db.select(a.db.fxRates).get() ..sort((x, y) => x.asOf.compareTo(y.asOf)); @@ -813,7 +849,7 @@ void main() { final g = await seedGroup(joined: false); serveRates(a); publish('2026-08-20', 'USD', 1); - await a.sync.syncGroup(g.groupId); + await a.sync.syncEverything(); expect(lastSince(), isNot('2024-03-11')); // In a currency that is not the group's own, so it needs a rate. @@ -828,7 +864,7 @@ void main() { createdBy: g.ravi, ); publish('2024-03-11', 'USD', 1); - await a.sync.syncGroup(g.groupId); + await a.sync.syncEverything(); expect(lastSince(), '2024-03-11'); expect( @@ -851,19 +887,19 @@ void main() { createdBy: g.ravi, ); - await a.sync.syncGroup(g.groupId); + await a.sync.syncEverything(); expect(lastSince(), '1999-01-01'); - await a.sync.syncGroup(g.groupId); + await a.sync.syncEverything(); expect(lastSince(), isNot('1999-01-01')); }); liveTest('a rate failure never fails a sync that carries money', () async { - final g = await seedGroup(joined: false); + await seedGroup(joined: false); a.tap.before = (request) async { if (request.path.endsWith('/api/fx')) throw offline(request); }; - final report = await a.sync.syncGroup(g.groupId); + final report = await a.sync.syncEverything(); expect(report.isClean, isTrue, reason: '$report'); }); }); @@ -1047,15 +1083,18 @@ void main() { liveTest('the cursor stops it re-fetching what it already has', () async { await start(); - String? after() => - a.tap.last('GET', '/api/profiles')?.queryParameters['after'] - as String?; + int? since() => + a.tap.last('GET', '/api/profiles')?.queryParameters['since'] as int?; await a.sync.shared.pullProfiles(); - expect(after(), isNull, reason: 'the first pull asks for everything'); + expect(since(), 0, reason: 'the first pull asks for everything'); await a.sync.shared.pullProfiles(); - expect(after(), isNotNull, reason: 'the second asks only for changes'); + expect( + since(), + greaterThan(0), + reason: 'the second asks only for changes', + ); }); }); @@ -1287,24 +1326,21 @@ void main() { ); }); - liveTest('an edit that moves no money is simply applied', () async { - final (:g, entryId: _) = await divergent( - raviAmount: 45000, + /// The write replaces the whole expense, so applying a stale one that + /// only renames it would put B's old amount back. + liveTest('an edit that moves no money is parked too', () async { + final (:g, :entryId) = await divergent( + raviAmount: 30000, raviDescription: 'Dinner at Toit', priyaAmount: 45000, ); await a.sync.syncGroup(g.groupId); - expect(await DriftConflictRepository(a.db).watchAll().first, isEmpty); - expect( - (await a.ledger(g.groupId)).single.row.description, - 'Dinner at Toit', - ); + final parked = await DriftConflictRepository(a.db).byEntry(entryId); + expect(parked?.attempted.description, 'Dinner at Toit'); + expect((await a.ledger(g.groupId)).single.row.amountMinor, 45000); await b.sync.syncGroup(g.groupId); - expect( - (await b.ledger(g.groupId)).single.row.description, - 'Dinner at Toit', - ); + expect((await b.ledger(g.groupId)).single.row.amountMinor, 45000); }); liveTest('editing it again lands, and clears the notice', () async { @@ -1390,12 +1426,11 @@ void main() { await a.sync.syncGroup(g.groupId); // An expense and its history line share a number, and so a page. - final firstPage = await fetch( - a.client.getSyncApi().getChanges( - groupId: g.groupId, - since: beforeExpenses, - limit: 1, - ), + final firstPage = await changesOf( + a.client, + g.groupId, + since: beforeExpenses, + limit: 1, ); expect(firstPage.entries, hasLength(1)); expect(firstPage.events.map((event) => event.seq), [ @@ -1419,4 +1454,153 @@ void main() { expect((await device.sync.syncGroup(g.groupId)).pulled, 0); }); }); + + group('what a sync asks for', () { + /// The groups a pull asked the server for. + List pulled(RequestOptions request) => [ + for (final cursor + in (jsonDecode(request.data as String) as Map)['groups'] as List) + (cursor as Map)['groupId'] as String, + ]; + + liveTest( + 'a local write sends, and reads back only its own group', + () async { + final g = await seedGroup(); + await a.groups.createGroup( + name: 'Flat', + defaultCurrency: 'INR', + creatorDisplayName: 'Ravi', + creatorProfileId: a.profileId, + ); + await a.sync.syncEverything(); + a.tap.requests.clear(); + + await a.entries.create(draft(g, 500), createdBy: g.ravi); + final report = await a.sync.syncGroups(const {}); + + expect(report.isClean, isTrue, reason: '$report'); + for (final feed in ['/api/reference', '/api/fx', '/api/profiles']) { + expect(a.tap.count('GET', feed), 0, reason: feed); + } + expect(a.tap.requests.where(isGroupList).map(pulled), [ + [g.groupId], + ]); + }, + ); + + liveTest('every group goes out in one pull', () async { + final g = await seedGroup(); + for (final name in ['Flat', 'Office']) { + await a.groups.createGroup( + name: name, + defaultCurrency: 'INR', + creatorDisplayName: 'Ravi', + creatorProfileId: a.profileId, + ); + } + await a.sync.syncEverything(); + a.tap.requests.clear(); + + await a.sync.syncEverything(); + + final pulls = a.tap.requests.where(isGroupList).toList(); + expect(pulls, hasLength(1)); + expect(pulled(pulls.single), hasLength(3)); + expect(pulled(pulls.single), contains(g.groupId)); + }); + + /// The server judges the removal against the balances it holds, so the + /// settlement made first on this device has to reach it first. + liveTest( + 'settling up and then removing somebody, offline, both land', + () async { + final g = await seedGroup(); + await a.entries.create( + draft(g, 900, between: [g.ravi, g.arun]), + createdBy: g.ravi, + ); + await a.sync.syncGroup(g.groupId); + + await a.entries.create( + EntryDraft.settlement( + groupId: g.groupId, + currency: 'INR', + amountMinor: 450, + fromMemberId: g.arun, + toMemberId: g.ravi, + ), + createdBy: g.ravi, + ); + await a.groups.removeMember(g.arun); + final report = await a.sync.syncGroup(g.groupId); + + expect(report.failed, 0, reason: '$report'); + expect(await a.outbox.watchDeadLetters().first, isEmpty); + await b.sync.syncGroup(g.groupId); + final arun = await (b.db.select( + b.db.members, + )..where((t) => t.id.equals(g.arun))).getSingle(); + expect(arun.leftAt, isNotNull); + }, + ); + + liveTest( + 'a group the server has not taken yet does not stop the rest', + () async { + final g = await seedGroup(); + await b.entries.create(draft(g, 700), createdBy: g.priya); + await b.sync.syncGroup(g.groupId); + + final offline = await a.groups.createGroup( + name: 'Flat', + defaultCurrency: 'INR', + creatorDisplayName: 'Ravi', + creatorProfileId: a.profileId, + ); + a.tap.before = (request) async { + if (request.method == 'PUT' && + request.path.endsWith('/groups/${offline.group.id}')) { + throw refused(request, status: 503, retry: api.Retry.transient); + } + }; + + final report = await a.sync.syncEverything(); + + expect(report.error, isNull, reason: '$report'); + expect(report.failed, 1, reason: 'the creation waits for a retry'); + expect((await a.ledger(g.groupId)).map((e) => e.row.amountMinor), [ + 700, + ]); + expect( + a.tap.requests.where(isGroupList).expand(pulled), + isNot(contains(offline.group.id)), + ); + }, + ); + + liveTest('a group the server refuses does not stop the rest', () async { + final g = await seedGroup(); + await b.entries.create(draft(g, 700), createdBy: g.priya); + await b.sync.syncGroup(g.groupId); + + // A copy the server has no group for, as a reset backend would leave. + await a.db + .into(a.db.groups) + .insert( + GroupsCompanion.insert( + id: '00000000-0000-4000-8000-00000000dead', + name: 'Gone', + defaultCurrency: 'INR', + createdAt: DateTime.utc(2026, 1, 1), + seq: const Value(4), + ), + ); + + final report = await a.sync.syncEverything(); + + expect(report.error, isNull, reason: '$report'); + expect((await a.ledger(g.groupId)).map((e) => e.row.amountMinor), [700]); + }); + }); } diff --git a/test/presentation/unsynced_banner_test.dart b/test/presentation/unsynced_banner_test.dart index ead79cd..55c0516 100644 --- a/test/presentation/unsynced_banner_test.dart +++ b/test/presentation/unsynced_banner_test.dart @@ -93,7 +93,7 @@ Future _deadLetter( target: OutboxTarget.entry, revision: 'r1', targetId: entryId, - createdAt: DateTime.utc(2026, 8, 21), + position: 1, lastError: Value(error), deadLetteredAt: Value(DateTime.utc(2026, 8, 21)), ), From 343b7f33af49a860c8936720f48b14b46cfe2cf6 Mon Sep 17 00:00:00 2001 From: Seenu K Date: Mon, 5 Oct 2026 02:28:29 +0530 Subject: [PATCH 3/9] Squash migrations and remove backward compatibility logic - Removed fallback default values for schema migrations - Created single pristine generated migrations for D1 and DOs - Simplified DO upkeep logic assuming fresh deployment - Deleted all old migration files since this is early dev --- .../20261002071250_nifty_rhino/snapshot.json | 897 ----------- .../migration.sql | 12 - .../migration.sql | 8 +- .../snapshot.json | 4 +- server/src/db/group/schema.ts | 2 +- .../snapshot.json | 1369 ----------------- .../migration.sql | 1 - .../migration.sql | 1 + .../snapshot.json | 6 +- server/src/do/group/migrations/migrations.js | 6 +- server/src/do/group/roster.ts | 1 + server/src/do/group/upkeep.ts | 2 +- 12 files changed, 18 insertions(+), 2291 deletions(-) delete mode 100644 server/migrations/20261002071250_nifty_rhino/snapshot.json delete mode 100644 server/migrations/20261003013707_profile_versions/migration.sql rename server/migrations/{20261002071250_nifty_rhino => 20261004205652_dark_maximus}/migration.sql (94%) rename server/migrations/{20261003013707_profile_versions => 20261004205652_dark_maximus}/snapshot.json (99%) delete mode 100644 server/src/do/group/migrations/20261002071250_fair_hemingway/snapshot.json delete mode 100644 server/src/do/group/migrations/20261004204449_wooden_phil_sheldon/migration.sql rename server/src/do/group/migrations/{20261002071250_fair_hemingway => 20261004205652_glorious_the_spike}/migration.sql (99%) rename server/src/do/group/migrations/{20261004204449_wooden_phil_sheldon => 20261004205652_glorious_the_spike}/snapshot.json (99%) diff --git a/server/migrations/20261002071250_nifty_rhino/snapshot.json b/server/migrations/20261002071250_nifty_rhino/snapshot.json deleted file mode 100644 index 5cc0165..0000000 --- a/server/migrations/20261002071250_nifty_rhino/snapshot.json +++ /dev/null @@ -1,897 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "id": "6ac080d4-f4b5-4906-880a-e81a1df1e0e1", - "prevIds": [ - "00000000-0000-0000-0000-000000000000" - ], - "ddl": [ - { - "name": "device_tokens", - "entityType": "tables" - }, - { - "name": "link_tokens", - "entityType": "tables" - }, - { - "name": "memberships", - "entityType": "tables" - }, - { - "name": "profiles", - "entityType": "tables" - }, - { - "name": "purged_groups", - "entityType": "tables" - }, - { - "name": "account", - "entityType": "tables" - }, - { - "name": "session", - "entityType": "tables" - }, - { - "name": "user", - "entityType": "tables" - }, - { - "name": "verification", - "entityType": "tables" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "token", - "entityType": "columns", - "table": "device_tokens" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "profile_id", - "entityType": "columns", - "table": "device_tokens" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "platform", - "entityType": "columns", - "table": "device_tokens" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "updated_at", - "entityType": "columns", - "table": "device_tokens" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "token", - "entityType": "columns", - "table": "link_tokens" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "group_id", - "entityType": "columns", - "table": "link_tokens" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "kind", - "entityType": "columns", - "table": "link_tokens" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "created_at", - "entityType": "columns", - "table": "link_tokens" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "profile_id", - "entityType": "columns", - "table": "memberships" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "group_id", - "entityType": "columns", - "table": "memberships" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "left_at", - "entityType": "columns", - "table": "memberships" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "updated_at", - "entityType": "columns", - "table": "memberships" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "version", - "entityType": "columns", - "table": "memberships" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "profiles" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "display_name", - "entityType": "columns", - "table": "profiles" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "upi_vpa", - "entityType": "columns", - "table": "profiles" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": "'initials'", - "generated": null, - "name": "avatar_kind", - "entityType": "columns", - "table": "profiles" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "avatar_color", - "entityType": "columns", - "table": "profiles" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "avatar_emoji", - "entityType": "columns", - "table": "profiles" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "avatar_icon", - "entityType": "columns", - "table": "profiles" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "avatar_photo", - "entityType": "columns", - "table": "profiles" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "updated_at", - "entityType": "columns", - "table": "profiles" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "deleted_at", - "entityType": "columns", - "table": "profiles" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "group_id", - "entityType": "columns", - "table": "purged_groups" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "purged_at", - "entityType": "columns", - "table": "purged_groups" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "account_id", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "provider_id", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "user_id", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "access_token", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "refresh_token", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id_token", - "entityType": "columns", - "table": "account" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "access_token_expires_at", - "entityType": "columns", - "table": "account" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "refresh_token_expires_at", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "scope", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "password", - "entityType": "columns", - "table": "account" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "(cast(unixepoch('subsecond') * 1000 as integer))", - "generated": null, - "name": "created_at", - "entityType": "columns", - "table": "account" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "updated_at", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "expires_at", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "token", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "(cast(unixepoch('subsecond') * 1000 as integer))", - "generated": null, - "name": "created_at", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "updated_at", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "ip_address", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "user_agent", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "user_id", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "user" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "name", - "entityType": "columns", - "table": "user" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "email", - "entityType": "columns", - "table": "user" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "false", - "generated": null, - "name": "email_verified", - "entityType": "columns", - "table": "user" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "image", - "entityType": "columns", - "table": "user" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "(cast(unixepoch('subsecond') * 1000 as integer))", - "generated": null, - "name": "created_at", - "entityType": "columns", - "table": "user" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "(cast(unixepoch('subsecond') * 1000 as integer))", - "generated": null, - "name": "updated_at", - "entityType": "columns", - "table": "user" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": "false", - "generated": null, - "name": "is_anonymous", - "entityType": "columns", - "table": "user" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "verification" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "identifier", - "entityType": "columns", - "table": "verification" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "value", - "entityType": "columns", - "table": "verification" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "expires_at", - "entityType": "columns", - "table": "verification" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "(cast(unixepoch('subsecond') * 1000 as integer))", - "generated": null, - "name": "created_at", - "entityType": "columns", - "table": "verification" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "(cast(unixepoch('subsecond') * 1000 as integer))", - "generated": null, - "name": "updated_at", - "entityType": "columns", - "table": "verification" - }, - { - "columns": [ - "user_id" - ], - "tableTo": "user", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_account_user_id_user_id_fk", - "entityType": "fks", - "table": "account" - }, - { - "columns": [ - "user_id" - ], - "tableTo": "user", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_user_id_user_id_fk", - "entityType": "fks", - "table": "session" - }, - { - "columns": [ - "profile_id", - "group_id" - ], - "nameExplicit": false, - "name": "memberships_pk", - "entityType": "pks", - "table": "memberships" - }, - { - "columns": [ - "token" - ], - "nameExplicit": false, - "name": "device_tokens_pk", - "table": "device_tokens", - "entityType": "pks" - }, - { - "columns": [ - "token" - ], - "nameExplicit": false, - "name": "link_tokens_pk", - "table": "link_tokens", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "profiles_pk", - "table": "profiles", - "entityType": "pks" - }, - { - "columns": [ - "group_id" - ], - "nameExplicit": false, - "name": "purged_groups_pk", - "table": "purged_groups", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "account_pk", - "table": "account", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "session_pk", - "table": "session", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "user_pk", - "table": "user", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "verification_pk", - "table": "verification", - "entityType": "pks" - }, - { - "columns": [ - { - "value": "profile_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "device_tokens_profile", - "entityType": "indexes", - "table": "device_tokens" - }, - { - "columns": [ - { - "value": "group_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "link_tokens_group", - "entityType": "indexes", - "table": "link_tokens" - }, - { - "columns": [ - { - "value": "group_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "memberships_group", - "entityType": "indexes", - "table": "memberships" - }, - { - "columns": [ - { - "value": "updated_at", - "isExpression": false - }, - { - "value": "id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "profiles_updated", - "entityType": "indexes", - "table": "profiles" - }, - { - "columns": [ - { - "value": "user_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "account_userId_idx", - "entityType": "indexes", - "table": "account" - }, - { - "columns": [ - { - "value": "user_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_userId_idx", - "entityType": "indexes", - "table": "session" - }, - { - "columns": [ - { - "value": "identifier", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "verification_identifier_idx", - "entityType": "indexes", - "table": "verification" - }, - { - "columns": [ - "token" - ], - "nameExplicit": false, - "name": "session_token_unique", - "entityType": "uniques", - "table": "session" - }, - { - "columns": [ - "email" - ], - "nameExplicit": false, - "name": "user_email_unique", - "entityType": "uniques", - "table": "user" - }, - { - "value": "(\"avatar_kind\" = 'emoji') = (\"avatar_emoji\" is not null)", - "name": "profiles_avatar_emoji", - "entityType": "checks", - "table": "profiles" - }, - { - "value": "(\"avatar_kind\" = 'icon') = (\"avatar_icon\" is not null)", - "name": "profiles_avatar_icon", - "entityType": "checks", - "table": "profiles" - }, - { - "value": "(\"avatar_kind\" = 'photo') = (\"avatar_photo\" is not null)", - "name": "profiles_avatar_photo", - "entityType": "checks", - "table": "profiles" - } - ], - "renames": [] -} \ No newline at end of file diff --git a/server/migrations/20261003013707_profile_versions/migration.sql b/server/migrations/20261003013707_profile_versions/migration.sql deleted file mode 100644 index b4c75d9..0000000 --- a/server/migrations/20261003013707_profile_versions/migration.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE `counters` ( - `name` text PRIMARY KEY, - `value` integer NOT NULL -); ---> statement-breakpoint --- Existing rows are numbered in the order the old timestamp cursor read them; every write after --- this sets `version` itself, so the default is never relied on. -ALTER TABLE `profiles` ADD `version` integer NOT NULL DEFAULT 0;--> statement-breakpoint -UPDATE `profiles` SET `version` = (SELECT count(*) FROM `profiles` AS `earlier` WHERE (`earlier`.`updated_at`, `earlier`.`id`) <= (`profiles`.`updated_at`, `profiles`.`id`));--> statement-breakpoint -INSERT INTO `counters` (`name`, `value`) SELECT 'profiles', coalesce(max(`version`), 0) FROM `profiles`;--> statement-breakpoint -DROP INDEX IF EXISTS `profiles_updated`;--> statement-breakpoint -CREATE UNIQUE INDEX `profiles_version` ON `profiles` (`version`); diff --git a/server/migrations/20261002071250_nifty_rhino/migration.sql b/server/migrations/20261004205652_dark_maximus/migration.sql similarity index 94% rename from server/migrations/20261002071250_nifty_rhino/migration.sql rename to server/migrations/20261004205652_dark_maximus/migration.sql index 678c8a4..3fa2032 100644 --- a/server/migrations/20261002071250_nifty_rhino/migration.sql +++ b/server/migrations/20261004205652_dark_maximus/migration.sql @@ -1,3 +1,8 @@ +CREATE TABLE `counters` ( + `name` text PRIMARY KEY, + `value` integer NOT NULL +); +--> statement-breakpoint CREATE TABLE `device_tokens` ( `token` text PRIMARY KEY, `profile_id` text NOT NULL, @@ -32,6 +37,7 @@ CREATE TABLE `profiles` ( `avatar_photo` text, `updated_at` text NOT NULL, `deleted_at` text, + `version` integer NOT NULL, CONSTRAINT "profiles_avatar_emoji" CHECK(("avatar_kind" = 'emoji') = ("avatar_emoji" is not null)), CONSTRAINT "profiles_avatar_icon" CHECK(("avatar_kind" = 'icon') = ("avatar_icon" is not null)), CONSTRAINT "profiles_avatar_photo" CHECK(("avatar_kind" = 'photo') = ("avatar_photo" is not null)) @@ -94,7 +100,7 @@ CREATE TABLE `verification` ( CREATE INDEX `device_tokens_profile` ON `device_tokens` (`profile_id`);--> statement-breakpoint CREATE INDEX `link_tokens_group` ON `link_tokens` (`group_id`);--> statement-breakpoint CREATE INDEX `memberships_group` ON `memberships` (`group_id`);--> statement-breakpoint -CREATE INDEX `profiles_updated` ON `profiles` (`updated_at`,`id`);--> statement-breakpoint +CREATE UNIQUE INDEX `profiles_version` ON `profiles` (`version`);--> statement-breakpoint CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`); \ No newline at end of file diff --git a/server/migrations/20261003013707_profile_versions/snapshot.json b/server/migrations/20261004205652_dark_maximus/snapshot.json similarity index 99% rename from server/migrations/20261003013707_profile_versions/snapshot.json rename to server/migrations/20261004205652_dark_maximus/snapshot.json index ce070ab..d946fc2 100644 --- a/server/migrations/20261003013707_profile_versions/snapshot.json +++ b/server/migrations/20261004205652_dark_maximus/snapshot.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "e5a00bfa-2376-4aa5-a7e9-6bbca7d850e4", + "id": "d14dcdc6-1286-4f24-841b-0a76aea5c4f6", "prevIds": [ - "6ac080d4-f4b5-4906-880a-e81a1df1e0e1" + "00000000-0000-0000-0000-000000000000" ], "ddl": [ { diff --git a/server/src/db/group/schema.ts b/server/src/db/group/schema.ts index 8a5757e..b97837d 100644 --- a/server/src/db/group/schema.ts +++ b/server/src/db/group/schema.ts @@ -72,7 +72,7 @@ export const meta = sqliteTable( .notNull() .references(() => members.id), createdAt: text("created_at").notNull(), - lastActivityAt: integer("last_activity_at").notNull().default(0), + lastActivityAt: integer("last_activity_at").notNull(), archivedAt: text("archived_at"), updatedAt: text("updated_at").notNull(), seq: integer("seq").notNull(), diff --git a/server/src/do/group/migrations/20261002071250_fair_hemingway/snapshot.json b/server/src/do/group/migrations/20261002071250_fair_hemingway/snapshot.json deleted file mode 100644 index 9e0c3a8..0000000 --- a/server/src/do/group/migrations/20261002071250_fair_hemingway/snapshot.json +++ /dev/null @@ -1,1369 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "id": "f612090c-d005-4921-9693-cb1974b0d8db", - "prevIds": [ - "00000000-0000-0000-0000-000000000000" - ], - "ddl": [ - { - "name": "counter", - "entityType": "tables" - }, - { - "name": "entries", - "entityType": "tables" - }, - { - "name": "entry_payers", - "entityType": "tables" - }, - { - "name": "entry_shares", - "entityType": "tables" - }, - { - "name": "events", - "entityType": "tables" - }, - { - "name": "group_link", - "entityType": "tables" - }, - { - "name": "invites", - "entityType": "tables" - }, - { - "name": "members", - "entityType": "tables" - }, - { - "name": "meta", - "entityType": "tables" - }, - { - "name": "outbox", - "entityType": "tables" - }, - { - "name": "schedule", - "entityType": "tables" - }, - { - "name": "tombstone", - "entityType": "tables" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "name", - "entityType": "columns", - "table": "counter" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "value", - "entityType": "columns", - "table": "counter" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "entries" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": "'expense'", - "generated": null, - "name": "kind", - "entityType": "columns", - "table": "entries" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": "''", - "generated": null, - "name": "description", - "entityType": "columns", - "table": "entries" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "category_id", - "entityType": "columns", - "table": "entries" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "currency", - "entityType": "columns", - "table": "entries" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "amount_minor", - "entityType": "columns", - "table": "entries" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "entry_date", - "entityType": "columns", - "table": "entries" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "occurred_at", - "entityType": "columns", - "table": "entries" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_zone", - "entityType": "columns", - "table": "entries" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": "'equal'", - "generated": null, - "name": "split_kind", - "entityType": "columns", - "table": "entries" - }, - { - "type": "real", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "fx_rate", - "entityType": "columns", - "table": "entries" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "fx_source", - "entityType": "columns", - "table": "entries" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "fx_at", - "entityType": "columns", - "table": "entries" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "notes", - "entityType": "columns", - "table": "entries" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "created_by", - "entityType": "columns", - "table": "entries" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "created_at", - "entityType": "columns", - "table": "entries" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "updated_at", - "entityType": "columns", - "table": "entries" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "deleted_at", - "entityType": "columns", - "table": "entries" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "entries" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "entry_id", - "entityType": "columns", - "table": "entry_payers" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "member_id", - "entityType": "columns", - "table": "entry_payers" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "amount_minor", - "entityType": "columns", - "table": "entry_payers" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "entry_id", - "entityType": "columns", - "table": "entry_shares" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "member_id", - "entityType": "columns", - "table": "entry_shares" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "amount_minor", - "entityType": "columns", - "table": "entry_shares" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "weight_micros", - "entityType": "columns", - "table": "entry_shares" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "events" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "actor_id", - "entityType": "columns", - "table": "events" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "created_at", - "entityType": "columns", - "table": "events" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "kind", - "entityType": "columns", - "table": "events" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "subject_id", - "entityType": "columns", - "table": "events" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "entry", - "entityType": "columns", - "table": "events" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "member", - "entityType": "columns", - "table": "events" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "group", - "entityType": "columns", - "table": "events" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "link", - "entityType": "columns", - "table": "events" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "events" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "ordinal", - "entityType": "columns", - "table": "events" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "group_link" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "token", - "entityType": "columns", - "table": "group_link" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "created_by", - "entityType": "columns", - "table": "group_link" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "created_at", - "entityType": "columns", - "table": "group_link" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "expires_at", - "entityType": "columns", - "table": "group_link" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "revoked_at", - "entityType": "columns", - "table": "group_link" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "token", - "entityType": "columns", - "table": "invites" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "member_id", - "entityType": "columns", - "table": "invites" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "created_by", - "entityType": "columns", - "table": "invites" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "created_at", - "entityType": "columns", - "table": "invites" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "expires_at", - "entityType": "columns", - "table": "invites" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "redeemed_at", - "entityType": "columns", - "table": "invites" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "redeemed_by", - "entityType": "columns", - "table": "invites" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "members" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "profile_id", - "entityType": "columns", - "table": "members" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "display_name", - "entityType": "columns", - "table": "members" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "upi_vpa", - "entityType": "columns", - "table": "members" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "joined_at", - "entityType": "columns", - "table": "members" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "left_at", - "entityType": "columns", - "table": "members" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "updated_at", - "entityType": "columns", - "table": "members" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "members" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "meta" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "name", - "entityType": "columns", - "table": "meta" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "default_currency", - "entityType": "columns", - "table": "meta" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "false", - "generated": null, - "name": "is_direct", - "entityType": "columns", - "table": "meta" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "true", - "generated": null, - "name": "simplify_debts", - "entityType": "columns", - "table": "meta" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": "'initials'", - "generated": null, - "name": "avatar_kind", - "entityType": "columns", - "table": "meta" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "avatar_color", - "entityType": "columns", - "table": "meta" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "avatar_emoji", - "entityType": "columns", - "table": "meta" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "avatar_icon", - "entityType": "columns", - "table": "meta" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "avatar_photo", - "entityType": "columns", - "table": "meta" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": "'generated'", - "generated": null, - "name": "cover_kind", - "entityType": "columns", - "table": "meta" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "cover_photo", - "entityType": "columns", - "table": "meta" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "created_by", - "entityType": "columns", - "table": "meta" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "created_at", - "entityType": "columns", - "table": "meta" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "archived_at", - "entityType": "columns", - "table": "meta" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "updated_at", - "entityType": "columns", - "table": "meta" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "meta" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": true, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "outbox" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "kind", - "entityType": "columns", - "table": "outbox" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "payload", - "entityType": "columns", - "table": "outbox" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "attempts", - "entityType": "columns", - "table": "outbox" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "created_at", - "entityType": "columns", - "table": "outbox" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "name", - "entityType": "columns", - "table": "schedule" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "due_at", - "entityType": "columns", - "table": "schedule" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "tombstone" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "group_id", - "entityType": "columns", - "table": "tombstone" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "purged_at", - "entityType": "columns", - "table": "tombstone" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "tombstone" - }, - { - "columns": [ - "created_by" - ], - "tableTo": "members", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "NO ACTION", - "nameExplicit": false, - "name": "fk_entries_created_by_members_id_fk", - "entityType": "fks", - "table": "entries" - }, - { - "columns": [ - "entry_id" - ], - "tableTo": "entries", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "NO ACTION", - "nameExplicit": false, - "name": "fk_entry_payers_entry_id_entries_id_fk", - "entityType": "fks", - "table": "entry_payers" - }, - { - "columns": [ - "member_id" - ], - "tableTo": "members", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "NO ACTION", - "nameExplicit": false, - "name": "fk_entry_payers_member_id_members_id_fk", - "entityType": "fks", - "table": "entry_payers" - }, - { - "columns": [ - "entry_id" - ], - "tableTo": "entries", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "NO ACTION", - "nameExplicit": false, - "name": "fk_entry_shares_entry_id_entries_id_fk", - "entityType": "fks", - "table": "entry_shares" - }, - { - "columns": [ - "member_id" - ], - "tableTo": "members", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "NO ACTION", - "nameExplicit": false, - "name": "fk_entry_shares_member_id_members_id_fk", - "entityType": "fks", - "table": "entry_shares" - }, - { - "columns": [ - "actor_id" - ], - "tableTo": "members", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "NO ACTION", - "nameExplicit": false, - "name": "fk_events_actor_id_members_id_fk", - "entityType": "fks", - "table": "events" - }, - { - "columns": [ - "created_by" - ], - "tableTo": "members", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "NO ACTION", - "nameExplicit": false, - "name": "fk_group_link_created_by_members_id_fk", - "entityType": "fks", - "table": "group_link" - }, - { - "columns": [ - "member_id" - ], - "tableTo": "members", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "NO ACTION", - "nameExplicit": false, - "name": "fk_invites_member_id_members_id_fk", - "entityType": "fks", - "table": "invites" - }, - { - "columns": [ - "created_by" - ], - "tableTo": "members", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "NO ACTION", - "nameExplicit": false, - "name": "fk_invites_created_by_members_id_fk", - "entityType": "fks", - "table": "invites" - }, - { - "columns": [ - "created_by" - ], - "tableTo": "members", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "NO ACTION", - "nameExplicit": false, - "name": "fk_meta_created_by_members_id_fk", - "entityType": "fks", - "table": "meta" - }, - { - "columns": [ - "entry_id", - "member_id" - ], - "nameExplicit": false, - "name": "entry_payers_pk", - "entityType": "pks", - "table": "entry_payers" - }, - { - "columns": [ - "entry_id", - "member_id" - ], - "nameExplicit": false, - "name": "entry_shares_pk", - "entityType": "pks", - "table": "entry_shares" - }, - { - "columns": [ - "name" - ], - "nameExplicit": false, - "name": "counter_pk", - "table": "counter", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "entries_pk", - "table": "entries", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "events_pk", - "table": "events", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "group_link_pk", - "table": "group_link", - "entityType": "pks" - }, - { - "columns": [ - "token" - ], - "nameExplicit": false, - "name": "invites_pk", - "table": "invites", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "members_pk", - "table": "members", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "meta_pk", - "table": "meta", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "outbox_pk", - "table": "outbox", - "entityType": "pks" - }, - { - "columns": [ - "name" - ], - "nameExplicit": false, - "name": "schedule_pk", - "table": "schedule", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "tombstone_pk", - "table": "tombstone", - "entityType": "pks" - }, - { - "columns": [ - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "entries_seq", - "entityType": "indexes", - "table": "entries" - }, - { - "columns": [ - { - "value": "seq", - "isExpression": false - }, - { - "value": "ordinal", - "isExpression": false - } - ], - "isUnique": true, - "where": null, - "origin": "manual", - "name": "events_position", - "entityType": "indexes", - "table": "events" - }, - { - "columns": [ - { - "value": "subject_id", - "isExpression": false - }, - { - "value": "created_at", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "events_subject", - "entityType": "indexes", - "table": "events" - }, - { - "columns": [ - { - "value": "member_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "invites_member", - "entityType": "indexes", - "table": "invites" - }, - { - "columns": [ - { - "value": "profile_id", - "isExpression": false - } - ], - "isUnique": true, - "where": null, - "origin": "manual", - "name": "members_profile", - "entityType": "indexes", - "table": "members" - }, - { - "columns": [ - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "members_seq", - "entityType": "indexes", - "table": "members" - }, - { - "value": "\"amount_minor\" > 0", - "name": "entries_amount_positive", - "entityType": "checks", - "table": "entries" - }, - { - "value": "\"fx_rate\" is null or \"fx_rate\" > 0", - "name": "entries_fx_rate_positive", - "entityType": "checks", - "table": "entries" - }, - { - "value": "(\"occurred_at\" is null) = (\"time_zone\" is null)", - "name": "entries_moment_complete", - "entityType": "checks", - "table": "entries" - }, - { - "value": "(\"fx_rate\" is null) = (\"fx_source\" is null) and (\"fx_rate\" is null) = (\"fx_at\" is null)", - "name": "entries_fx_complete", - "entityType": "checks", - "table": "entries" - }, - { - "value": "\"amount_minor\" > 0", - "name": "entry_payers_amount_positive", - "entityType": "checks", - "table": "entry_payers" - }, - { - "value": "\"amount_minor\" >= 0", - "name": "entry_shares_amount_not_negative", - "entityType": "checks", - "table": "entry_shares" - }, - { - "value": "(\"entry\" is not null) + (\"member\" is not null) + (\"group\" is not null) + (\"link\" is not null) = 1", - "name": "events_one_payload", - "entityType": "checks", - "table": "events" - }, - { - "value": "length(trim(\"display_name\")) > 0", - "name": "members_name_not_blank", - "entityType": "checks", - "table": "members" - }, - { - "value": "length(trim(\"name\")) > 0", - "name": "meta_name_not_blank", - "entityType": "checks", - "table": "meta" - }, - { - "value": "(\"avatar_kind\" = 'emoji') = (\"avatar_emoji\" is not null)", - "name": "meta_avatar_emoji", - "entityType": "checks", - "table": "meta" - }, - { - "value": "(\"avatar_kind\" = 'icon') = (\"avatar_icon\" is not null)", - "name": "meta_avatar_icon", - "entityType": "checks", - "table": "meta" - }, - { - "value": "(\"avatar_kind\" = 'photo') = (\"avatar_photo\" is not null)", - "name": "meta_avatar_photo", - "entityType": "checks", - "table": "meta" - }, - { - "value": "(\"cover_kind\" = 'photo') = (\"cover_photo\" is not null)", - "name": "meta_cover_photo", - "entityType": "checks", - "table": "meta" - } - ], - "renames": [] -} \ No newline at end of file diff --git a/server/src/do/group/migrations/20261004204449_wooden_phil_sheldon/migration.sql b/server/src/do/group/migrations/20261004204449_wooden_phil_sheldon/migration.sql deleted file mode 100644 index 0399c72..0000000 --- a/server/src/do/group/migrations/20261004204449_wooden_phil_sheldon/migration.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `meta` ADD `last_activity_at` integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/server/src/do/group/migrations/20261002071250_fair_hemingway/migration.sql b/server/src/do/group/migrations/20261004205652_glorious_the_spike/migration.sql similarity index 99% rename from server/src/do/group/migrations/20261002071250_fair_hemingway/migration.sql rename to server/src/do/group/migrations/20261004205652_glorious_the_spike/migration.sql index e5be1c4..6cb6fb6 100644 --- a/server/src/do/group/migrations/20261002071250_fair_hemingway/migration.sql +++ b/server/src/do/group/migrations/20261004205652_glorious_the_spike/migration.sql @@ -116,6 +116,7 @@ CREATE TABLE `meta` ( `cover_photo` text, `created_by` text NOT NULL, `created_at` text NOT NULL, + `last_activity_at` integer NOT NULL, `archived_at` text, `updated_at` text NOT NULL, `seq` integer NOT NULL, diff --git a/server/src/do/group/migrations/20261004204449_wooden_phil_sheldon/snapshot.json b/server/src/do/group/migrations/20261004205652_glorious_the_spike/snapshot.json similarity index 99% rename from server/src/do/group/migrations/20261004204449_wooden_phil_sheldon/snapshot.json rename to server/src/do/group/migrations/20261004205652_glorious_the_spike/snapshot.json index 695f354..a64791b 100644 --- a/server/src/do/group/migrations/20261004204449_wooden_phil_sheldon/snapshot.json +++ b/server/src/do/group/migrations/20261004205652_glorious_the_spike/snapshot.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "51a0ca86-32d6-451c-9c96-a734e7831190", + "id": "5200d3ed-98be-4e6a-b5b7-0e1e3e8c5a23", "prevIds": [ - "f612090c-d005-4921-9693-cb1974b0d8db" + "00000000-0000-0000-0000-000000000000" ], "ddl": [ { @@ -798,7 +798,7 @@ "type": "integer", "notNull": true, "autoincrement": false, - "default": "0", + "default": null, "generated": null, "name": "last_activity_at", "entityType": "columns", diff --git a/server/src/do/group/migrations/migrations.js b/server/src/do/group/migrations/migrations.js index ffe81ec..83abcce 100644 --- a/server/src/do/group/migrations/migrations.js +++ b/server/src/do/group/migrations/migrations.js @@ -1,10 +1,8 @@ -import m0000 from './20261002071250_fair_hemingway/migration.sql'; -import m0001 from './20261004204449_wooden_phil_sheldon/migration.sql'; +import m0000 from './20261004205652_glorious_the_spike/migration.sql'; export default { migrations: { - "20261002071250_fair_hemingway": m0000, -"20261004204449_wooden_phil_sheldon": m0001 + "20261004205652_glorious_the_spike": m0000 } } \ No newline at end of file diff --git a/server/src/do/group/roster.ts b/server/src/do/group/roster.ts index cd28156..966cc75 100644 --- a/server/src/do/group/roster.ts +++ b/server/src/do/group/roster.ts @@ -27,6 +27,7 @@ export function createGroup(tx: Tx, groupId: string, input: GroupInput, profileI ...appearanceOf(input), createdBy: input.creatorId, createdAt: now, + lastActivityAt: Date.parse(now), updatedAt: now, seq, }) diff --git a/server/src/do/group/upkeep.ts b/server/src/do/group/upkeep.ts index 3327b84..e4441f3 100644 --- a/server/src/do/group/upkeep.ts +++ b/server/src/do/group/upkeep.ts @@ -60,7 +60,7 @@ export function runDormancy(tx: Tx, now: number): UpkeepOutcome { const meta = findMeta(tx); if (!meta) return outcome; - const quietSince = meta.lastActivityAt > 0 ? meta.lastActivityAt : Date.parse(meta.createdAt); + const quietSince = meta.lastActivityAt; if (meta.archivedAt === null) { if (quietSince + DORMANCY.archiveAfter > now) { From c0484ca1dac498fdecee0c450a107214f4ac3a78 Mon Sep 17 00:00:00 2001 From: Seenu K Date: Mon, 5 Oct 2026 10:17:06 +0530 Subject: [PATCH 4/9] Update database ID in wrangler configuration --- server/wrangler.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/wrangler.jsonc b/server/wrangler.jsonc index eeea514..7cddb23 100644 --- a/server/wrangler.jsonc +++ b/server/wrangler.jsonc @@ -51,7 +51,7 @@ { "binding": "DB", "database_name": "opensplit", - "database_id": "db2d89ad-d9cb-4608-bd8d-25642aaa7cb7", + "database_id": "a25350b6-ac42-474b-b14c-4535b7448ee5", "migrations_dir": "migrations", "migrations_pattern": "migrations/*/migration.sql" } From 2adaf2e0ea21c47beb8fbbaa9cb1899772f70654 Mon Sep 17 00:00:00 2001 From: Seenu K Date: Mon, 5 Oct 2026 10:56:29 +0530 Subject: [PATCH 5/9] Fix sync_test.dart to expect fx fetch on partial syncs --- test/data/sync_test.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/data/sync_test.dart b/test/data/sync_test.dart index 92f8278..c2940c6 100644 --- a/test/data/sync_test.dart +++ b/test/data/sync_test.dart @@ -1480,9 +1480,10 @@ void main() { final report = await a.sync.syncGroups(const {}); expect(report.isClean, isTrue, reason: '$report'); - for (final feed in ['/api/reference', '/api/fx', '/api/profiles']) { + for (final feed in ['/api/reference', '/api/profiles']) { expect(a.tap.count('GET', feed), 0, reason: feed); } + expect(a.tap.count('GET', '/api/fx'), 1, reason: '/api/fx'); expect(a.tap.requests.where(isGroupList).map(pulled), [ [g.groupId], ]); From 2012f580c982b6f97c9b9215bbf7154d51370a99 Mon Sep 17 00:00:00 2001 From: Seenu K Date: Mon, 5 Oct 2026 11:15:06 +0530 Subject: [PATCH 6/9] chore: regenerate API client with lastActivityAt --- docs/openapi.json | 6 ++++++ packages/opensplit_api/lib/src/model/group.dart | 9 +++++++++ packages/opensplit_api/lib/src/model/group.g.dart | 6 ++++++ 3 files changed, 21 insertions(+) diff --git a/docs/openapi.json b/docs/openapi.json index 7400956..303ed77 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -86,6 +86,11 @@ "format": "date-time", "example": "2026-09-23T11:20:41.865Z" }, + "lastActivityAt": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, "archivedAt": { "type": "string", "nullable": true, @@ -118,6 +123,7 @@ "coverPhoto", "createdBy", "createdAt", + "lastActivityAt", "archivedAt", "updatedAt", "seq" diff --git a/packages/opensplit_api/lib/src/model/group.dart b/packages/opensplit_api/lib/src/model/group.dart index db23274..b3011a0 100644 --- a/packages/opensplit_api/lib/src/model/group.dart +++ b/packages/opensplit_api/lib/src/model/group.dart @@ -48,6 +48,8 @@ class Group { required this.createdAt, + required this.lastActivityAt, + required this.archivedAt, required this.updatedAt, @@ -117,6 +119,11 @@ class Group { @JsonKey(name: r'createdAt', required: true, includeIfNull: false) final DateTime createdAt; + // minimum: -9007199254740991 + // maximum: 9007199254740991 + @JsonKey(name: r'lastActivityAt', required: true, includeIfNull: false) + final int lastActivityAt; + @JsonKey(name: r'archivedAt', required: true, includeIfNull: true) final DateTime? archivedAt; @@ -145,6 +152,7 @@ class Group { other.coverPhoto == coverPhoto && other.createdBy == createdBy && other.createdAt == createdAt && + other.lastActivityAt == lastActivityAt && other.archivedAt == archivedAt && other.updatedAt == updatedAt && other.seq == seq; @@ -165,6 +173,7 @@ class Group { (coverPhoto == null ? 0 : coverPhoto.hashCode) + createdBy.hashCode + createdAt.hashCode + + lastActivityAt.hashCode + (archivedAt == null ? 0 : archivedAt.hashCode) + updatedAt.hashCode + seq.hashCode; diff --git a/packages/opensplit_api/lib/src/model/group.g.dart b/packages/opensplit_api/lib/src/model/group.g.dart index 4269ab3..b0aa3b0 100644 --- a/packages/opensplit_api/lib/src/model/group.g.dart +++ b/packages/opensplit_api/lib/src/model/group.g.dart @@ -26,6 +26,7 @@ Group _$GroupFromJson( 'coverPhoto', 'createdBy', 'createdAt', + 'lastActivityAt', 'archivedAt', 'updatedAt', 'seq', @@ -74,6 +75,10 @@ Group _$GroupFromJson( coverPhoto: $checkedConvert('coverPhoto', (v) => v as String?), createdBy: $checkedConvert('createdBy', (v) => v as String), createdAt: $checkedConvert('createdAt', (v) => DateTime.parse(v as String)), + lastActivityAt: $checkedConvert( + 'lastActivityAt', + (v) => (v as num).toInt(), + ), archivedAt: $checkedConvert( 'archivedAt', (v) => v == null ? null : DateTime.parse(v as String), @@ -99,6 +104,7 @@ Map _$GroupToJson(Group instance) => { 'coverPhoto': instance.coverPhoto, 'createdBy': instance.createdBy, 'createdAt': instance.createdAt.toIso8601String(), + 'lastActivityAt': instance.lastActivityAt, 'archivedAt': instance.archivedAt?.toIso8601String(), 'updatedAt': instance.updatedAt.toIso8601String(), 'seq': instance.seq, From ce7df9dc6a69d640754207c4f62f910d1eadf71d Mon Sep 17 00:00:00 2001 From: Seenu K Date: Mon, 5 Oct 2026 11:27:23 +0530 Subject: [PATCH 7/9] fix: change lastActivityAt to TimestampSchema so it matches the Dart DateTime expectations --- docs/openapi.json | 6 +++--- packages/opensplit_api/lib/src/model/group.dart | 4 +--- packages/opensplit_api/lib/src/model/group.g.dart | 4 ++-- server/src/schemas/ledger.ts | 1 + test/data/apply_changes_test.dart | 1 + test/data/wire_test.dart | 1 + 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/openapi.json b/docs/openapi.json index 303ed77..d7f6008 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -87,9 +87,9 @@ "example": "2026-09-23T11:20:41.865Z" }, "lastActivityAt": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "type": "string", + "format": "date-time", + "example": "2026-09-23T11:20:41.865Z" }, "archivedAt": { "type": "string", diff --git a/packages/opensplit_api/lib/src/model/group.dart b/packages/opensplit_api/lib/src/model/group.dart index b3011a0..f2e2903 100644 --- a/packages/opensplit_api/lib/src/model/group.dart +++ b/packages/opensplit_api/lib/src/model/group.dart @@ -119,10 +119,8 @@ class Group { @JsonKey(name: r'createdAt', required: true, includeIfNull: false) final DateTime createdAt; - // minimum: -9007199254740991 - // maximum: 9007199254740991 @JsonKey(name: r'lastActivityAt', required: true, includeIfNull: false) - final int lastActivityAt; + final DateTime lastActivityAt; @JsonKey(name: r'archivedAt', required: true, includeIfNull: true) final DateTime? archivedAt; diff --git a/packages/opensplit_api/lib/src/model/group.g.dart b/packages/opensplit_api/lib/src/model/group.g.dart index b0aa3b0..a70ae01 100644 --- a/packages/opensplit_api/lib/src/model/group.g.dart +++ b/packages/opensplit_api/lib/src/model/group.g.dart @@ -77,7 +77,7 @@ Group _$GroupFromJson( createdAt: $checkedConvert('createdAt', (v) => DateTime.parse(v as String)), lastActivityAt: $checkedConvert( 'lastActivityAt', - (v) => (v as num).toInt(), + (v) => DateTime.parse(v as String), ), archivedAt: $checkedConvert( 'archivedAt', @@ -104,7 +104,7 @@ Map _$GroupToJson(Group instance) => { 'coverPhoto': instance.coverPhoto, 'createdBy': instance.createdBy, 'createdAt': instance.createdAt.toIso8601String(), - 'lastActivityAt': instance.lastActivityAt, + 'lastActivityAt': instance.lastActivityAt.toIso8601String(), 'archivedAt': instance.archivedAt?.toIso8601String(), 'updatedAt': instance.updatedAt.toIso8601String(), 'seq': instance.seq, diff --git a/server/src/schemas/ledger.ts b/server/src/schemas/ledger.ts index cfd0762..304f01a 100644 --- a/server/src/schemas/ledger.ts +++ b/server/src/schemas/ledger.ts @@ -112,6 +112,7 @@ const groupRow = createSelectSchema(tables.meta, { createdAt: () => TimestampSchema, archivedAt: () => TimestampSchema, updatedAt: () => TimestampSchema, + lastActivityAt: () => TimestampSchema, seq: () => SeqSchema, }); diff --git a/test/data/apply_changes_test.dart b/test/data/apply_changes_test.dart index d2e808d..97d3000 100644 --- a/test/data/apply_changes_test.dart +++ b/test/data/apply_changes_test.dart @@ -33,6 +33,7 @@ void main() { createdAt: at, archivedAt: archivedAt, updatedAt: at, + lastActivityAt: at, seq: seq, ); diff --git a/test/data/wire_test.dart b/test/data/wire_test.dart index 91c738d..33b8c5b 100644 --- a/test/data/wire_test.dart +++ b/test/data/wire_test.dart @@ -68,6 +68,7 @@ void main() { createdAt: DateTime.utc(2026), archivedAt: null, updatedAt: DateTime.utc(2026), + lastActivityAt: DateTime.utc(2026), seq: 1, ); final row = group.toRow(); From 9393bd7f60d2c3605de9fa962bd731e6202b44e7 Mon Sep 17 00:00:00 2001 From: Seenu K Date: Mon, 5 Oct 2026 11:46:32 +0530 Subject: [PATCH 8/9] chore: squash DO migration --- server/src/db/group/schema.ts | 2 +- .../migration.sql | 2 +- .../snapshot.json | 4 ++-- server/src/do/group/migrations/migrations.js | 4 ++-- server/src/do/group/roster.ts | 2 +- server/src/do/group/upkeep.ts | 7 +++---- server/test/dormancy.test.ts | 6 +++--- 7 files changed, 13 insertions(+), 14 deletions(-) rename server/src/do/group/migrations/{20261004205652_glorious_the_spike => 20261005061632_gigantic_mother_askani}/migration.sql (99%) rename server/src/do/group/migrations/{20261004205652_glorious_the_spike => 20261005061632_gigantic_mother_askani}/snapshot.json (99%) diff --git a/server/src/db/group/schema.ts b/server/src/db/group/schema.ts index b97837d..f8887c8 100644 --- a/server/src/db/group/schema.ts +++ b/server/src/db/group/schema.ts @@ -72,7 +72,7 @@ export const meta = sqliteTable( .notNull() .references(() => members.id), createdAt: text("created_at").notNull(), - lastActivityAt: integer("last_activity_at").notNull(), + lastActivityAt: text("last_activity_at").notNull(), archivedAt: text("archived_at"), updatedAt: text("updated_at").notNull(), seq: integer("seq").notNull(), diff --git a/server/src/do/group/migrations/20261004205652_glorious_the_spike/migration.sql b/server/src/do/group/migrations/20261005061632_gigantic_mother_askani/migration.sql similarity index 99% rename from server/src/do/group/migrations/20261004205652_glorious_the_spike/migration.sql rename to server/src/do/group/migrations/20261005061632_gigantic_mother_askani/migration.sql index 6cb6fb6..cddfc77 100644 --- a/server/src/do/group/migrations/20261004205652_glorious_the_spike/migration.sql +++ b/server/src/do/group/migrations/20261005061632_gigantic_mother_askani/migration.sql @@ -116,7 +116,7 @@ CREATE TABLE `meta` ( `cover_photo` text, `created_by` text NOT NULL, `created_at` text NOT NULL, - `last_activity_at` integer NOT NULL, + `last_activity_at` text NOT NULL, `archived_at` text, `updated_at` text NOT NULL, `seq` integer NOT NULL, diff --git a/server/src/do/group/migrations/20261004205652_glorious_the_spike/snapshot.json b/server/src/do/group/migrations/20261005061632_gigantic_mother_askani/snapshot.json similarity index 99% rename from server/src/do/group/migrations/20261004205652_glorious_the_spike/snapshot.json rename to server/src/do/group/migrations/20261005061632_gigantic_mother_askani/snapshot.json index a64791b..d37270f 100644 --- a/server/src/do/group/migrations/20261004205652_glorious_the_spike/snapshot.json +++ b/server/src/do/group/migrations/20261005061632_gigantic_mother_askani/snapshot.json @@ -1,7 +1,7 @@ { "version": "7", "dialect": "sqlite", - "id": "5200d3ed-98be-4e6a-b5b7-0e1e3e8c5a23", + "id": "09fbe554-2e51-414e-935c-d7812dbbe577", "prevIds": [ "00000000-0000-0000-0000-000000000000" ], @@ -795,7 +795,7 @@ "table": "meta" }, { - "type": "integer", + "type": "text", "notNull": true, "autoincrement": false, "default": null, diff --git a/server/src/do/group/migrations/migrations.js b/server/src/do/group/migrations/migrations.js index 83abcce..d6d50d0 100644 --- a/server/src/do/group/migrations/migrations.js +++ b/server/src/do/group/migrations/migrations.js @@ -1,8 +1,8 @@ -import m0000 from './20261004205652_glorious_the_spike/migration.sql'; +import m0000 from './20261005061632_gigantic_mother_askani/migration.sql'; export default { migrations: { - "20261004205652_glorious_the_spike": m0000 + "20261005061632_gigantic_mother_askani": m0000 } } \ No newline at end of file diff --git a/server/src/do/group/roster.ts b/server/src/do/group/roster.ts index 966cc75..65e6ef7 100644 --- a/server/src/do/group/roster.ts +++ b/server/src/do/group/roster.ts @@ -27,7 +27,7 @@ export function createGroup(tx: Tx, groupId: string, input: GroupInput, profileI ...appearanceOf(input), createdBy: input.creatorId, createdAt: now, - lastActivityAt: Date.parse(now), + lastActivityAt: now, updatedAt: now, seq, }) diff --git a/server/src/do/group/upkeep.ts b/server/src/do/group/upkeep.ts index e4441f3..207db15 100644 --- a/server/src/do/group/upkeep.ts +++ b/server/src/do/group/upkeep.ts @@ -43,9 +43,8 @@ export function nextDue(tx: Tx): number | null { /** Called in the transaction of every change a member makes: a group in use is not dormant. */ export function touchDormancy(tx: Tx, now: string): void { - const at = Date.parse(now); - tx.update(schema.meta).set({ lastActivityAt: at }).run(); - scheduleAt(tx, "archive", at + DORMANCY.archiveAfter); + tx.update(schema.meta).set({ lastActivityAt: now }).run(); + scheduleAt(tx, "archive", Date.parse(now) + DORMANCY.archiveAfter); unschedule(tx, "purge"); } @@ -60,7 +59,7 @@ export function runDormancy(tx: Tx, now: number): UpkeepOutcome { const meta = findMeta(tx); if (!meta) return outcome; - const quietSince = meta.lastActivityAt; + const quietSince = Date.parse(meta.lastActivityAt); if (meta.archivedAt === null) { if (quietSince + DORMANCY.archiveAfter > now) { diff --git a/server/test/dormancy.test.ts b/server/test/dormancy.test.ts index 2728174..ae6e35f 100644 --- a/server/test/dormancy.test.ts +++ b/server/test/dormancy.test.ts @@ -59,7 +59,7 @@ describe("a group that goes quiet", () => { const longAgo = Date.now() - 180 * DAYS; await runInDurableObject(object, async (_instance: Group, state) => { state.storage.sql.exec("update entries set created_at = ?", new Date(longAgo).toISOString()); - state.storage.sql.exec("update meta set last_activity_at = ?", longAgo); + state.storage.sql.exec("update meta set last_activity_at = ?", new Date(longAgo).toISOString()); }); ok(await saveEntry(object, draftOf(entry, { description: "Dinner, corrected" }), RAVI)); @@ -71,8 +71,8 @@ describe("a group that goes quiet", () => { const object = stub(groupId); await object.runUpkeep(Date.now() + 100 * DAYS); - const activity = await runInDurableObject(object, async (_instance: Group, state) => state.storage.sql.exec<{ last_activity_at: number }>("select last_activity_at from meta").one().last_activity_at); - expect(activity).toBeLessThan(Date.now() + DAYS); + const activity = await runInDurableObject(object, async (_instance: Group, state) => state.storage.sql.exec<{ last_activity_at: string }>("select last_activity_at from meta").one().last_activity_at); + expect(Date.parse(activity)).toBeLessThan(Date.now() + DAYS); }); /** A deadline already spent would hold the object's one alarm in the past, firing again as soon as it returns. */ From 154b603f7b1544211d08f716b0b4eb1feeb262ef Mon Sep 17 00:00:00 2001 From: Seenu K Date: Mon, 5 Oct 2026 12:17:16 +0530 Subject: [PATCH 9/9] fix: update outbox enqueue methods to use enqueueInTransaction for atomicity --- .../repositories/drift_entry_repository.dart | 2 +- .../repositories/drift_group_repository.dart | 26 ++++--- .../drift_profile_repository.dart | 2 +- lib/data/sync/outbox_queue.dart | 78 +++++++++++-------- test/data/local_write_atomicity_test.dart | 5 +- 5 files changed, 64 insertions(+), 49 deletions(-) diff --git a/lib/data/repositories/drift_entry_repository.dart b/lib/data/repositories/drift_entry_repository.dart index 04418b2..6cd29f2 100644 --- a/lib/data/repositories/drift_entry_repository.dart +++ b/lib/data/repositories/drift_entry_repository.dart @@ -30,7 +30,7 @@ final class DriftEntryRepository { /// Queues a row for the server. Future _enqueue(String entryId) async => - outbox?.enqueue(OutboxTarget.entry, entryId); + outbox?.enqueueInTransaction(OutboxTarget.entry, entryId); /// Writes an entry, a provisional feed line for it, and its outbox item. Future _writeWithSnapshot({ diff --git a/lib/data/repositories/drift_group_repository.dart b/lib/data/repositories/drift_group_repository.dart index 8b81376..211cf51 100644 --- a/lib/data/repositories/drift_group_repository.dart +++ b/lib/data/repositories/drift_group_repository.dart @@ -163,8 +163,8 @@ final class DriftGroupRepository { await _db.into(_db.members).insert(creator); // Both rows have to reach the server, and the group has to land first: // members and entries reference it by foreign key. - await outbox?.enqueue(OutboxTarget.group, group.id); - await outbox?.enqueue(OutboxTarget.member, creator.id); + await outbox?.enqueueInTransaction(OutboxTarget.group, group.id); + await outbox?.enqueueInTransaction(OutboxTarget.member, creator.id); }); return (group: group, creator: creator); @@ -193,7 +193,7 @@ final class DriftGroupRepository { // Bumped on every local write. ), ); - await outbox?.enqueue(OutboxTarget.group, group.id); + await outbox?.enqueueInTransaction(OutboxTarget.group, group.id); }); } @@ -204,7 +204,7 @@ final class DriftGroupRepository { await _db.transaction(() async { await (_db.update(_db.members)..where((t) => t.id.equals(memberId))) .write(MembersCompanion(leftAt: Value(_clock()))); - await outbox?.enqueue(OutboxTarget.member, memberId); + await outbox?.enqueueInTransaction(OutboxTarget.member, memberId); }); } @@ -237,7 +237,7 @@ final class DriftGroupRepository { await _db.transaction(() async { await _db.into(_db.members).insert(member); - await outbox?.enqueue(OutboxTarget.member, member.id); + await outbox?.enqueueInTransaction(OutboxTarget.member, member.id); }); return member; } @@ -254,7 +254,7 @@ final class DriftGroupRepository { await _db.transaction(() async { await (_db.update(_db.members)..where((t) => t.id.equals(memberId))) .write(MembersCompanion(displayName: Value(trimmed))); - await outbox?.enqueue(OutboxTarget.member, memberId); + await outbox?.enqueueInTransaction(OutboxTarget.member, memberId); }); } @@ -266,7 +266,7 @@ final class DriftGroupRepository { await _db.transaction(() async { await (_db.update(_db.members)..where((t) => t.id.equals(memberId))) .write(MembersCompanion(leftAt: Value(_clock()))); - await outbox?.enqueue(OutboxTarget.member, memberId); + await outbox?.enqueueInTransaction(OutboxTarget.member, memberId); }); } @@ -284,7 +284,7 @@ final class DriftGroupRepository { upiVpa: Value(trimmed == null || trimmed.isEmpty ? null : trimmed), ), ); - await outbox?.enqueue(OutboxTarget.member, memberId); + await outbox?.enqueueInTransaction(OutboxTarget.member, memberId); }); } @@ -314,9 +314,11 @@ final class DriftGroupRepository { } Future setArchived(String groupId, {required bool archived}) async { - await (_db.update(_db.groups)..where((t) => t.id.equals(groupId))).write( - GroupsCompanion(archivedAt: Value(archived ? _clock() : null)), - ); - await outbox?.enqueue(OutboxTarget.group, groupId); + await _db.transaction(() async { + await (_db.update(_db.groups)..where((t) => t.id.equals(groupId))).write( + GroupsCompanion(archivedAt: Value(archived ? _clock() : null)), + ); + await outbox?.enqueueInTransaction(OutboxTarget.group, groupId); + }); } } diff --git a/lib/data/repositories/drift_profile_repository.dart b/lib/data/repositories/drift_profile_repository.dart index cae1202..0c476a6 100644 --- a/lib/data/repositories/drift_profile_repository.dart +++ b/lib/data/repositories/drift_profile_repository.dart @@ -30,6 +30,6 @@ final class DriftProfileRepository { Future upsert(Profile profile) => _db.transaction(() async { await _db.into(_db.profiles).insertOnConflictUpdate(profile); - await outbox?.enqueue(OutboxTarget.profile, profile.id); + await outbox?.enqueueInTransaction(OutboxTarget.profile, profile.id); }); } diff --git a/lib/data/sync/outbox_queue.dart b/lib/data/sync/outbox_queue.dart index 5ab7332..94424b1 100644 --- a/lib/data/sync/outbox_queue.dart +++ b/lib/data/sync/outbox_queue.dart @@ -56,40 +56,50 @@ class OutboxQueue { /// reads each row as it is then, so a creation sent from its old place /// carries any later edit too. Future enqueue(OutboxTarget target, String targetId) => - _db.transaction(() async { - if (!(await readSyncSession(_db)).enabled) { - throw StateError('This account session has ended.'); - } - final revision = const Uuid().v4(); - final position = await _nextPosition(); - final moves = await _isOnServer(target, targetId); - await _db - .into(_db.outbox) - .insert( - OutboxCompanion.insert( - target: target, - targetId: targetId, - revision: revision, - position: position, - ), - onConflict: DoUpdate( - // A fresh change deserves an immediate attempt even if a - // previous one had been backed off, or set aside as a dead - // letter: whatever the server refused may be exactly what this - // edit changed. - (_) => OutboxCompanion( - revision: Value(revision), - position: moves ? Value(position) : const Value.absent(), - attempts: const Value(0), - nextAttemptAt: const Value(null), - lastError: const Value(null), - deadLetteredAt: const Value(null), - ), - ), - ); - - if (_queued.hasListener) _queued.add(null); - }); + _db.transaction(() => enqueueInTransaction(target, targetId)); + + /// Queues a row as part of the caller's active database transaction. + /// + /// The local write and its queue row must commit together. A separate + /// transaction would nest on browsers, where Drift holds one Web Lock for + /// the outer transaction. + Future enqueueInTransaction( + OutboxTarget target, + String targetId, + ) async { + if (!(await readSyncSession(_db)).enabled) { + throw StateError('This account session has ended.'); + } + final revision = const Uuid().v4(); + final position = await _nextPosition(); + final moves = await _isOnServer(target, targetId); + await _db + .into(_db.outbox) + .insert( + OutboxCompanion.insert( + target: target, + targetId: targetId, + revision: revision, + position: position, + ), + onConflict: DoUpdate( + // A fresh change deserves an immediate attempt even if a + // previous one had been backed off, or set aside as a dead + // letter: whatever the server refused may be exactly what this + // edit changed. + (_) => OutboxCompanion( + revision: Value(revision), + position: moves ? Value(position) : const Value.absent(), + attempts: const Value(0), + nextAttemptAt: const Value(null), + lastError: const Value(null), + deadLetteredAt: const Value(null), + ), + ), + ); + + if (_queued.hasListener) _queued.add(null); + } Future _nextPosition() async { final last = _db.outbox.position.max(); diff --git a/test/data/local_write_atomicity_test.dart b/test/data/local_write_atomicity_test.dart index 9e61670..349159c 100644 --- a/test/data/local_write_atomicity_test.dart +++ b/test/data/local_write_atomicity_test.dart @@ -150,7 +150,10 @@ class _RefusingOutbox extends OutboxQueue { _RefusingOutbox(super.db); @override - Future enqueue(OutboxTarget target, String targetId) async { + Future enqueueInTransaction( + OutboxTarget target, + String targetId, + ) async { throw StateError('Simulated queue failure'); } }