diff --git a/README.md b/README.md index 2debe67..ac102d1 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,19 @@ 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 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 ```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..44b3863 100644 --- a/src/routes/sdk.ts +++ b/src/routes/sdk.ts @@ -15,6 +15,59 @@ 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 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 + * 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 +281,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 +309,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 || {});