mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
fix(ingest): refuse an external id that cannot be a stable identity
An external id is half of every ingest dedup key — (userId, source, externalId), (userId, externalSource, externalId), (userId, type, source, externalId). Re-posting the same triple is idempotent and returns the existing row, which holds only while the id is stable across client launches. A client that serialised an opaque platform object with its default object description sent a per-allocation memory address instead, so no sweep ever matched its own earlier row and each one minted a fresh record: a live instance grew 23 phantom medications in a day, none of which could collect a dose, because the dose side resolved through the same rotating string. Add one shared classifier for identifiers that provably cannot be an identity — an angle-bracketed object description carrying a hex pointer, a bare 0x address, and a blank value — and wire it at every surface that accepts a client-supplied externalId. Single-entry creates refuse through the schema, so the client gets the standard 422 multi-issue envelope with a message naming the shape it sent and what to send instead. Batch and bulk surfaces classify per entry and report the offending row as skipped with reason unstable_external_id, leaving the rest of the payload to land: one bad row must not stop a sync. Every refusal carries pinned wide-event meta (count, shape, surface) and never the id itself. A structural guard freezes both routes into the floor: a request-side externalId declaration either applies the field check or carries a marker naming the file that checks it per entry, and the guard verifies that file really does. The backup-restore payload is the one exemption, with its reason recorded — it replays ids the server itself wrote.
This commit is contained in:
1 parent
ef9891fec5
commit
2ab66ab2e3
26 files changed
+1232
-64
No files matched your search
@@ -32,6 +32,12 @@ import {
|
||||
cycleBulkSchema,
|
||||
MAX_CYCLE_BULK_ENTRIES,
|
||||
} from "@/lib/validations/cycle";
|
||||
import {
|
||||
classifyExternalId,
|
||||
unstableExternalIdMeta,
|
||||
UNSTABLE_EXTERNAL_ID_REASON,
|
||||
type UnstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
|
||||
@@ -121,9 +127,33 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
let updated = 0;
|
||||
let duplicates = 0;
|
||||
let skipped = 0;
|
||||
const unstableShapes: UnstableExternalIdShape[] = [];
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
|
||||
// The `(userId, source, externalId)` upsert key is only idempotent
|
||||
// while the id is STABLE across client launches. An id that rotates
|
||||
// per process (an object description carrying a memory address)
|
||||
// never matches its own earlier row, so every re-post mints another
|
||||
// day log. Refused per entry — one bad row must not stop the rest of
|
||||
// the batch landing.
|
||||
const unstable =
|
||||
entry.externalId === undefined
|
||||
? null
|
||||
: classifyExternalId(entry.externalId);
|
||||
if (unstable) {
|
||||
unstableShapes.push(unstable);
|
||||
skipped += 1;
|
||||
results.push({
|
||||
index: i,
|
||||
status: "skipped",
|
||||
reason: UNSTABLE_EXTERNAL_ID_REASON,
|
||||
externalId: entry.externalId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const cycleId = owningCycleId(entry.date);
|
||||
const r = await upsertCycleDayLog(
|
||||
@@ -206,6 +236,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
updated,
|
||||
duplicates,
|
||||
skipped,
|
||||
...(unstableShapes.length > 0
|
||||
? unstableExternalIdMeta("cycle.day_log.bulk", unstableShapes)
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -27,9 +27,13 @@ import {
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
cycleDayLogInputSchema,
|
||||
cycleDayLogCreateSchema,
|
||||
cycleDayLogQuerySchema,
|
||||
} from "@/lib/validations/cycle";
|
||||
import {
|
||||
unstableExternalIdMeta,
|
||||
unstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
|
||||
import { findOwningCycleId } from "@/lib/cycle/cycle-attribution";
|
||||
import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto";
|
||||
@@ -82,11 +86,17 @@ async function postDayLog(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = cycleDayLogInputSchema.safeParse(rawBody);
|
||||
const parsed = cycleDayLogCreateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
const shape = unstableExternalIdShape(rawBody);
|
||||
annotate({
|
||||
action: { name: "cycle.day-log.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
meta: {
|
||||
issue_count: parsed.error.issues.length,
|
||||
...(shape
|
||||
? unstableExternalIdMeta("cycle.day_log.create", [shape])
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "cycle.day-log.invalid",
|
||||
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { cyclePeriodSchema } from "@/lib/validations/cycle";
|
||||
import {
|
||||
unstableExternalIdMeta,
|
||||
unstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
|
||||
import type { FlowLevel } from "@/lib/cycle/types";
|
||||
import {
|
||||
@@ -49,9 +53,13 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
|
||||
const parsed = cyclePeriodSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
const shape = unstableExternalIdShape(rawBody);
|
||||
annotate({
|
||||
action: { name: "cycle.period.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
meta: {
|
||||
issue_count: parsed.error.issues.length,
|
||||
...(shape ? unstableExternalIdMeta("cycle.period", [shape]) : {}),
|
||||
},
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "cycle.period.invalid",
|
||||
|
||||
Reference in new issue
Block a user