mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Carry the Coach transcript through backup and restore
A restored account got a Coach that had never spoken to it. The conversations, every turn in them, and the record of which vault documents a thread was grounded in were all classified as backed up and travelled with nothing. The register's own entry for the messages called them the largest volume of free text an account owns after its documents. The three travel together because two of them mean nothing alone: a turn without its thread has nowhere to hang, and an attachment names a pairing rather than a thing. documentScoped is the field that makes this more than lost history. It is a permanent fence — set when a conversation is created through the fenced endpoint or gets its first document, and lowered by no code path anywhere, not by detaching every document and not by deleting one. A restore that let it default to false would hand the tool loop back to precisely the conversations the fence exists to keep away from it, and nothing in the interface would look wrong afterwards. So it is carried verbatim, restored verbatim, and asserted per thread rather than in aggregate: a restore that wrote false everywhere would still return both threads and every turn and satisfy every count. Prose follows the contract the rest of the payload already uses. A disaster-recovery file carries the stored ciphertext; a portable file carries the text, because a portable export exists to be readable by the person who owns it, exactly as it already decrypts medication notes, mood notes and document summaries. A single row under a key the instance no longer holds is named rather than thrown, so one unreadable turn cannot cost someone the rest of the transcript. Ids ride in both modes, as the reminders do, so an attachment resolves and the facts, plans and reminders still owed have something real to point at. The restore runs after the documents for the same reason, and an attachment naming a document the file does not carry is dropped and reported instead of failing the whole file over one link. Verified by breaking it: forcing documentScoped to false on the way back fails the round trip on that field by name.
This commit is contained in:
1 parent
3f570b6f53
commit
bcb7716ffa
11 files changed
+730
-8
No files matched your search
@@ -1042,6 +1042,57 @@ const measurementReminderEventBackupSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* One Coach turn.
|
||||
*
|
||||
* `contentEncrypted` and `content` are the two ends of the same contract, so
|
||||
* both are optional here and exactly one arrives: a disaster-recovery file
|
||||
* carries the ciphertext, a portable file carries the prose. Requiring either
|
||||
* would refuse half the valid files; requiring both would refuse all of them.
|
||||
* The restore picks whichever it was handed.
|
||||
*/
|
||||
const coachMessageBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
role: z.string().min(1),
|
||||
contentEncrypted: base64BytesSchema.optional(),
|
||||
content: z.string().optional(),
|
||||
metricSourceJson: z.string().nullable().optional(),
|
||||
providerType: z.string().nullable().optional(),
|
||||
promptVersion: z.string().nullable().optional(),
|
||||
tokensUsed: z.number().int().nullable().optional(),
|
||||
model: z.string().nullable().optional(),
|
||||
createdAt: isoDateTime,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const coachConversationDocumentBackupSchema = z
|
||||
.object({
|
||||
documentId: z.string().min(1),
|
||||
addedAt: isoDateTime,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const coachConversationBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string(),
|
||||
// NOT optional with a default. The fence is permanent and a file that does
|
||||
// not state it is a file that cannot be trusted to re-fence the
|
||||
// conversation, so the absence has to be visible rather than defaulted to
|
||||
// the permissive value.
|
||||
documentScoped: z.boolean(),
|
||||
summaryEncrypted: base64BytesSchema.nullable().optional(),
|
||||
summary: z.string().nullable().optional(),
|
||||
summaryUpdatedAt: isoDateTime.nullable().optional(),
|
||||
summaryTurnCount: z.number().int().min(0).optional(),
|
||||
createdAt: isoDateTime,
|
||||
updatedAt: isoDateTime,
|
||||
messages: z.array(coachMessageBackupSchema).default([]),
|
||||
attachments: z.array(coachConversationDocumentBackupSchema).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const allergyBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
@@ -1208,6 +1259,7 @@ export const backupPayloadSchema = z
|
||||
// iOS #68). Defaulted for the same reason as the sections above: a file
|
||||
// written before the reminders travelled carries no key, and an account
|
||||
// with none writes [].
|
||||
coachConversations: z.array(coachConversationBackupSchema).default([]),
|
||||
measurementReminders: z.array(measurementReminderBackupSchema).default([]),
|
||||
measurementReminderEvents: z
|
||||
.array(measurementReminderEventBackupSchema)
|
||||
@@ -1298,6 +1350,10 @@ export interface BackupSummary {
|
||||
measurementReminders: number;
|
||||
/** v1.37.20 (#223 / iOS #68) — completion-ledger rows across every reminder. */
|
||||
measurementReminderEvents: number;
|
||||
/** Coach conversation threads. */
|
||||
coachConversations: number;
|
||||
/** Coach turns across every thread. */
|
||||
coachMessages: number;
|
||||
}
|
||||
|
||||
export function summarizeBackup(payload: BackupPayload): BackupSummary {
|
||||
@@ -1354,6 +1410,11 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
|
||||
// file with reminders the way it once did for visits.
|
||||
measurementReminders: payload.measurementReminders.length,
|
||||
measurementReminderEvents: payload.measurementReminderEvents.length,
|
||||
coachConversations: payload.coachConversations.length,
|
||||
coachMessages: payload.coachConversations.reduce(
|
||||
(total, conversation) => total + conversation.messages.length,
|
||||
0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user