Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ac5841d
docs: design for dive computer gear twins
ericgriffin Aug 26, 2026
e475c3c
docs: implementation plan for dive computer gear twins
ericgriffin Aug 26, 2026
11dc768
feat(equipment): deterministic gear-twin identity for dive computers
ericgriffin Aug 26, 2026
27fa011
feat(db): add dive_computers.equipment_id gear-twin bridge at v169
ericgriffin Aug 26, 2026
402fada
feat(equipment): seed a gear twin when a dive computer is registered
ericgriffin Aug 26, 2026
7a7b64e
feat(equipment): attach dive computer gear twins at the import seams
ericgriffin Aug 26, 2026
5a2bdc4
feat(equipment): link the gear twin when a dive source is replaced
ericgriffin Aug 26, 2026
9e0773a
feat(db): backfill dive computer gear twins at v169
ericgriffin Aug 26, 2026
41d5177
feat(db): seed gear twins from the imported-computer self-heal
ericgriffin Aug 26, 2026
d6e4ee0
fix(buoyancy): dive computers contribute no dry mass
ericgriffin Aug 26, 2026
a1b77df
feat(ui): show a dive computer's linked gear item on its detail page
ericgriffin Aug 26, 2026
4719275
fix: correct stray v168 references and cover the gear-twin error paths
ericgriffin Aug 27, 2026
ee3ae2d
fix(db): declare the gear-twin FK on upgraded databases too
ericgriffin Aug 27, 2026
52d3114
docs: correct D9, the v169 backfill is local-only by design
ericgriffin Aug 27, 2026
f5def7a
fix(equipment): seed the gear twin with insertOrIgnore, never an upsert
ericgriffin Aug 27, 2026
26d3ae1
Merge branch 'main' into worktree-dive-computer-gear-twin
ericgriffin Aug 27, 2026
bcf528f
Merge branch 'main' into worktree-dive-computer-gear-twin
ericgriffin Aug 27, 2026
4c22cbc
fix(equipment): mark the twin pending only when the insert inserted
ericgriffin Aug 27, 2026
f1c6722
Merge branch 'main' into worktree-dive-computer-gear-twin
ericgriffin Aug 27, 2026
a49b9e1
docs: follow the v169 -> v175 renumber into the design doc
ericgriffin Aug 27, 2026
72d9fd8
fix(dive-log): mark the registry row pending once, not twice
ericgriffin Aug 27, 2026
357dcce
test(db): bind parameters in the gear-twin backfill test queries
ericgriffin Aug 27, 2026
db7b155
merge: resolve the schema ladder with main, keeping both sides (v175)
ericgriffin Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,380 changes: 2,380 additions & 0 deletions docs/superpowers/plans/2026-08-26-dive-computer-gear-twin.md

Large diffs are not rendered by default.

540 changes: 540 additions & 0 deletions docs/superpowers/specs/2026-08-26-dive-computer-gear-twin-design.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions lib/core/buoyancy/gear_feature.dart
Original file line number Diff line number Diff line change
Expand Up @@ -218,13 +218,21 @@ class GearFeature extends Equatable {
EquipmentType.hood => 0.3,
EquipmentType.gloves => 0.2,
EquipmentType.boots => 0.4,
// Stated rather than left to the fallthrough, which already returns 0.0:
// gear twins (v175) make this a case readers will look for.
EquipmentType.computer => 0.0,
_ => 0.0,
};

static double _typeDryMass(EquipmentType type) => switch (type) {
EquipmentType.wetsuit => 2.0,
EquipmentType.drysuit => 3.0,
EquipmentType.bcd => 3.5,
// A wrist computer's dry mass is negligible against the rig, and gear
// twins (v175) put one on every downloaded dive: the 0.5 kg fallthrough
// would move every diver's buoyancy by that much per computer. An explicit
// dry_weight_kg attribute still wins, so a bulky console can be modeled.
EquipmentType.computer => 0.0,
_ => 0.5,
};

Expand Down
86 changes: 85 additions & 1 deletion lib/core/database/database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:developer' as developer;

import 'package:drift/drift.dart';

import 'package:submersion/core/database/dive_computer_gear_backfill.dart';
import 'package:submersion/core/database/imported_computer_backfill.dart';
import 'package:submersion/core/database/performance_indexes.dart';
import 'package:submersion/core/database/tag_uniqueness.dart';
Expand Down Expand Up @@ -2552,6 +2553,23 @@ class DiveComputers extends Table {
/// (nullable: rows written before HLC rollout fall back to updatedAt).
TextColumn get hlc => text().nullable()();

/// The equipment row representing this device as gear, its "gear twin"
/// (v175). Seeded once at registration, then owned by the user: renaming or
/// retiring the gear item never writes back here, and renaming the computer
/// never overwrites the gear name.
///
/// Unlike [bluetoothAddress] this DOES synchronize, because equipment ids are
/// fleet-stable and a peer holding a null here would dangle the reference.
///
/// setNull rather than cascade: deleting the gear item leaves the device
/// registered. The cleared column is also what makes that deletion permanent,
/// because only a genuine computer insert ever mints a twin.
TextColumn get equipmentId => text().nullable().references(
Equipment,
#id,
onDelete: KeyAction.setNull,
)();

@override
Set<Column> get primaryKey => {id};
}
Expand Down Expand Up @@ -3270,7 +3288,7 @@ class AppDatabase extends _$AppDatabase {

/// The current schema version as a static constant so that pre-open checks
/// (e.g. version-mismatch guard) can reference it without an instance.
static const int currentSchemaVersion = 174;
static const int currentSchemaVersion = 175;

/// The oldest schema whose reader can apply this build's sync payloads
/// without loss or misinterpretation (the compatibility floor).
Expand Down Expand Up @@ -3620,6 +3638,17 @@ class AppDatabase extends _$AppDatabase {
// per-type toggles for which badge rows a diver's types appear in.
// Issue #1269 follow-up.
174,
// v175 (gear twins): dive_computers.equipment_id, the equipment row that
// represents a registered computer as gear, so a downloaded dive lists the
// computer that logged it alongside the rest of the diver's kit. Issue
// #1320. Renumbered from 169: main reserved 169 for this branch but landed
// 170 past it, and a rung below the shipped version never runs its
// onUpgrade step, so the gear-twin backfill would silently never execute.
// 171, 173 and 174 then landed as well (trip_day_weather and the two
// dive_types columns above), so this takes 175, which main's own v171
// comment already reserves for it. 169 is now permanently skipped, as are
// 162 and 167.
175,
];

/// Idempotent DDL for the v106 connector-suggestion columns (Lightroom
Expand Down Expand Up @@ -4407,6 +4436,46 @@ class AppDatabase extends _$AppDatabase {
}
}

/// v175: dive_computers.equipment_id (gear twins). Idempotent; safe to call
/// from both onUpgrade and the beforeOpen backstop. Nullable with no default,
/// because a null means "this computer has no gear item", which is also what
/// a user deleting the gear item leaves behind.
///
/// The REFERENCES clause is not decoration. Without it an upgraded database
/// gets a bare TEXT column while a freshly created one gets the FK from the
/// table definition, so `onDelete: setNull` would hold only for new installs
/// and existing users would be left with `equipment_id` pointing at a deleted
/// row. SQLite permits a REFERENCES clause on ADD COLUMN precisely because
/// this column is nullable and defaults to NULL. Mirrors the v158
/// `_assertProfileSourceIdColumn` precedent.
///
/// It is added ONLY when `equipment` actually exists. SQLite accepts a
/// reference to a missing table at ALTER time and then fails every later
/// write to `dive_computers` with "no such table: main.equipment" once
/// foreign keys are on, which would break minimal fixtures and any database
/// caught mid-upgrade. Every real database has `equipment`, so production
/// always takes the FK branch; the bare fallback is harmless where it
/// applies, because a database with no `equipment` table has no gear rows
/// whose deletion the FK would need to cascade.
Future<void> _assertDiveComputerEquipmentColumn() async {
final cols = await customSelect(
"PRAGMA table_info('dive_computers')",
).get();
if (cols.isEmpty) return;
final names = cols.map((c) => c.read<String>('name')).toSet();
if (names.contains('equipment_id')) return;

final equipmentCols = await customSelect(
"PRAGMA table_info('equipment')",
).get();
final reference = equipmentCols.isEmpty
? ''
: ' REFERENCES equipment(id) ON DELETE SET NULL';
await customStatement(
'ALTER TABLE dive_computers ADD COLUMN equipment_id TEXT$reference',
);
}

/// v164: media.manual_elapsed_seconds (issue #1090). Idempotent; safe to
/// call from both onUpgrade and the beforeOpen backstop. Nullable with no
/// default, so every pre-existing row reads back as "position from
Expand Down Expand Up @@ -8995,6 +9064,14 @@ class AppDatabase extends _$AppDatabase {
await _assertDiveTypeVisibilityColumns();
}
if (from < 174) await reportProgress();
// v175: dive_computers.equipment_id (gear twins). The backfill that
// seeds the twins and links existing dives runs on the same rung; the
// column has to land first. Renumbered from 169, which main overtook.
if (from < 175) {
await _assertDiveComputerEquipmentColumn();
await backfillDiveComputerGearTwins(this);
}
if (from < 175) await reportProgress();
},
beforeOpen: (details) async {
// Enable foreign keys
Expand Down Expand Up @@ -9225,6 +9302,13 @@ class AppDatabase extends _$AppDatabase {
// version-collision self-heal).
await _assertDiveTypeVisibilityColumns();

// v175 backstop: re-assert dive_computers.equipment_id (gear twins;
// same parallel-branch version-collision self-heal). Column only:
// backfillDiveComputerGearTwins is a full-table pass that belongs to
// the ladder, and re-running it on every open would resurrect a gear
// item the user deleted.
await _assertDiveComputerEquipmentColumn();

// v145 backstop: re-assert the gps_tracks provenance and trim columns.
await _assertGpsTrackColumns();

Expand Down
189 changes: 189 additions & 0 deletions lib/core/database/dive_computer_gear_backfill.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import 'package:drift/drift.dart';

import 'package:submersion/core/database/dive_computer_gear_identity.dart';

/// Seed a gear twin for every registered dive computer and link it to the dives
/// that computer logged (v175).
///
/// Ladder-only, never a `beforeOpen` backstop, for two independent reasons: it
/// is a full-table pass over every dive, and re-running it on every open would
/// resurrect a gear item the user deleted. That is the same rule
/// `_backfillLegacyServiceSchedules` and `_backfillBottomTimeFromProfile`
/// follow.
///
/// New rows land on a deterministic id ([diveComputerGearId]), so every device
/// in a synced fleet derives the same primary key and they converge under sync
/// upsert rather than duplicating.
///
/// LOCAL-ONLY AND HLC-NEUTRAL, deliberately. Nothing here marks a record
/// pending or stamps an HLC, so these writes never go out on an incremental
/// sync. That is the `_backfillDiveComputerIds` pattern and it is correct here
/// for the same reason: every input is already synced (`dive_computers`,
/// `dives`, `dive_data_sources`) and the twin id is derived, so every device
/// produces identical rows independently when its own ladder runs. Marking
/// them pending would push one record per computer plus one per (dive,
/// computer) pair from every device in the fleet, to make peers agree on rows
/// they will each derive anyway.
///
/// Two consequences worth knowing before "fixing" this:
///
/// * A base/full export passes `hlcSince == null` and therefore DOES carry
/// these rows, so a device adopting the cloud base is not missing them.
/// * A dive downloaded by a peer still on the previous schema and synced to an
/// already-migrated device is not linked on that device: its ladder has run,
/// the runtime linker fires only at local creation seams, and the peer's own
/// later backfill is HLC-neutral so it does not push. That is a missing join
/// row on one device during the rollout window, not divergence in the twin
/// itself, and it resolves the moment anyone edits that dive's gear.
///
/// The runtime paths are the opposite and mark pending as usual: the resolver
/// when it mints a twin, and `bulkAddEquipment` for every link the linker adds.
Future<void> backfillDiveComputerGearTwins(DatabaseConnectionUser db) async {
// PRAGMA-guarded like every other backfill helper: the ladder runs against
// minimal fixtures and against databases caught mid-upgrade. PRAGMA
// table_info returns empty for a missing table, so probing the columns covers
// both "table absent" and "column absent".
Future<Set<String>> columnsOf(String table) async {
final rows = await db.customSelect("PRAGMA table_info('$table')").get();
return rows.map((c) => c.read<String>('name')).toSet();
}

final computerCols = await columnsOf('dive_computers');
if (!computerCols.containsAll({
'id',
'diver_id',
'name',
'manufacturer',
'model',
'serial_number',
'equipment_id',
})) {
return;
}
// Every column the INSERT below writes, not a subset: a guard that passes
// and then throws on the insert is worse than no guard, because the caller
// reads it as proof the write is safe.
final equipmentCols = await columnsOf('equipment');
if (!equipmentCols.containsAll({
'id',
'diver_id',
'name',
'type',
'brand',
'model',
'serial_number',
'status',
'purchase_currency',
'notes',
'is_active',
'created_at',
'updated_at',
})) {
return;
}

// Pass 1: resolve a twin per computer. Bounded by device count, a handful of
// rows, so no event-loop yield is needed here.
final computers = await db
.customSelect(
'SELECT id, diver_id, name, manufacturer, model, serial_number '
'FROM dive_computers WHERE equipment_id IS NULL ORDER BY id',
)
.get();

for (final computer in computers) {
final id = computer.read<String>('id');
final diverId = computer.read<String?>('diver_id');
final name = computer.read<String>('name');
final manufacturer = computer.read<String?>('manufacturer');
final model = computer.read<String?>('model');
final serial = computer.read<String?>('serial_number');

final derivedId = diveComputerGearId(id);

// Adopt the row already holding the derived id before matching on text:
// the match reads each row's CURRENT text while the id derives from the
// computer id, so a renamed gear item makes the match miss while the id
// still collides.
final byDerivedId = await db
.customSelect(
'SELECT id FROM equipment WHERE id = ?',
variables: [Variable<String>(derivedId)],
)
.getSingleOrNull();

var twinId = byDerivedId?.read<String>('id');

if (twinId == null) {
final candidateRows = await db
.customSelect(
'SELECT id, diver_id, brand, model, serial_number FROM equipment '
"WHERE type = 'computer' AND is_active = 1 "
'ORDER BY updated_at DESC, id',
)
.get();
twinId = matchGearTwin(
manufacturer: manufacturer,
model: model,
serialNumber: serial,
diverId: diverId,
candidates: candidateRows.map(
(r) => GearTwinCandidate(
id: r.read<String>('id'),
diverId: r.read<String?>('diver_id'),
brand: r.read<String?>('brand'),
model: r.read<String?>('model'),
serialNumber: r.read<String?>('serial_number'),
),
),
)?.id;
}

if (twinId == null) {
twinId = derivedId;
final now = DateTime.now().millisecondsSinceEpoch;
await db.customStatement(
'INSERT OR IGNORE INTO equipment '
'(id, diver_id, name, type, brand, model, serial_number, status, '
'purchase_currency, notes, is_active, created_at, updated_at) '
"VALUES (?, ?, ?, 'computer', ?, ?, ?, 'active', 'USD', '', 1, ?, ?)",
Comment on lines +145 to +149

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 52d3114, by correcting the documentation rather than the code, since the finding is factually right but the behaviour is intentional.

Verified first: _exportEquipment filters on hlc > hlcSince when incremental, and the backfill makes zero sync calls. So the rows do carry a null HLC and do not replicate incrementally. D9 and the PR description both claimed otherwise, and were wrong.

Local-only is correct here. Every input is already synced and the twin id is a deterministic v5 derivation, so each device produces identical rows when its own ladder runs. That is the _backfillDiveComputerIds pattern. Marking pending would push one record per computer plus one per (dive, computer) pair from every device, so peers agree on rows they each derive anyway.

Both of D9's original arguments failed on checking: a peer on the old schema has no equipment_id column so nothing dangles, and a base export passes hlcSince == null so adopting devices do receive the rows. One real limitation is now documented: a dive downloaded by a not-yet-upgraded peer and synced to an already-migrated device is not linked on that device during the rollout window. A test asserts the HLC-neutrality so it cannot be reversed silently.

[twinId, diverId, name, manufacturer, model, serial, now, now],
);
}

await db.customStatement(
'UPDATE dive_computers SET equipment_id = ? WHERE id = ?',
[twinId, id],
);
}

// Pass 2: link the dives. Set-based, so it needs no per-dive loop and no
// event-loop yield: a per-dive loop during a migration runs as one unbroken
// microtask chain and freezes the progress spinner.
final junctionCols = await columnsOf('dive_equipment');
if (!junctionCols.containsAll({'dive_id', 'equipment_id'})) return;

// dives.computer_id holds only the PRIMARY computer, so it is unioned with
// dive_data_sources: a dive logged on two computers must list both.
final diveCols = await columnsOf('dives');
if (diveCols.contains('computer_id')) {
await db.customStatement('''
INSERT OR IGNORE INTO dive_equipment (dive_id, equipment_id)
SELECT d.id, c.equipment_id
FROM dives d
JOIN dive_computers c ON c.id = d.computer_id
WHERE c.equipment_id IS NOT NULL
''');
}

final sourceCols = await columnsOf('dive_data_sources');
if (sourceCols.containsAll({'dive_id', 'computer_id'})) {
await db.customStatement('''
INSERT OR IGNORE INTO dive_equipment (dive_id, equipment_id)
SELECT s.dive_id, c.equipment_id
FROM dive_data_sources s
JOIN dive_computers c ON c.id = s.computer_id
WHERE c.equipment_id IS NOT NULL
''');
}
}
Loading