# Data model

Cohorly's data model has three core concepts - events, users, and projects - all scoped by project. Event properties are stored as schemaless JSON, so you never define a schema up front.

## Projects

A **project** represents one app you are tracking. All data is scoped to a
project; create projects in the dashboard under **Settings**.

```ts
interface Project {
  id: number;
  name: string;
  token: string;      // UUID, authenticates ingestion
  created_at: number; // unix ms
}
```

The `token` is what SDKs send with each request; Cohorly resolves it to a
`project_id` and stores data under it. Ingestion is authed by this token,
while the admin and query API is authed by your account API key.

## Events

An **event** is an immutable record of something a user did. Events belong
to a project and a `distinct_id`, carry a `time`, and store their properties
as `jsonb`.

| Column        | Type    | Notes                                     |
| ------------- | ------- | ----------------------------------------- |
| `project_id`  | integer | Owning project.                           |
| `event`       | text    | Event name.                               |
| `distinct_id` | text    | User/device this event belongs to.        |
| `time`        | bigint  | Unix milliseconds.                        |
| `$insert_id`  | text    | Idempotency key for deduplication.        |
| `properties`  | jsonb   | Arbitrary event properties (GIN indexed). |

Properties are stored as `jsonb` with a GIN index, so filters and breakdowns
on property keys are queryable without a fixed schema.

## Users

A **user** (profile) aggregates identity and profile properties for a
`distinct_id` within a project. Profiles are mutated via the `/engage`
endpoint (the People API), while the derived stats come from the underlying
events.

```ts
interface UserProfile {
  distinct_id: string;
  properties: Record<string, unknown>; // from $set / $set_once / $add / $unset
  first_seen: number | null;           // unix ms
  last_seen: number | null;            // unix ms
  event_count: number;
}
```

## Identity

Every visitor starts **anonymous**: the SDK mints a random id and stamps it
as both `distinct_id` and `$device_id` on every event. Once you call
`identify(id)`, later events carry your real `distinct_id` and `$user_id`,
while `$device_id` stays the same - it survives `identify()` so the device
can still be recognized after login.

That shared `$device_id` is what lets Cohorly fold pre-login activity into
the post-login profile automatically. The first event after `identify()`
that carries a `$device_id` and `$user_id` you have not seen linked before is
enough - no separate call is required. Cohorly retroactively **links** the
anonymous id into the identified user's identity cluster: history already
stored under the anonymous id is rewritten onto the real `distinct_id`, so a
funnel or retention query spanning the login boundary counts one person, not
two. This is a one-time, one-directional operation - once ids are linked,
they cannot be split back apart. It only rewrites history this way when the
absorbed id's own stored events prove it anonymous (every one stamped with a
matching `$device_id`, none carrying a `$user_id` of its own) or when it has
no history at all; an id that has itself ever identified a user keeps its
history exactly where it is.

Server SDKs that pass an explicit `distinct_id` on every call (Node.js,
NestJS, Python, PHP, Go) have no `$device_id` to signal with, so they use the
same mechanism explicitly via `alias(previousId, distinctId)`, which calls
the ingestion API's `/alias` endpoint. It applies the same anonymity check to
`previousId`: when `previousId`'s own history is anonymous or empty, the link
is retroactive just like the implicit case above. Otherwise - the usual case
for a pre-signup id that already tracked real activity - `alias()` still
succeeds, but only routes *future* events under `previousId` to `distinctId`;
nothing already stored moves. See [Event
properties](/reference/event-properties) for the `$device_id`/`$user_id`
keys and [Ingestion API](/api/ingestion) for `/alias`.

Call `reset()` on logout. Without it, the next person to use a shared
device (a kiosk, a shared laptop) keeps the previous person's `$device_id`,
and their first post-login event links the new account into the old
person's history. `reset()` mints a fresh `$device_id` along with a fresh
anonymous `distinct_id`, so each login on a shared device starts its own
identity.

## Multi-project isolation

Every table is keyed by `project_id`, and every query resolves a target
project (from the token on ingestion, or the `projectId` parameter on the
admin/query API). This keeps data for separate apps fully isolated within
one Cohorly deployment.
