fix(backup): carry four columns the restore was dropping

A column-level round trip over every two-ended model found four values
that left with the export and came back empty or re-stamped:

- MenstrualCycle.absorbedIntoId: the disaster-recovery file carried the
  folded, soft-deleted start but not the start that absorbed it, so after
  a restore removing the absorbing start could no longer bring the folded
  one back. Carried in the disaster-recovery arm and written back only
  when the file carries the start it names.
- Workout.externalSourceVersion: the source's version marker, stored so
  the sync can replay it unchanged, was in neither end. Carried beside
  metadata in the disaster-recovery arm.
- NutrientIntakeDay.externalSourceVersion and createdAt: neither was
  selected, so a restored day lost its source marker and its first-arrival
  time. Carried in both arms.

Files written before this change parse as before; every new field is
optional on the way in.
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 11:17:44 +02:00
1 parent f3bf65fb4e
commit 3f5bcbd441
5 files changed
+35

No files matched your search

+17
View File
@@ -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<string, string>();
const restoredCycleIds = new Set<string>();
// 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) } : {}),
},
+7
View File
@@ -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(),
})),
};
+3
View File
@@ -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,
+3
View File
@@ -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) } : {}),
})),
});
}
+5
View File
@@ -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(),