From 7e7a481a1c406e751f85647c7561719ff563c50a Mon Sep 17 00:00:00 2001 From: Grace Nakiyemba Date: Thu, 22 Oct 2026 16:31:42 +0300 Subject: [PATCH 1/8] refactor(formulus): use primary key for observation reads --- .../database/repositories/WatermelonDBRepo.ts | 24 ++----------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/formulus/src/database/repositories/WatermelonDBRepo.ts b/formulus/src/database/repositories/WatermelonDBRepo.ts index 8a93e16c7..1668fa22a 100644 --- a/formulus/src/database/repositories/WatermelonDBRepo.ts +++ b/formulus/src/database/repositories/WatermelonDBRepo.ts @@ -275,28 +275,8 @@ export class WatermelonDBRepo implements LocalRepoInterface { async getObservation(id: string): Promise { return profileActivity.run('Database: getObservation', async () => { try { - // First try direct lookup by ID (WatermelonDB's internal ID) - try { - const observation = await this.observationsCollection.find(id); - return this.mapObservationModelToInterface(observation); - } catch { - // ID not found, continue to next approach - } - - // If not found by ID, try to find by observationId field - // Force a database sync before querying to ensure we have the latest data - await this.database.get('observations').query().fetch(); - - const observations = await this.observationsCollection - .query(Q.where('observation_id', id)) - .fetch(); - - if (observations.length > 0) { - const observation = observations[0]; - return this.mapObservationModelToInterface(observation); - } - - return null; + const observation = await this.observationsCollection.find(id); + return this.mapObservationModelToInterface(observation); } catch (error) { console.error( 'Error getting observation:', From 9105e5d6ee3339e15cf7323a0e4fb2c9cb945980 Mon Sep 17 00:00:00 2001 From: Grace Nakiyemba Date: Thu, 22 Oct 2026 16:32:41 +0300 Subject: [PATCH 2/8] refactor(formulus): use primary key for observation mutations --- .../database/repositories/WatermelonDBRepo.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/formulus/src/database/repositories/WatermelonDBRepo.ts b/formulus/src/database/repositories/WatermelonDBRepo.ts index 1668fa22a..16f1c7632 100644 --- a/formulus/src/database/repositories/WatermelonDBRepo.ts +++ b/formulus/src/database/repositories/WatermelonDBRepo.ts @@ -482,12 +482,9 @@ export class WatermelonDBRepo implements LocalRepoInterface { async updateObservation(input: UpdateObservationInput): Promise { return profileActivity.run('Database: updateObservation', async () => { try { - const record = await this.findObservationRecord(input.observationId); - - if (!record) { - console.error('Observation not found with ID:', input.observationId); - return false; - } + const record = await this.observationsCollection.find( + input.observationId, + ); // Update the record let success = false; @@ -537,12 +534,7 @@ export class WatermelonDBRepo implements LocalRepoInterface { async deleteObservation(id: string): Promise { return profileActivity.run('Database: deleteObservation', async () => { try { - const record = await this.findObservationRecord(id); - - if (!record) { - console.error('Observation not found with ID:', id); - return false; - } + const record = await this.observationsCollection.find(id); // Mark the record as deleted (soft delete) let success = false; From d2df340c1b9e237e0f1caf0eff69499fac898e37 Mon Sep 17 00:00:00 2001 From: Grace Nakiyemba Date: Thu, 22 Oct 2026 16:34:13 +0300 Subject: [PATCH 3/8] refactor(formulus): remove legacy observation lookup helper --- .../src/database/repositories/WatermelonDBRepo.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/formulus/src/database/repositories/WatermelonDBRepo.ts b/formulus/src/database/repositories/WatermelonDBRepo.ts index 16f1c7632..d20ec0284 100644 --- a/formulus/src/database/repositories/WatermelonDBRepo.ts +++ b/formulus/src/database/repositories/WatermelonDBRepo.ts @@ -253,20 +253,6 @@ export class WatermelonDBRepo implements LocalRepoInterface { }); } - /** Resolve a row by Watermelon id or by observation_id column (legacy ingested rows - TODO: Legacy support to be removed in vNextMinor). */ - private async findObservationRecord( - stableId: string, - ): Promise { - try { - return await this.observationsCollection.find(stableId); - } catch { - const rows = await this.observationsCollection - .query(Q.where('observation_id', stableId)) - .fetch(); - return rows[0] ?? null; - } - } - /** * Get an observation by its ID * @param id The unique identifier for the observation From 16441e73050651dde6ae98fabaeba547e5cd4def Mon Sep 17 00:00:00 2001 From: Grace Nakiyemba Date: Thu, 22 Oct 2026 16:35:24 +0300 Subject: [PATCH 4/8] refactor(formulus): simplify single observation sync lookup --- .../database/repositories/WatermelonDBRepo.ts | 26 +------------------ 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/formulus/src/database/repositories/WatermelonDBRepo.ts b/formulus/src/database/repositories/WatermelonDBRepo.ts index d20ec0284..a8a0fb2b5 100644 --- a/formulus/src/database/repositories/WatermelonDBRepo.ts +++ b/formulus/src/database/repositories/WatermelonDBRepo.ts @@ -558,31 +558,7 @@ export class WatermelonDBRepo implements LocalRepoInterface { 'Database: markObservationAsSynced', async () => { try { - // Find the observation using our improved lookup approach - let record: ObservationModel | null = null; - - // Try to find by direct ID first - try { - record = await this.observationsCollection.find(id); - } catch { - // not found by primary key - } - - // If not found by ID, try to find by observationId field - if (!record) { - const observations = await this.observationsCollection - .query(Q.where('observation_id', id)) - .fetch(); - - if (observations.length > 0) { - record = observations[0]; - } - } - - if (!record) { - console.error('Observation not found with ID:', id); - return false; - } + const record = await this.observationsCollection.find(id); // Update the syncedAt timestamp let success = false; From d462512328b1a2ef6799d612067423bac2e43d53 Mon Sep 17 00:00:00 2001 From: Grace Nakiyemba Date: Thu, 22 Oct 2026 16:36:09 +0300 Subject: [PATCH 5/8] refactor(formulus): simplify bulk observation sync lookup --- formulus/src/database/repositories/WatermelonDBRepo.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/formulus/src/database/repositories/WatermelonDBRepo.ts b/formulus/src/database/repositories/WatermelonDBRepo.ts index a8a0fb2b5..310f83107 100644 --- a/formulus/src/database/repositories/WatermelonDBRepo.ts +++ b/formulus/src/database/repositories/WatermelonDBRepo.ts @@ -757,17 +757,9 @@ export class WatermelonDBRepo implements LocalRepoInterface { } const now = new Date(); await this.database.write(async () => { - const byPk = await this.observationsCollection + const records = await this.observationsCollection .query(Q.where('id', Q.oneOf(ids))) .fetch(); - const byObservationColumn = await this.observationsCollection - .query(Q.where('observation_id', Q.oneOf(ids))) - .fetch(); - const merged = new Map(); - for (const r of [...byPk, ...byObservationColumn]) { - merged.set(r.id, r); - } - const records = [...merged.values()]; const batchOps = records.map(record => record.prepareUpdate(rec => { From 8bf79ada928bcd80b2b028ec2234a53a1d15f548 Mon Sep 17 00:00:00 2001 From: Grace Nakiyemba Date: Thu, 22 Oct 2026 16:39:41 +0300 Subject: [PATCH 6/8] refactor(formulus): remove legacy observation id fallback --- formulus/src/mappers/ObservationMapper.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/formulus/src/mappers/ObservationMapper.ts b/formulus/src/mappers/ObservationMapper.ts index 2d46dcc24..d3c3729ef 100644 --- a/formulus/src/mappers/ObservationMapper.ts +++ b/formulus/src/mappers/ObservationMapper.ts @@ -89,15 +89,6 @@ export class ObservationMapper { }; } - /** - * Canonical observation id for API/sync: the `observation_id` column (server id). - * Falls back to Watermelon row id for empty legacy rows. - */ - static observationIdFromDBModel(model: ObservationModel): string { - const col = model.observationId?.trim(); - return col || model.id; - } - // DB Model -> Domain static fromDBModel(model: ObservationModel): DomainObservation { let geolocation: ObservationGeolocation | null = null; @@ -122,7 +113,7 @@ export class ObservationMapper { } return { - observationId: ObservationMapper.observationIdFromDBModel(model), + observationId: model.observationId, formType: model.formType, formVersion: model.formVersion, data: From 0476fbc0129cb64017c2ea8fbf505bae38344a7a Mon Sep 17 00:00:00 2001 From: Grace Nakiyemba Date: Thu, 22 Oct 2026 16:41:39 +0300 Subject: [PATCH 7/8] test(formulus): align mapper identity test with stable ids --- .../ObservationMapper.syncIdentity.test.ts | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/formulus/src/mappers/__tests__/ObservationMapper.syncIdentity.test.ts b/formulus/src/mappers/__tests__/ObservationMapper.syncIdentity.test.ts index 0b1a9cc4e..cc0235920 100644 --- a/formulus/src/mappers/__tests__/ObservationMapper.syncIdentity.test.ts +++ b/formulus/src/mappers/__tests__/ObservationMapper.syncIdentity.test.ts @@ -2,18 +2,15 @@ import { ObservationMapper } from '../ObservationMapper'; import type { ObservationModel } from '../../database/models/ObservationModel'; /** - * Documents the contract broken when domain observationId is taken from WatermelonDB's - * internal row id instead of the observation_id column (see WatermelonDBRepo.applyServerChanges prepareCreate). - * - * Why this was not caught earlier: WatermelonDBRepo tests only exercised saveObservation(), - * which sets _raw.id === observation_id, so model.id and model.observationId were always equal. + * Observations use the same stable identifier for both the WatermelonDB row id + * and the observation_id column throughout persistence and synchronization. */ -describe('ObservationMapper sync identity (regression)', () => { - test('fromDBModel must use observation_id column when it differs from Watermelon row id', () => { - const serverId = 'obs_pulled_from_sync_1001'; +describe('ObservationMapper sync identity', () => { + test('preserves the stable observation id from database to API', () => { + const observationId = 'obs_pulled_from_sync_1001'; const rowAsModel = { - id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', - observationId: serverId, + id: observationId, + observationId, formType: 'register_coffee', formVersion: '1.0', data: JSON.stringify({ k: 'v' }), @@ -27,12 +24,12 @@ describe('ObservationMapper sync identity (regression)', () => { syncedAt: new Date('2025-01-03T00:00:00.000Z'), } as ObservationModel; - const domain = ObservationMapper.fromDBModel(rowAsModel); + expect(rowAsModel.id).toBe(rowAsModel.observationId); - expect(domain.observationId).toBe(serverId); - expect(domain.observationId).not.toBe(rowAsModel.id); + const domain = ObservationMapper.fromDBModel(rowAsModel); + expect(domain.observationId).toBe(observationId); const api = ObservationMapper.toApi(domain); - expect(api.observation_id).toBe(serverId); + expect(api.observation_id).toBe(observationId); }); }); From 0fc475eba02df8db06bb638c1fee1e830ae3c772 Mon Sep 17 00:00:00 2001 From: Grace Nakiyemba Date: Thu, 22 Oct 2026 16:48:32 +0300 Subject: [PATCH 8/8] test(formulus): update observation identity documentation --- .../repositories/__tests__/WatermelonDBRepo.test.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/formulus/src/database/repositories/__tests__/WatermelonDBRepo.test.ts b/formulus/src/database/repositories/__tests__/WatermelonDBRepo.test.ts index 79b829bb9..0ec59d00f 100644 --- a/formulus/src/database/repositories/__tests__/WatermelonDBRepo.test.ts +++ b/formulus/src/database/repositories/__tests__/WatermelonDBRepo.test.ts @@ -577,16 +577,8 @@ describe('WatermelonDBRepo', () => { }); /** - * Regression: observations ingested via applyServerChanges must keep the server's - * observation_id as the canonical id exposed to the app and to Synkronus push payloads. - * - * Why earlier tests missed it: - * - All existing WatermelonDBRepo tests only used saveObservation(), which sets both - * Watermelon record id and observation_id to the same obs_${timestamp}_ value. - * - applyServerChanges was never covered (only a synchronize() todo). - * - prepareCreate for pull sets observation_id but leaves Watermelon to assign a - * different internal id; mappers incorrectly used model.id as domain observationId, - * so pushes could use a random uuid as observation_id and duplicate server rows. + * Pulled observations keep the server observation_id as the stable identifier + * across the WatermelonDB row, domain model, lookup path, and API payload. */ test('applyServerChanges (new row): domain observationId matches server observation_id', async () => { const serverObservationId = 'obs_pulled_from_sync_1001';