diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 048e52f8c..1a216b812 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -64,6 +64,12 @@ export interface CycleBackupSection { isPredicted?: boolean; syncVersion?: number; deletedAt?: string | null; + /** + * Disaster recovery only: the start that folded this one in. A bare id with + * no foreign key, meaningful only because the same file carries the ids of + * the starts it names. + */ + absorbedIntoId?: string | null; createdAt?: string; updatedAt?: string; }>; @@ -214,6 +220,7 @@ export async function buildCycleBackupSection( isPredicted: c.isPredicted, syncVersion: c.syncVersion, deletedAt: c.deletedAt?.toISOString() ?? null, + absorbedIntoId: c.absorbedIntoId, createdAt: c.createdAt.toISOString(), updatedAt: c.updatedAt.toISOString(), } @@ -432,6 +439,12 @@ export async function restoreCycleData( // re-attach to their owning span. const cycleIdByStart = new Map(); const restoredCycleIds = new Set(); + // A folded start names the start that absorbed it. Kept only when that start + // is in the same file under the same id; a pointer at nothing would stop the + // folded row from ever coming back, which is what the column is for. + const carriedCycleIds = new Set( + payload.cycles.flatMap((c) => (c.id ? [c.id] : [])), + ); for (const c of payload.cycles) { const created = await tx.menstrualCycle.create({ data: { @@ -447,6 +460,10 @@ export async function restoreCycleData( isPredicted: c.isPredicted ?? false, syncVersion: c.syncVersion ?? 0, deletedAt: c.deletedAt ? new Date(c.deletedAt) : null, + absorbedIntoId: + c.absorbedIntoId && carriedCycleIds.has(c.absorbedIntoId) + ? c.absorbedIntoId + : null, ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), ...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}), }, diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index d5a9137d7..a1c9396c3 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -887,6 +887,9 @@ export async function buildFullBackupPayload( // `source` is part of the composite PK, so it has to ride along or a // restore cannot tell a manual water entry from a synced day total. The // table carries no tombstone, so both purposes read the same shape. + // `externalSourceVersion` is the source's own version marker, stored so + // the sync can replay it back unchanged; `createdAt` is when the day + // first arrived. Neither can be rebuilt after a restore. prisma.nutrientIntakeDay.findMany({ where: { userId }, select: { @@ -895,6 +898,8 @@ export async function buildFullBackupPayload( amount: true, unit: true, source: true, + externalSourceVersion: true, + createdAt: true, }, orderBy: [{ day: "desc" }, { nutrient: "asc" }], }), @@ -1293,6 +1298,8 @@ export async function buildFullBackupPayload( amount: n.amount, unit: n.unit, source: n.source, + externalSourceVersion: n.externalSourceVersion, + createdAt: n.createdAt.toISOString(), })), }; diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index 2bb4cd278..99e97d032 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -142,6 +142,8 @@ export interface WorkoutBackupEntry { externalId: string | null; /** Canonical DR only; portable workout summaries keep their existing shape. */ metadata?: Prisma.JsonValue; + /** Canonical DR only, beside `metadata`: the source's own version marker. */ + externalSourceVersion?: string | null; } export interface DocumentBackupEntry { @@ -516,6 +518,7 @@ export async function buildRecordsBackupSection( createdAt: w.createdAt.toISOString(), updatedAt: w.updatedAt.toISOString(), ...(w.metadata == null ? {} : { metadata: w.metadata }), + externalSourceVersion: w.externalSourceVersion, } : {}), sportType: w.sportType, diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index ff3bb1175..da1e3baca 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -2139,6 +2139,7 @@ export async function restoreBackup( pauseDurationSec: workout.pauseDurationSec ?? null, source: workout.source as never, externalId: workout.externalId ?? null, + externalSourceVersion: workout.externalSourceVersion ?? null, ...(workout.metadata == null ? {} : { metadata: toJson(workout.metadata) }), @@ -2161,6 +2162,8 @@ export async function restoreBackup( amount: n.amount, unit: n.unit, source: n.source, + externalSourceVersion: n.externalSourceVersion ?? null, + ...(n.createdAt ? { createdAt: new Date(n.createdAt) } : {}), })), }); } diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 06eb54aa7..e6412d98f 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -559,6 +559,7 @@ const cycleSpanSchema = z tz: z.string().nullable().optional(), syncVersion: z.number().int().optional(), deletedAt: isoDateTime.nullable().optional(), + absorbedIntoId: z.string().min(1).nullable().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), }) @@ -691,6 +692,9 @@ const nutrientDaySchema = z // Closed set in app code rather than a DB enum; kept as a string here so a // future source does not make an old file unparseable. source: z.string().min(1), + // Absent from files written before v1.39.7. + externalSourceVersion: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), }) .passthrough(); @@ -1641,6 +1645,7 @@ const workoutBackupSchema = z pauseDurationSec: z.number().int().nullable().optional(), source: z.enum(MeasurementSource), externalId: z.string().nullable().optional(), + externalSourceVersion: z.string().nullable().optional(), metadata: z.json().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(),