From d8b540bf8fa7629ec912a0c07dd3dece7b6c7eae Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Tue, 22 Sep 2026 21:51:46 -0700 Subject: [PATCH 1/3] fix(sdk): recover an install the server no longer has instead of refusing its events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An event whose installId is not in install_events was answered with a 404 and dropped. The id lives in the app's storage for the life of the install, so that is not a transient failure: every event that device will ever send refers to an id the server cannot resolve, and the SDK has no way back. A deployment that prunes analytics on a retention window reaches this state the moment an install outlives the window, which is the common case rather than an edge one, and the failure is invisible — the 404 goes to a mobile client that ignores it. The install is now recorded again under the id the client sent and the event is stored against it. The recovered row claims nothing it cannot know: marked `recovered`, with no link, click or confidence score, so it can never be read as a fresh attributed install, and a marker fingerprint that cannot collide with a real one. Creating a row from a client-supplied id adds no exposure that /api/sdk/v1/install does not already have, since that endpoint is unauthenticated and creates install rows for any caller. The 404 that remains — recovery itself could not complete — now carries code INSTALL_NOT_FOUND and action reregister, so a client can tell it from a transport error. The earlier test for the old behaviour is replaced by cases covering recovery, the concurrent-request race, and that path. --- README.md | 9 +++ src/routes/sdk.event.test.ts | 115 ++++++++++++++++++++++++++++++++++- src/routes/sdk.ts | 70 +++++++++++++++++++-- 3 files changed, 188 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2debe67..ceeed69 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,15 @@ GET /api/sdk/v1/resolve/:shortCode # Resolve link to deep link data (no redi GET /api/sdk/v1/health # Health check ``` +An event whose `installId` this server no longer has is **recovered, not +refused**: the install is recorded again (marked `recovered`, with no link, +click or confidence score, so it is never mistaken for a fresh attributed +install) and the event is stored against it. This matters if you prune +analytics on a retention window — the id lives in the app's storage for the +life of the install, so without recovery a device that outlived the window +would be refused forever with no way back. The 404 that remains carries +`code: "INSTALL_NOT_FOUND"` and `action: "reregister"`. + ### Health ```bash diff --git a/src/routes/sdk.event.test.ts b/src/routes/sdk.event.test.ts index 125bd09..6dc2674 100644 --- a/src/routes/sdk.event.test.ts +++ b/src/routes/sdk.event.test.ts @@ -142,13 +142,124 @@ describe('POST /api/sdk/v1/event — last-click attribution stamp', () => { await app.close(); }); - it('returns 404 when the install does not exist', async () => { +}); + +/** + * An event whose install is gone. + * + * The install id lives in the app's storage for the life of the install, so a + * missing row is permanent from the device's point of view: refusing the event + * silences that app forever. A deployment that prunes analytics on a retention + * window reaches this the moment an install outlives the window. + */ +describe('POST /api/sdk/v1/event — an install the server no longer has', () => { + beforeEach(() => { + mockQuery.mockReset(); + }); + + const orphanEvent = { + installId: INSTALL_ID, + eventName: 'purchase', + eventData: { value: 12 }, + sdkName: 'android', + sdkVersion: '1.3.2', + }; + + it('records the install again and stores the event against it', async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }); // 1) install lookup: gone + mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] }); // 2) recovery insert + mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] }); // 3) the event insert + + const app = await buildApp(); + const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: orphanEvent }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ eventId: EVENT_ID, acknowledged: true }); + + const recovery = mockQuery.mock.calls[1]; + expect(recovery[0]).toMatch(/INSERT INTO install_events/); + // The id the client sent, so the device's next event resolves normally. + expect(recovery[1][0]).toBe(INSTALL_ID); + // A marker, never a real hash: it cannot collide with a fingerprint match. + expect(recovery[1][1]).toBe(`recovered:${INSTALL_ID}`); + expect(recovery[1].slice(2)).toEqual(['android', '1.3.2']); + // Concurrent events for the same install must not collide. + expect(recovery[0]).toMatch(/ON CONFLICT \(id\) DO NOTHING/); + + // The event is stored against that install, unattributed. + const insert = mockQuery.mock.calls[2]; + expect(insert[0]).toMatch(/INSERT INTO in_app_events/); + expect(insert[1][0]).toBe(INSTALL_ID); + + await app.close(); + }); + + it('claims no attribution for a recovered install', async () => { mockQuery.mockResolvedValueOnce({ rows: [] }); + mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] }); + mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] }); const app = await buildApp(); - const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent }); + await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: orphanEvent }); + + const sql = mockQuery.mock.calls[1][0] as string; + expect(sql).toMatch(/'recovered'/); + // No link, click or confidence among the columns written: a device we have + // met before is not a new attributed install, and nothing downstream may + // read it as one. (The RETURNING clause reads link_id back; that is not a + // write.) + const columns = sql.slice(sql.indexOf('('), sql.indexOf('VALUES')); + expect(columns).not.toMatch(/link_id|click_id|confidence_score/); + + await app.close(); + }); + + it('uses the row a concurrent request created rather than failing', async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }); // lookup: gone + mockQuery.mockResolvedValueOnce({ rows: [] }); // insert: lost the race + mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: LINK_ID }] }); // re-read + mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] }); // event insert + mockQuery.mockResolvedValueOnce({ rows: [] }); // webhook lookup (link_id set) + + const app = await buildApp(); + const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: orphanEvent }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ eventId: EVENT_ID, acknowledged: true }); + + await app.close(); + }); + + it('tells a client what to do when recovery cannot complete', async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }); // lookup: gone + mockQuery.mockResolvedValueOnce({ rows: [] }); // insert: no row + mockQuery.mockResolvedValueOnce({ rows: [] }); // re-read: still nothing + + const app = await buildApp(); + const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: orphanEvent }); expect(res.statusCode).toBe(404); + expect(res.json()).toEqual({ + error: 'Install event not found', + code: 'INSTALL_NOT_FOUND', + action: 'reregister', + }); + + await app.close(); + }); + + it('leaves a known install untouched', async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] }); + mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] }); + + const app = await buildApp(); + const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: orphanEvent }); + + expect(res.statusCode).toBe(200); + // Two queries only: the lookup and the event. No recovery insert. + expect(mockQuery.mock.calls).toHaveLength(2); + expect(mockQuery.mock.calls[1][0]).toMatch(/INSERT INTO in_app_events/); + await app.close(); }); }); diff --git a/src/routes/sdk.ts b/src/routes/sdk.ts index ec77e2b..854f5d4 100644 --- a/src/routes/sdk.ts +++ b/src/routes/sdk.ts @@ -15,6 +15,57 @@ import { parseUserAgent, getLocationFromIP, detectDevice } from '../lib/utils.js import { emitClickEvent } from '../lib/event-emitter.js'; import { classifyBot, edgeBotSignal } from '../lib/bot-detection.js'; +/** + * An event arrived for an install this server does not have. Put the install + * back rather than refusing the event. + * + * The id lives in the app's own storage for the life of the install, so a + * missing row is not a transient condition: every event that device will ever + * send refers to an id we cannot resolve, and refusing them means the app goes + * silent forever with no way to recover. A deployment that prunes analytics on + * a retention window reaches this state the moment an install outlives the + * window, which is the common case and not an edge one. + * + * The recovered row is deliberately thin. We know the device exists and which + * SDK it runs; we do not know what brought it here, and we must not invent + * that: `attribution_method` says `recovered` and there is no link, click or + * confidence score, so nothing downstream can mistake it for a fresh + * attributed install. The fingerprint is a marker rather than a hash — it can + * never equal a real one, so it cannot match a click by accident. + * + * Creating a row from a client-supplied id adds no exposure that + * `POST /api/sdk/v1/install` does not already have: that endpoint is + * unauthenticated and creates an install row for anyone who calls it. The only + * difference here is who chose the id, and a UUID the caller picked is still + * only ever their own row. + * + * Returns the row, or null when a concurrent request already created it and we + * lost the race but cannot read it back — the caller then answers 404 as + * before, and the SDK's next event succeeds. + */ +async function recoverInstall( + installId: string, + sdkName?: string, + sdkVersion?: string +): Promise<{ id: string; link_id: string | null } | null> { + const inserted = await db.query( + `INSERT INTO install_events + (id, fingerprint_hash, attribution_method, installed_at, first_open_at, sdk_name, sdk_version) + VALUES ($1, $2, 'recovered', NOW(), NOW(), $3, $4) + ON CONFLICT (id) DO NOTHING + RETURNING id, link_id`, + [installId, `recovered:${installId}`, sdkName || null, sdkVersion || null] + ); + if (inserted.rows[0]) return inserted.rows[0]; + + // Lost the race with a concurrent event for the same install. + const existing = await db.query( + `SELECT id, link_id FROM install_events WHERE id = $1`, + [installId] + ); + return existing.rows[0] ?? null; +} + /** * SDK Routes - Mobile SDK endpoints for deferred deep linking * These endpoints are used by the mobile SDKs to report installs and retrieve attribution data @@ -228,6 +279,13 @@ export async function sdkRoutes(fastify: FastifyInstance) { * Response: * - eventId: UUID of the tracked event * - acknowledged: Boolean confirmation + * + * An `installId` this server no longer has is recovered rather than + * refused: the install is recorded again, marked `recovered`, and the event + * is stored against it. See recoverInstall above. A 404 with + * `code: 'INSTALL_NOT_FOUND'` and `action: 'reregister'` is returned only in + * the rare case where recovery itself could not complete; an SDK seeing it + * should register a fresh install rather than retry the same id. */ fastify.post('/api/sdk/v1/event', async (request, reply) => { const schema = z.object({ @@ -249,19 +307,23 @@ export async function sdkRoutes(fastify: FastifyInstance) { const body = schema.parse(request.body); try { - // Verify install exists and get link_id for webhook lookup + // The install this event belongs to, recovered if we no longer have it. const installCheck = await db.query( `SELECT id, link_id FROM install_events WHERE id = $1`, [body.installId] ); - if (installCheck.rows.length === 0) { + const install = + installCheck.rows[0] ?? + (await recoverInstall(body.installId, body.sdkName, body.sdkVersion)); + + if (!install) { return reply.status(404).send({ error: 'Install event not found', + code: 'INSTALL_NOT_FOUND', + action: 'reregister', }); } - - const install = installCheck.rows[0]; const eventTimestamp = body.timestamp || new Date().toISOString(); const eventDataJson = JSON.stringify(body.eventData || {}); From 6480032bcb7aa0534f8751a29971b2a359b3b679 Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Tue, 22 Sep 2026 22:08:19 -0700 Subject: [PATCH 2/3] docs(sdk): a lost install row is ordinary operation, not only a retention job --- README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ceeed69..ac102d1 100644 --- a/README.md +++ b/README.md @@ -306,10 +306,14 @@ GET /api/sdk/v1/health # Health check An event whose `installId` this server no longer has is **recovered, not refused**: the install is recorded again (marked `recovered`, with no link, click or confidence score, so it is never mistaken for a fresh attributed -install) and the event is stored against it. This matters if you prune -analytics on a retention window — the id lives in the app's storage for the -life of the install, so without recovery a device that outlived the window -would be refused forever with no way back. The 404 that remains carries +install) and the event is stored against it. + +This matters because the id lives in the app's storage for the life of the +install while the server's copy may not: a restore from an older backup, a +manual cleanup, an app build pointed at a fresh database, or an analytics +retention job all leave a live device holding an id nothing resolves. The +bundled SDKs store the id once and never re-register, so without recovery +that device is refused forever with no way back. The 404 that remains carries `code: "INSTALL_NOT_FOUND"` and `action: "reregister"`. ### Health From 6c635b24180258d34257d75538f52c8af4ba8e5b Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Tue, 22 Sep 2026 22:08:39 -0700 Subject: [PATCH 3/3] docs(sdk): say the same in the code comment as in the README --- src/routes/sdk.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/routes/sdk.ts b/src/routes/sdk.ts index 854f5d4..44b3863 100644 --- a/src/routes/sdk.ts +++ b/src/routes/sdk.ts @@ -19,12 +19,14 @@ import { classifyBot, edgeBotSignal } from '../lib/bot-detection.js'; * An event arrived for an install this server does not have. Put the install * back rather than refusing the event. * - * The id lives in the app's own storage for the life of the install, so a - * missing row is not a transient condition: every event that device will ever - * send refers to an id we cannot resolve, and refusing them means the app goes - * silent forever with no way to recover. A deployment that prunes analytics on - * a retention window reaches this state the moment an install outlives the - * window, which is the common case and not an edge one. + * The id lives in the app's own storage for the life of the install while our + * copy may not, so a missing row is not a transient condition: every event + * that device will ever send refers to an id we cannot resolve, and the SDKs + * store the id once and never re-register, so refusing them means the app goes + * silent forever with no way back. A row goes missing in ordinary operation — + * a restore from an older backup, a manual cleanup, a build pointed at a fresh + * database, an analytics retention job — and none of those are the device's + * fault. * * The recovered row is deliberately thin. We know the device exists and which * SDK it runs; we do not know what brought it here, and we must not invent