Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,50 @@ jobs:
flags: unittests
name: codecov-umbrella

test-with-database:
name: Test (with database)
runs-on: ubuntu-latest
timeout-minutes: 15

# Most of the suite is pure and needs nothing. The schema-initialisation
# lock is different: it is a property of Postgres locking, so it can only be
# demonstrated against a real server. Those tests skip themselves when
# DATABASE_URL is absent, which is why they need a job that provides one.
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: linkforty
POSTGRES_PASSWORD: linkforty
POSTGRES_DB: linkforty
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U linkforty"
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Run tests against Postgres
env:
# The suite creates and drops a scratch database, so this user needs
# CREATEDB. The default superuser has it.
DATABASE_URL: postgresql://linkforty:linkforty@localhost:5432/linkforty
run: npm test

build:
name: Build Package
runs-on: ubuntu-latest
Expand Down Expand Up @@ -205,14 +249,15 @@ jobs:
ci-success:
name: CI Success
runs-on: ubuntu-latest
needs: [lint-and-typecheck, test, build, package-validation]
needs: [lint-and-typecheck, test, test-with-database, build, package-validation]
if: always()

steps:
- name: Check job results
run: |
if [[ "${{ needs.lint-and-typecheck.result }}" != "success" ]] || \
[[ "${{ needs.test.result }}" != "success" ]] || \
[[ "${{ needs.test-with-database.result }}" != "success" ]] || \
[[ "${{ needs.build.result }}" != "success" ]] || \
[[ "${{ needs.package-validation.result }}" != "success" ]]; then
echo "One or more CI jobs failed"
Expand Down
8 changes: 4 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@

## <small>1.22.2 (2026-09-04)</small>

* fix(preview): serve Open Graph to iMessage, Reddit and Bluesky scrapers (SIT-359) (#42) ([0897627](https://github.com/LinkForty/core/commit/0897627)), closes [#42](https://github.com/LinkForty/core/issues/42)
* fix(preview): serve Open Graph to iMessage, Reddit and Bluesky scrapers (#42) ([0897627](https://github.com/LinkForty/core/commit/0897627)), closes [#42](https://github.com/LinkForty/core/issues/42)

## <small>1.22.1 (2026-09-01)</small>

* fix(analytics): record link-configured UTMs on click events (SIT-382) (#43) ([7386c8e](https://github.com/LinkForty/core/commit/7386c8e)), closes [#43](https://github.com/LinkForty/core/issues/43)
* fix(analytics): record link-configured UTMs on click events (#43) ([7386c8e](https://github.com/LinkForty/core/commit/7386c8e)), closes [#43](https://github.com/LinkForty/core/issues/43)

## 1.22.0 (2026-08-25)

* feat(sdk): project install and resolve deep links through one shape (SIT-349) (#41) ([b7b00ad](https://github.com/LinkForty/core/commit/b7b00ad)), closes [#41](https://github.com/LinkForty/core/issues/41)
* feat(sdk): project install and resolve deep links through one shape (#41) ([b7b00ad](https://github.com/LinkForty/core/commit/b7b00ad)), closes [#41](https://github.com/LinkForty/core/issues/41)

## 1.21.0 (2026-08-11)

Expand Down Expand Up @@ -45,7 +45,7 @@

## 1.16.0 (2026-06-08)

* feat(sdk): persist last-click attribution on in-app events (SIT-237) (#27) ([24dff3e](https://github.com/LinkForty/core/commit/24dff3e)), closes [#27](https://github.com/LinkForty/core/issues/27)
* feat(sdk): persist last-click attribution on in-app events (#27) ([24dff3e](https://github.com/LinkForty/core/commit/24dff3e)), closes [#27](https://github.com/LinkForty/core/issues/27)

## <small>1.15.2 (2026-06-04)</small>

Expand Down
93 changes: 93 additions & 0 deletions src/lib/database.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { SCHEMA_INIT_LOCK_KEY, db, initializeDatabase } from './database.js';

/**
* Schema initialisation must serialise across processes.
*
* `CREATE TABLE IF NOT EXISTS` is not atomic — the existence check and the
* creation are separate steps — so two connections initialising an empty
* database at once can both find a table absent and both try to create it. The
* loser fails with a `pg_type_typname_nsp_index` unique violation. An advisory
* lock makes the second caller wait instead.
*
* This needs a real database: the behaviour under test is a property of
* Postgres locking, and a mocked client would accept any sequence of queries.
* The suite skips itself when DATABASE_URL is absent so `npm test` stays green
* without one.
*/

const ADMIN_URL = process.env.DATABASE_URL;

if (!ADMIN_URL) {
// eslint-disable-next-line no-console
console.warn(
'\n database.test.ts skipped: set DATABASE_URL to run it.' +
'\n It creates and drops a scratch database, so the user needs CREATEDB.\n',
);
}

describe.skipIf(!ADMIN_URL)('initializeDatabase schema lock', () => {
// A scratch database per run: the race only exists against an EMPTY schema,
// and initialising a shared database would also mutate whatever is in it.
const scratchName = `lf_core_schema_init_${Date.now()}`;
let admin: pg.Client;
let scratchUrl: string;

beforeAll(async () => {
admin = new pg.Client({ connectionString: ADMIN_URL });
await admin.connect();
await admin.query(`CREATE DATABASE ${scratchName}`);

const url = new URL(ADMIN_URL as string);
url.pathname = `/${scratchName}`;
scratchUrl = url.toString();
}, 30_000);

afterAll(async () => {
// The pool `initializeDatabase` created still holds connections, and
// Postgres refuses to drop a database that has any.
await db?.end().catch(() => undefined);
await admin.query(`DROP DATABASE IF EXISTS ${scratchName} WITH (FORCE)`);
await admin.end();
}, 30_000);

it('waits for a concurrent initialisation rather than racing it', async () => {
// Stand in for a second instance that got there first.
const holder = new pg.Client({ connectionString: scratchUrl });
await holder.connect();
await holder.query('SELECT pg_advisory_lock($1)', [SCHEMA_INIT_LOCK_KEY]);

let settled = false;
const init = initializeDatabase({ url: scratchUrl, pool: { min: 1, max: 2 } }).then(() => {
settled = true;
});

// Long enough that an unsynchronised initialisation would have finished
// creating tables and resolved.
await new Promise((resolve) => setTimeout(resolve, 500));
expect(settled).toBe(false);

await holder.query('SELECT pg_advisory_unlock($1)', [SCHEMA_INIT_LOCK_KEY]);
await holder.end();

await init;
expect(settled).toBe(true);
}, 60_000);

it('releases the lock once initialisation completes', async () => {
// Runs after the case above, which left a fully initialised database. A
// leaked lock would block every subsequent boot, so assert it is gone.
const probe = new pg.Client({ connectionString: scratchUrl });
await probe.connect();
try {
const { rows } = await probe.query('SELECT pg_try_advisory_lock($1) AS acquired', [
SCHEMA_INIT_LOCK_KEY,
]);
expect(rows[0].acquired).toBe(true);
await probe.query('SELECT pg_advisory_unlock($1)', [SCHEMA_INIT_LOCK_KEY]);
} finally {
await probe.end();
}
}, 30_000);
});
48 changes: 44 additions & 4 deletions src/lib/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ async function connectWithRetry(maxRetries: number = 10, baseDelay: number = 100
throw new Error('Max retries exceeded');
}

/**
* Advisory lock key guarding schema initialisation.
*
* Exported so a host application taking its own advisory locks on the same
* database can avoid colliding with this one. Stable across releases.
*/
export const SCHEMA_INIT_LOCK_KEY = 4977261;

// Initialize database schema
export async function initializeDatabase(options: DatabaseOptions = {}) {
// Initialize pool
Expand All @@ -50,7 +58,32 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {

const client = await connectWithRetry();

// Serialise schema initialisation across processes.
//
// `CREATE TABLE IF NOT EXISTS` is not atomic: the existence check and the
// creation are separate steps, so two connections can both find the table
// absent and both try to create it. The loser fails with
// `duplicate key value violates unique constraint "pg_type_typname_nsp_index"`
// rather than quietly doing nothing. The same applies to the
// `CREATE INDEX IF NOT EXISTS` and `ADD COLUMN IF NOT EXISTS` statements
// below.
//
// That only bites when several processes initialise an *empty* database at
// once — the first boot of a new environment that starts more than one
// instance, or a test suite running files in parallel. Once the objects
// exist, every statement short-circuits and the race disappears, which is why
// it never shows up against an established database.
//
// A session-level advisory lock is enough: it is released automatically if
// the connection dies, so a process that crashes mid-initialisation cannot
// wedge the others. Waiting here is the desired behaviour — another instance
// is building the schema this process is about to use.
let lockHeld = false;

try {
await client.query('SELECT pg_advisory_lock($1)', [SCHEMA_INIT_LOCK_KEY]);
lockHeld = true;

// Organizations table (must be created before links, which references it).
//
// The redirect path LEFT JOINs this table to read `settings.appConfig`, which
Expand Down Expand Up @@ -473,7 +506,7 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
END $$;
`);

// Bot classification columns on click_events (SIT-298). Classified at
// Bot classification columns on click_events. Classified at
// ingestion (see lib/bot-detection.ts) and persisted so every consumer reads
// one consistent flag; analytics excludes is_bot rows. Backward compatible:
// legacy rows default to is_bot=false and age out of the retention window.
Expand All @@ -489,7 +522,7 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
END $$;
`);

// Attribution metadata on install_events (SIT-296): how the install was
// Attribution metadata on install_events: how the install was
// attributed ('fingerprint' | 'none') and which fingerprint signals matched.
// Makes attribution quality measurable. Backward compatible (NULL until set).
await client.query(`
Expand All @@ -504,7 +537,7 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
END $$;
`);

// Last-click attribution columns on in_app_events (SIT-237).
// Last-click attribution columns on in_app_events.
// Events (screen views + custom events) are attributed to the deep link that
// drove them, not just the original install link. The SDK stamps each event
// with the active link, when it opened, and the app-open session; the window
Expand All @@ -528,7 +561,7 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
END $$;
`);

// SDK identity columns (SIT-235) — name + version of the SDK that sent the
// SDK identity columns — name + version of the SDK that sent the
// install/event, for SDK version diagnostics. Persisted on BOTH tables:
// install_events (version at install time) and in_app_events because an app
// that updates keeps its original install row but sends events with the new
Expand Down Expand Up @@ -610,6 +643,13 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
console.error('Error initializing database:', error);
throw error;
} finally {
if (lockHeld) {
// Best effort: a broken connection has already dropped the lock, and
// failing to unlock must not mask the error that got us here.
await client
.query('SELECT pg_advisory_unlock($1)', [SCHEMA_INIT_LOCK_KEY])
.catch(() => undefined);
}
client.release();
}
}
2 changes: 1 addition & 1 deletion src/lib/fingerprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ export async function recordInstallEvent(
// Attempt to match install to a click
const match = await matchInstallToClick(fingerprintData, attributionWindowHours);

// Attribution metadata for measurement (SIT-296): install attribution is
// Attribution metadata for measurement: install attribution is
// fingerprint-based, so the method is 'fingerprint' on a match and 'none'
// (organic) otherwise; matched_factors records which signals matched.
const attributionMethod = match ? 'fingerprint' : 'none';
Expand Down
9 changes: 4 additions & 5 deletions src/routes/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ import { isSocialScraper, SCRAPER_PATTERNS } from './preview.js';
* - a matched human gets a meta-refresh interstitial instead of their
* redirect, which is the worse of the two
*
* Kept in sync with the same suite in Cloud
* (`cloud/backend/src/lib/social-preview-hook.test.ts`). Cloud's hook registers
* ahead of these routes, so this list governs self-hosted deployments.
* A host application may register its own preview handler ahead of these
* routes; this list is what governs a standalone deployment.
*/

/** Real server-side fetchers. Each must be served the preview page. */
Expand All @@ -29,8 +28,8 @@ const SCRAPERS: Array<[string, string]> = [
['Bing', 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)'],
['Alexa', 'ia_archiver (+http://www.alexa.com/site/help/webmasters)'],

// Added by SIT-359, matching Cloud. All three were verified in production
// falling through to a 302 before the Cloud fix.
// All three were observed in production falling through to a 302 — they were
// being served a redirect instead of the preview page.
[
'Apple / iMessage',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15 (Applebot/0.1; +http://www.apple.com/go/applebot)',
Expand Down
4 changes: 2 additions & 2 deletions src/routes/redirect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ describe('pickMobileFallbackUrl — Android in-app browser (FB/Instagram/Line/We
});

describe('pickMobileFallbackUrl — reporter scenario regression test', () => {
// Reproduces SIT-163: user creates a link with iOS, Android, and web fallback URLs;
// mobile visitor expects the App/Play Store; previously got the web fallback.
// Reproduces a reported regression: a link with iOS, Android and web fallback
// URLs served the web fallback to mobile visitors, who expect the App/Play Store.
it('iOS Safari → iOS App Store (was: web fallback)', () => {
const r = pickMobileFallbackUrl('ios', UA.iosSafari, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r?.url).toBe(URLS.iosStore);
Expand Down
2 changes: 1 addition & 1 deletion src/routes/redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ export async function redirectRoutes(
const { platform, platformVersion } = parseUserAgent(userAgent);
const { countryCode, countryName, region, city, latitude, longitude, timezone } = getLocationFromIP(ip);

// Classify bots at ingestion (SIT-298) — persisted on the row so
// Classify bots at ingestion — persisted on the row so
// analytics reads a consistent flag instead of re-detecting from the
// stored user-agent.
const { isBot, reason: botReason } = classifyBot(
Expand Down
10 changes: 5 additions & 5 deletions src/routes/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export async function sdkRoutes(fastify: FastifyInstance) {
platformVersion: z.string().optional(),
deviceId: z.string().optional(),
attributionWindowHours: z.number().optional(),
// SDK identity for health/version diagnostics (SIT-235). Free-form by
// SDK identity for health/version diagnostics. Free-form by
// design: a consumer tolerates/normalizes non-semver versions — we never
// reject a request over this metadata. Empty → null.
sdkName: z.string().max(50).optional(),
Expand Down Expand Up @@ -219,7 +219,7 @@ export async function sdkRoutes(fastify: FastifyInstance) {
* - eventData: Optional JSON data associated with the event
* - timestamp: Optional event timestamp (defaults to now)
*
* Last-click attribution stamp (SIT-237, all optional / backward compatible):
* Last-click attribution stamp (all optional / backward compatible):
* - attributedLinkId: UUID of the deep link currently credited (last-click)
* - attributedClickId: UUID of the originating click, when known
* - linkOpenedAt: ISO timestamp of when that deep link opened the app
Expand All @@ -239,7 +239,7 @@ export async function sdkRoutes(fastify: FastifyInstance) {
attributedClickId: z.string().uuid().optional(),
linkOpenedAt: z.string().datetime().optional(),
sessionId: z.string().uuid().optional(),
// SDK identity for version-health diagnostics (SIT-235). Free-form by
// SDK identity for version-health diagnostics. Free-form by
// design: a consumer tolerates/normalizes non-semver versions — we never
// reject a request over this metadata. Empty → null.
sdkName: z.string().max(50).optional(),
Expand Down Expand Up @@ -305,7 +305,7 @@ export async function sdkRoutes(fastify: FastifyInstance) {
// omits attributed_link_id). attributed_click_id is intentionally kept
// without a link: the orphaned-click case is expected, and a null
// attributed_link_id is the correct value for link-keyed aggregation
// (the SIT-261 consumer must not read it as a data bug).
// (a link-keyed consumer must not read it as a data bug).
eventResult = await db.query(
`INSERT INTO in_app_events
(install_id, event_name, event_data, event_timestamp,
Expand Down Expand Up @@ -558,7 +558,7 @@ export async function sdkRoutes(fastify: FastifyInstance) {
const { platform, platformVersion } = parseUserAgent(userAgent);
const { countryCode, countryName, region, city, latitude, longitude, timezone } = getLocationFromIP(ip);

// Classify bots at ingestion (SIT-298); persisted for consistent reads.
// Classify bots at ingestion; persisted for consistent reads.
const { isBot, reason: botReason } = classifyBot(
userAgent,
request.method,
Expand Down
Loading