npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nativesquare/soma

v0.3.0

Published

A Convex component that normalizes health and fitness data from multiple wearable providers into a single, consistent schema.

Readme

@nativesquare/soma

A Convex component that normalizes health and fitness data from multiple wearable providers into a single, consistent schema.

Soma gives you a provider-agnostic data store for activities, sleep, body metrics, daily summaries, nutrition, menstruation, and athlete profiles — all stored as reactive Convex documents with automatic deduplication. You handle authentication and data fetching from each provider; Soma handles storage, normalization, and querying.

Installation

npm install @nativesquare/soma

Quick Start

1. Register the component

Create (or update) your convex/convex.config.ts:

// convex/convex.config.ts
import { defineApp } from "convex/server";
import soma from "@nativesquare/soma/convex.config.js";

const app = defineApp();
app.use(soma);

export default app;

2. Instantiate the client

In any Convex function file, create a Soma instance:

import { Soma } from "@nativesquare/soma";
import { components } from "./_generated/api";

const soma = new Soma(components.soma);

3. Connect a user to a provider

After your app completes the provider's OAuth flow, register the connection:

import { Soma } from "@nativesquare/soma";
import { components } from "./_generated/api";
import { mutation } from "./_generated/server";
import { v } from "convex/values";

const soma = new Soma(components.soma);

export const connectProvider = mutation({
  args: { userId: v.string(), provider: v.string() },
  returns: v.string(),
  handler: async (ctx, args) => {
    return await soma.connect(ctx, {
      userId: args.userId,
      provider: args.provider, // "GARMIN", "FITBIT", "OURA", etc.
    });
  },
});

4. Ingest health data

Use the ingestion methods to store normalized data. Soma automatically deduplicates — re-ingesting the same record updates it rather than creating a duplicate.

export const ingestWorkout = mutation({
  args: { connectionId: v.string(), userId: v.string(), data: v.any() },
  returns: v.string(),
  handler: async (ctx, args) => {
    return await soma.ingestActivity(ctx, {
      connectionId: args.connectionId,
      userId: args.userId,
      ...args.data,
    });
  },
});

Connection Management

Connections link a user in your app to a wearable provider. Each connection is uniquely identified by a userId + provider pair.

Connect

const connectionId = await soma.connect(ctx, {
  userId: "user_123",
  provider: "GARMIN",
});

Creates the connection if it doesn't exist, or re-activates it if it was previously disconnected. Idempotent — calling twice is a no-op.

Disconnect

await soma.disconnect(ctx, {
  userId: "user_123",
  provider: "GARMIN",
});

Sets the connection to inactive. Does not delete any synced data, so re-connecting later preserves historical records.

Query connections

// Get all connections for a user (active and inactive)
const connections = await soma.listConnections(ctx, { userId: "user_123" });

// Check if a specific provider is connected
const garmin = await soma.getConnectionByProvider(ctx, {
  userId: "user_123",
  provider: "GARMIN",
});
const isConnected = garmin?.active === true;

// Get a connection by its document ID
const connection = await soma.getConnection(ctx, { connectionId: "..." });

Update a connection

await soma.updateConnection(ctx, {
  connectionId: "...",
  active: false,
  lastDataUpdate: new Date().toISOString(),
});

Delete a connection

await soma.deleteConnection(ctx, { connectionId: "..." });

Hard deletes the connection record. Synced health data linked to this connection is not cascade-deleted.

Data Ingestion

Soma stores health data across seven normalized tables. Each ingestion method accepts a connectionId, userId, and the data fields for that type.

All methods except menstruation perform upsert — if a matching record already exists, it is updated rather than duplicated.

| Method | Table | Dedup Key | | --------------------- | --------------- | ------------------------------------------------------ | | ingestActivity | activities | connectionId + metadata.summary_id | | ingestSleep | sleep | connectionId + metadata.summary_id | | ingestBody | body | connectionId + metadata.start_time + end_time | | ingestDaily | daily | connectionId + metadata.start_time + end_time | | ingestNutrition | nutrition | connectionId + metadata.start_time + end_time | | ingestMenstruation | menstruation | (append-only — no dedup) | | ingestAthlete | athletes | connectionId (one per connection) |

Example: ingesting from Apple HealthKit

Soma ships HealthKit transformers that convert native HealthKit objects into Soma's normalized schema:

import { transformWorkout } from "@nativesquare/soma/healthkit";

// In your React Native app — after reading a workout from HealthKit:
const data = transformWorkout(hkWorkout);

// Then send `data` to your Convex mutation:
const activityId = await soma.ingestActivity(ctx, {
  connectionId,
  userId,
  ...data,
});

Available transformers from @nativesquare/soma/healthkit:

| Transformer | Input | Output | | ---------------------------- | ------------------- | -------------- | | transformWorkout | HKWorkout | Activity data | | transformSleep | HKCategorySample | Sleep data | | transformBody | HKQuantitySample | Body data | | transformDaily | HKQuantitySample | Daily data | | transformDailyFromSummary | HKActivitySummary | Daily data | | transformNutrition | HKQuantitySample | Nutrition data | | transformMenstruation | HKCategorySample | Menstruation | | transformAthlete | HKCharacteristics | Athlete data |

Also exports enum mapping utilities: mapActivityType, mapSleepLevel, isAsleepCategory, and mapMenstruationFlow.

Strava Integration

Soma includes a built-in Strava integration that handles the full OAuth lifecycle — authorization URL generation, token exchange, secure token storage, automatic token refresh, and data syncing. No manual API calls or token management required.

There are two ways to use the Strava integration:

  • Managed — use soma.connectStrava(), soma.syncStrava(), and soma.disconnectStrava() which handle everything end-to-end including OAuth token storage inside the component.
  • Manual — use the StravaClient, syncActivities, and syncAthlete utilities from @nativesquare/soma/strava for full control over the flow (e.g., custom token storage, webhook-driven sync).

Prerequisites

  1. Create a Strava API Application at strava.com/settings/api to obtain your Client ID and Client Secret.

  2. Set environment variables in the Convex dashboard:

    | Variable | Required | Description | | --------------------- | -------- | --------------------------------------------- | | STRAVA_CLIENT_ID | Yes | Your Strava application's Client ID | | STRAVA_CLIENT_SECRET| Yes | Your Strava application's Client Secret | | STRAVA_BASE_URL | No | Override for testing with a mock server |

    Alternatively, pass credentials directly to the Soma constructor:

    const soma = new Soma(components.soma, {
      strava: {
        clientId: "your-client-id",
        clientSecret: "your-client-secret",
      },
    });

Managed flow (recommended)

The managed flow handles OAuth, token storage, and syncing in a single call.

Step 1: Generate the authorization URL

import { Soma } from "@nativesquare/soma";
import { components } from "./_generated/api";
import { query } from "./_generated/server";
import { v } from "convex/values";

const soma = new Soma(components.soma);

export const getStravaAuthUrl = query({
  args: { redirectUri: v.string() },
  handler: async (_ctx, { redirectUri }) => {
    return soma.getStravaAuthUrl({ redirectUri });
  },
});

Redirect the user to this URL. After they authorize, Strava redirects back to your redirectUri with a code query parameter.

Step 2: Handle the OAuth callback

import { action } from "./_generated/server";

export const handleStravaCallback = action({
  args: { userId: v.string(), code: v.string() },
  handler: async (ctx, { userId, code }) => {
    // Exchanges the code, stores tokens, syncs athlete + activities
    return await soma.connectStrava(ctx, { userId, code });
  },
});

connectStrava returns { connectionId, synced, errors } — the number of activities synced and any per-activity errors.

Step 3: Incremental sync

export const syncStravaActivities = action({
  args: { userId: v.string() },
  handler: async (ctx, { userId }) => {
    // Auto-refreshes token if expired, then syncs
    return await soma.syncStrava(ctx, { userId, includeStreams: true });
  },
});

Pass after (Unix epoch seconds) to only sync activities after a given timestamp, useful for incremental updates.

Step 4: Disconnect

export const disconnectStrava = action({
  args: { userId: v.string() },
  handler: async (ctx, { userId }) => {
    // Revokes token at Strava, deletes stored tokens, sets connection inactive
    await soma.disconnectStrava(ctx, { userId });
  },
});

Step 5: Query Strava data

Once connected and synced, use the standard Soma query methods — Strava data is stored in the same normalized tables as any other provider:

export const getStravaConnection = query({
  args: { userId: v.string() },
  handler: async (ctx, { userId }) => {
    return await soma.getConnectionByProvider(ctx, {
      userId,
      provider: "STRAVA",
    });
  },
});

export const listStravaActivities = query({
  args: { userId: v.string() },
  handler: async (ctx, { userId }) => {
    return await soma.listActivities(ctx, { userId, order: "desc" });
  },
});

export const getStravaAthlete = query({
  args: { userId: v.string() },
  handler: async (ctx, { userId }) => {
    const athletes = await soma.listAthletes(ctx, { userId });
    return athletes[0] ?? null;
  },
});

Manual flow (advanced)

For full control over the OAuth flow, token storage, and sync timing, use the low-level utilities from @nativesquare/soma/strava. This is useful when you want to manage tokens yourself, use webhooks for sync triggers, or integrate with a custom auth system.

import { Soma } from "@nativesquare/soma";
import {
  StravaClient,
  syncActivities,
  syncAthlete,
} from "@nativesquare/soma/strava";
import { components } from "./_generated/api";
import { internalAction } from "./_generated/server";
import { v } from "convex/values";

const soma = new Soma(components.soma);

export const syncStrava = internalAction({
  args: {
    userId: v.string(),
    connectionId: v.string(),
    accessToken: v.string(),
  },
  handler: async (ctx, { userId, connectionId, accessToken }) => {
    const client = new StravaClient({ accessToken });

    // Sync the athlete profile
    await syncAthlete({ client, soma, ctx, connectionId, userId });

    // Sync activities (with streams and lap data)
    const result = await syncActivities({
      client,
      soma,
      ctx,
      connectionId,
      userId,
      includeStreams: true,
      includeLaps: true,
    });

    // Update the connection timestamp
    await soma.updateConnection(ctx, {
      connectionId,
      lastDataUpdate: new Date().toISOString(),
    });

    return result; // { synced: number, errors: [...] }
  },
});

You can also use the transformers and client independently:

import {
  StravaClient,
  transformActivity,
  transformAthlete,
  buildAuthUrl,
  exchangeCode,
  refreshToken,
} from "@nativesquare/soma/strava";

Available Strava exports (@nativesquare/soma/strava)

| Export | Type | Description | | -------------------- | ----------- | ------------------------------------------------ | | StravaClient | Class | Typed Strava API client (uses global fetch) | | StravaApiError | Class | Error thrown for non-OK Strava API responses | | syncActivities | Function | Sync activities from Strava into Soma | | syncAthlete | Function | Sync athlete profile from Strava into Soma | | transformActivity | Function | Transform Strava activity → Soma schema | | transformAthlete | Function | Transform Strava athlete → Soma schema | | mapSportType | Function | Map Strava sport type → Soma activity type enum | | buildAuthUrl | Function | Build the OAuth authorization URL | | exchangeCode | Function | Exchange OAuth code for tokens | | refreshToken | Function | Refresh an expired access token |

Data Queries

Soma provides query methods for reading back health data with optional time-range filtering. Each data table exposes two query variants:

  • list{Table} — returns all matching records (with optional limit)
  • paginate{Table} — cursor-based pagination for large result sets

Filtering by time range

All queries require a userId. Most tables also accept optional startTime and endTime arguments (ISO-8601 strings) to filter on metadata.start_time:

// All activities for a user (newest first by default):
const all = await soma.listActivities(ctx, { userId: "user_123" });

// Last 7 days:
const sevenDaysAgo = new Date(Date.now() - 7 * 86_400_000).toISOString();
const recentActivities = await soma.listActivities(ctx, {
  userId: "user_123",
  startTime: sevenDaysAgo,
});

// Last 28 days of sleep data:
const twentyEightDaysAgo = new Date(Date.now() - 28 * 86_400_000).toISOString();
const sleepHistory = await soma.listSleep(ctx, {
  userId: "user_123",
  startTime: twentyEightDaysAgo,
});

// Custom date range:
const dailySummaries = await soma.listDaily(ctx, {
  userId: "user_123",
  startTime: "2025-01-01T00:00:00.000Z",
  endTime: "2025-01-31T23:59:59.999Z",
  order: "asc", // oldest first
});

// Limit results:
const lastFiveWorkouts = await soma.listActivities(ctx, {
  userId: "user_123",
  limit: 5,
});

Pagination

For large datasets or infinite-scroll UIs, use the paginated variants with Convex's paginationOpts:

const page = await soma.paginateActivities(ctx, {
  userId: "user_123",
  startTime: sevenDaysAgo,
  paginationOpts: { numItems: 10, cursor: null }, // null = first page
});

// page.page          — array of activity documents
// page.isDone        — true if no more results
// page.continueCursor — pass as cursor to load the next page

Available query methods

| Method | Table | Time-range filter | Notes | | ------------------------ | -------------- | ----------------- | -------------------------- | | listActivities | activities | Yes | | | paginateActivities | activities | Yes | | | listSleep | sleep | Yes | | | paginateSleep | sleep | Yes | | | listBody | body | Yes | | | paginateBody | body | Yes | | | listDaily | daily | Yes | | | paginateDaily | daily | Yes | | | listNutrition | nutrition | Yes | | | paginateNutrition | nutrition | Yes | | | listMenstruation | menstruation | Yes | | | paginateMenstruation | menstruation | Yes | | | listAthletes | athletes | No | One per connection | | getAthlete | athletes | No | By connectionId |

Schema

Soma uses Terra's data model as its schema standard, but has no runtime dependency on Terra — data can come from any source as long as it conforms to the validators.

Tables

| Table | Description | | ---------------- | ------------------------------------------------------- | | connections | User ↔ provider links | | athletes | User profile data from providers | | activities | Workouts and exercise sessions | | body | Body metrics (heart rate, blood pressure, etc.) | | daily | Daily activity summaries (steps, calories) | | sleep | Sleep session data | | menstruation | Menstruation and fertility data | | nutrition | Food, drink, macro/micronutrient data | | plannedWorkouts| Scheduled/planned workouts | | providerTokens | OAuth tokens for cloud-based providers (Strava, etc.) |

All data fields are optional (v.optional) unless explicitly required. This accommodates providers that support different subsets of health data.

API Reference

Connection Methods

| Method | Context | Description | | ---------------------------------------------------- | -------- | --------------------------------------------- | | connect(ctx, { userId, provider }) | Mutation | Connect user to provider (idempotent) | | disconnect(ctx, { userId, provider }) | Mutation | Disconnect user (soft — preserves data) | | getConnection(ctx, { connectionId }) | Query | Get connection by document ID | | getConnectionByProvider(ctx, { userId, provider }) | Query | Get connection for a user–provider pair | | listConnections(ctx, { userId }) | Query | List all connections for a user | | updateConnection(ctx, { connectionId, ... }) | Mutation | Update connection fields | | deleteConnection(ctx, { connectionId }) | Mutation | Hard-delete connection record |

Ingestion Methods

| Method | Context | Description | | ---------------------------------------------- | -------- | ------------------------------- | | ingestActivity(ctx, { connectionId, ... }) | Mutation | Upsert a workout record | | ingestSleep(ctx, { connectionId, ... }) | Mutation | Upsert a sleep session | | ingestBody(ctx, { connectionId, ... }) | Mutation | Upsert body metrics | | ingestDaily(ctx, { connectionId, ... }) | Mutation | Upsert daily activity summary | | ingestNutrition(ctx, { connectionId, ... }) | Mutation | Upsert nutrition record | | ingestMenstruation(ctx, { connectionId, ... })| Mutation | Append menstruation record | | ingestAthlete(ctx, { connectionId, ... }) | Mutation | Upsert athlete profile |

Data Query Methods

All list* methods accept { userId, startTime?, endTime?, order?, limit? }. All paginate* methods accept { userId, startTime?, endTime?, paginationOpts }.

| Method | Context | Description | | ---------------------------------------------------------- | ------- | --------------------------------------- | | listActivities(ctx, { userId, ... }) | Query | List activities with time-range filter | | paginateActivities(ctx, { userId, ..., paginationOpts }) | Query | Paginate activities | | listSleep(ctx, { userId, ... }) | Query | List sleep records | | paginateSleep(ctx, { userId, ..., paginationOpts }) | Query | Paginate sleep records | | listBody(ctx, { userId, ... }) | Query | List body metrics | | paginateBody(ctx, { userId, ..., paginationOpts }) | Query | Paginate body metrics | | listDaily(ctx, { userId, ... }) | Query | List daily summaries | | paginateDaily(ctx, { userId, ..., paginationOpts }) | Query | Paginate daily summaries | | listNutrition(ctx, { userId, ... }) | Query | List nutrition records | | paginateNutrition(ctx, { userId, ..., paginationOpts }) | Query | Paginate nutrition records | | listMenstruation(ctx, { userId, ... }) | Query | List menstruation records | | paginateMenstruation(ctx, { userId, ..., paginationOpts })| Query | Paginate menstruation records | | listAthletes(ctx, { userId }) | Query | List athlete profiles for a user | | getAthlete(ctx, { connectionId }) | Query | Get athlete profile for a connection |

Strava Methods

These methods require Strava credentials to be configured (via environment variables or the Soma constructor). They handle OAuth, token storage, and data syncing end-to-end.

| Method | Context | Description | | ------------------------------------------------------------------- | ------- | ---------------------------------------------------- | | getStravaAuthUrl({ redirectUri, scope?, state? }) | Pure | Build the Strava OAuth authorization URL | | connectStrava(ctx, { userId, code, includeStreams? }) | Action | Handle OAuth callback: exchange code, store tokens, sync all data | | syncStrava(ctx, { userId, includeStreams?, after? }) | Action | Incremental sync with auto token refresh | | disconnectStrava(ctx, { userId }) | Action | Revoke token, delete stored tokens, deactivate connection |

Direct Component Access

For advanced use cases, you can call component functions directly instead of using the Soma class:

import { query, mutation } from "./_generated/server";
import { components } from "./_generated/api";
import { v } from "convex/values";

// Query — use ctx.runQuery
export const listConnections = query({
  args: { userId: v.string() },
  returns: v.any(),
  handler: async (ctx, args) => {
    return await ctx.runQuery(components.soma.public.listConnections, args);
  },
});

// Mutation — use ctx.runMutation
export const connect = mutation({
  args: { userId: v.string(), provider: v.string() },
  returns: v.string(),
  handler: async (ctx, args) => {
    return await ctx.runMutation(components.soma.public.connect, args);
  },
});

All public functions are available under components.soma.public.*. Strava actions are under components.soma.strava.*. Use ctx.runQuery for queries, ctx.runMutation for mutations, and ctx.runAction for actions.

Exports

| Import Path | Contents | | -------------------------------- | ------------------------------------------------------------- | | @nativesquare/soma | Soma client class | | @nativesquare/soma/healthkit | Apple HealthKit → Soma transformers & types | | @nativesquare/soma/strava | Strava API client, transformers, OAuth helpers, sync utilities | | @nativesquare/soma/react | React hooks (coming soon) | | @nativesquare/soma/convex.config.js | Component config for convex.config.ts |

License

Apache-2.0