Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/// <reference types="jest" />

import { describe, it, expect } from '@jest/globals';
import { effectiveRepositoryGenerationForRequest } from '../repositoryGenerationRequest';

describe('effectiveRepositoryGenerationForRequest', () => {
it('returns null when storage key is missing', () => {
expect(effectiveRepositoryGenerationForRequest(null, null)).toBeNull();
});

it('returns null for invalid stored values', () => {
expect(effectiveRepositoryGenerationForRequest('', null)).toBeNull();
expect(effectiveRepositoryGenerationForRequest('x', null)).toBeNull();
expect(effectiveRepositoryGenerationForRequest('0', null)).toBeNull();
});

it('treats default epoch 1 as omitted until an observation cursor exists', () => {
expect(effectiveRepositoryGenerationForRequest('1', null)).toBeNull();
expect(effectiveRepositoryGenerationForRequest('1', '')).toBeNull();
expect(effectiveRepositoryGenerationForRequest('1', '0')).toBeNull();
expect(effectiveRepositoryGenerationForRequest('1', ' 0 ')).toBeNull();
});

it('returns 1 once a real observation cursor is established', () => {
expect(effectiveRepositoryGenerationForRequest('1', '42')).toBe(1);
});

it('returns stored epoch >1 even without last_seen (e.g. after recovery wipe)', () => {
expect(effectiveRepositoryGenerationForRequest('5', null)).toBe(5);
expect(effectiveRepositoryGenerationForRequest('5', '')).toBe(5);
});
});
8 changes: 3 additions & 5 deletions formulus/src/api/synkronus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
RepositoryResetRequiredError,
} from '../../errors/RepositoryResetRequiredError';
import type { AxiosError, AxiosResponse } from 'axios';
import { effectiveRepositoryGenerationForRequest } from './repositoryGenerationRequest';

const REPOSITORY_GENERATION_STORAGE_KEY = '@repository_generation';

Expand Down Expand Up @@ -147,11 +148,8 @@ class SynkronusApi {
number | null
> {
const raw = await AsyncStorage.getItem(REPOSITORY_GENERATION_STORAGE_KEY);
if (raw == null) {
return null;
}
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : null;
const lastSeen = await AsyncStorage.getItem('@last_seen_version');
return effectiveRepositoryGenerationForRequest(raw, lastSeen);
}

private async persistRepositoryGenerationFromResponse(
Expand Down
28 changes: 28 additions & 0 deletions formulus/src/api/synkronus/repositoryGenerationRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Resolves which repository_generation value (if any) to send on sync requests.
*
* Some builds or migrations may persist the default epoch `1` before any
* observation cursor exists. Against a server whose epoch is >1, sending `1`
* looks like an explicit mismatch and yields 409. When there is no real
* observation sync cursor yet, treat that stored `1` as "unspecified" so the
* client omits header/body and the server adopts the current epoch (same as a
* missing key).
*/
export function effectiveRepositoryGenerationForRequest(
storedRaw: string | null,
lastSeenVersionRaw: string | null,
): number | null {
if (storedRaw == null) {
return null;
}
const n = Number(storedRaw);
if (!Number.isFinite(n) || n <= 0) {
return null;
}
const lastSeen = lastSeenVersionRaw?.trim() ?? '';
const noObservationCursorYet = lastSeen === '' || lastSeen === '0';
if (n === 1 && noObservationCursorYet) {
return null;
}
return n;
}
37 changes: 36 additions & 1 deletion synkronus/internal/handlers/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,42 @@ func (h *Handler) Push(w http.ResponseWriter, r *http.Request) {
return
}

clientGen := sync.ParseClientRepositoryGeneration(r, req.RepositoryGeneration)
clientGen, clientGenSent := sync.ParseClientRepositoryGenerationSent(r, req.RepositoryGeneration)

// No-op push with no epoch asserted (fresh install / client omitting header and body):
// must not fail with repository_generation mismatch — same contract as Pull and
// attachment manifest (see TestPull_missingRepositoryGeneration_adoptsServer).
if len(req.Records) == 0 && !clientGenSent {
currentVersion, err := h.syncService.GetCurrentVersion(r.Context())
if err != nil {
h.log.Error("Failed to get current version for empty push", "error", err)
SendErrorResponse(w, http.StatusInternalServerError, err, "Failed to read sync state")
return
}
serverGen, err := h.syncService.GetRepositoryGeneration(r.Context())
if err != nil {
h.log.Error("Failed to read repository generation for empty push", "error", err)
SendErrorResponse(w, http.StatusInternalServerError, err, "Failed to verify repository generation")
return
}
response := SyncPushResponse{
CurrentVersion: currentVersion,
RepositoryGeneration: serverGen,
SuccessCount: 0,
FailedRecords: nil,
Warnings: nil,
}
h.log.Info("Sync push (no records, client omitted repository_generation)",
"transmissionId", req.TransmissionID,
"clientId", req.ClientID,
"currentVersion", currentVersion,
"repositoryGeneration", serverGen)
h.recordPresenceAfterSyncPush(r, req.ClientID, currentVersion)
w.Header().Set(sync.HeaderRepositoryGeneration, strconv.FormatInt(serverGen, 10))
SendJSONResponse(w, http.StatusOK, response)
return
}

result, err := h.syncService.ProcessPushedRecords(r.Context(), req.Records, req.ClientID, req.TransmissionID, clientGen)
if err != nil {
if errors.Is(err, sync.ErrRepositoryGenerationMismatch) {
Expand Down
37 changes: 37 additions & 0 deletions synkronus/internal/handlers/sync_repository_generation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,43 @@ func TestPull_repositoryGenerationMismatch_header_returns409(t *testing.T) {
}
}

func TestPush_emptyRecords_missingRepositoryGeneration_ok(t *testing.T) {
h, mockSync, _ := createTestHandlerWithSync()
mockSync.SetRepositoryGeneration(5)

body, err := json.Marshal(SyncPushRequest{
TransmissionID: "tx-empty",
ClientID: "fresh-install",
Records: []sync.Observation{},
// No RepositoryGeneration, no header — same fresh-install contract as Pull.
})
if err != nil {
t.Fatal(err)
}

req := httptest.NewRequest(http.MethodPost, "/api/sync/push", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.Push(w, req)

resp := w.Result()
t.Cleanup(func() { _ = resp.Body.Close() })

if resp.StatusCode != http.StatusOK {
t.Fatalf("expected HTTP 200 for empty push without epoch, got %d", resp.StatusCode)
}
var out SyncPushResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode body: %v", err)
}
if out.RepositoryGeneration != 5 {
t.Fatalf("expected repository_generation 5, got %d", out.RepositoryGeneration)
}
if out.SuccessCount != 0 {
t.Fatalf("expected success_count 0, got %d", out.SuccessCount)
}
}

func TestPush_repositoryGenerationMismatch_returns409(t *testing.T) {
h, mockSync, _ := createTestHandlerWithSync()
mockSync.SetRepositoryGeneration(5)
Expand Down
Loading