npm package: @levo-so/core
Description: Levo common utilities
Author: Levo Engineering
Latest version: 0.1.82

# @levo-so/core

[![npm version](https://img.shields.io/npm/v/@levo-so/core)](https://www.npmjs.com/package/@levo-so/core)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue)](https://www.typescriptlang.org/)
[![ESM only](https://img.shields.io/badge/ESM-only-brightgreen)](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c)

The foundational JavaScript/TypeScript client for the [Levo](https://levo.so) platform. Every Levo-powered application — whether a website, web app, or backend service — starts here. It handles authentication, dynamic form collections, blog content, media, and all communication with the Levo API.

---

## Table of Contents

- [Installation](#installation)
- [Concepts](#concepts)
- [Setup](#setup)
- [Modules](#modules)
  - [Membership](#membership)
  - [Collection](#collection)
  - [Blog](#blog)
  - [Media](#media)
- [Error Handling](#error-handling)
- [Querying & Filtering](#querying--filtering)
- [Utilities](#utilities)

---

## Installation

```bash
# pnpm
pnpm add @levo-so/core

# npm
npm install @levo-so/core

# bun
bun add @levo-so/core
```

> ESM only. Requires a bundler or Node 18+.

---

## Concepts

Before jumping into code, a few Levo concepts worth understanding:

**Workspace** — Everything in Levo lives inside a workspace. Think of it as your project or organization account. Your workspace ID is visible in the Levo dashboard under Settings, and looks like `W1A2B3C4` (a `W` followed by 7 uppercase characters). All API requests are scoped to this workspace.

**Collections** — Levo's dynamic form and content system. A collection is a schema — it defines fields, their types, validation rules, and how they should be rendered (the widget). You submit data into a collection, and can query that data back. They power everything from contact forms to complex multi-step data entry flows.

**Membership** — Levo's built-in user identity and authentication system. It supports multiple sign-in methods (OAuth, magic links, OTP, passwords) and manages sessions natively, so you don't need a separate auth provider.

**APP_MODE vs NODE_ENV** — These are distinct. `NODE_ENV` is the standard build-time environment (`"development"` or `"production"`) and affects internal behavior like log verbosity and whether errors are shipped to Levo APM. `APP_MODE` is a Levo-specific concept for your deployment context — for example, `"production"`, `"staging"`, or `"preview"`. A staging server often runs with `NODE_ENV=production` (optimized build) but `APP_MODE=staging` (staging data, restricted features). `@levo-so/core` stores it but doesn't act on it — it's available via `client.APP_MODE` for your application logic.

---

## Setup

Create a single client instance and share it across your application:

```typescript
import { createLevoClient } from "@levo-so/core";

const client = createLevoClient({
  workspace: "W1234ABC",                      // Your workspace ID from the Levo dashboard
  apiUrl: "https://public-api.levo.so",       // Levo's public API — use as-is unless self-hosting
  NODE_ENV: process.env.NODE_ENV,
  APP_MODE: process.env.APP_MODE,             // e.g. "production", "staging", "preview"
});
```

In a browser context, the client automatically reads the current page URL, title, and referrer and attaches them to every request — this is used for analytics and debugging in the Levo dashboard. You don't need to configure this manually.

For SPAs where the URL changes without a full page load, call `client.updatePageContext({ url, title })` on navigation.

---

## Modules

### Membership

Levo handles identity for your users. The membership module covers the full lifecycle: signing up, signing in through any method, managing the session, and updating profile data. Sessions are cookie-based and managed by Levo automatically.

**Sign-in methods available:**

- **OAuth** — Redirect users to Google, LinkedIn, or Microsoft. Call `getOAuthURL()` to get the provider-specific redirect URL, then handle the callback on your end.
- **Magic link** — Send a one-click login email with `requestMagicLink()`. The user clicks the link and lands on your callback URL.
- **OTP** — Send a one-time code via email or WhatsApp with `requestOtp()`, then verify it with `signInWithOtp()`.
- **Password** — Traditional email + password auth via `signInWithPassword()` and `signUpWithPassword()`.

```typescript
// Get OAuth redirect URLs for all providers
const urls = await client.membership.getOAuthURL();
window.location.href = urls.google;

// OTP flow
await client.membership.requestOtp({ email: "user@example.com", type: "email" });
const account = await client.membership.signInWithOtp({ email, otp: "123456" });

// Password flow
const account = await client.membership.signInWithPassword({ email, password });

// Check current session — returns null if not signed in
const me = await client.membership.getMe();

// Update profile
await client.membership.updateMe({ first_name: "Jane", username: "jane" });

await client.membership.signOut();
```

---

### Collection

A collection is a schema — it tells you what fields exist, what type they are, and how they should be presented (the widget). Your application uses this schema to render forms, validate input, and submit data. Levo stores the submissions and makes them queryable.

Collections also support a **draft workflow**: save in-progress data before the user is ready to submit, let them return and continue, then finalize with a submit call. This is useful for multi-step forms or forms where users might leave and come back.

```typescript
// Fetch the schema for a collection — use this to render your form
const { content } = await client.collection.getAllCollections();
const schema = content.data.find(c => c.key === "my-form");

// Submit completed form data
await client.collection.saveCollectionData({
  collection_id: schema.id,
  data: { name: "Alice", email: "alice@example.com", message: "Hello" },
});

// Query existing submissions (e.g. to show a user their past entries)
const { content } = await client.collection.getCollectionContentList({
  collection_key: "my-form",
  page: 1,
  limit: 20,
  where: { status: { equals: "approved" } },
});

// Draft → submit (for multi-step or save-and-continue flows)
const draft = await client.collection.saveDraftEntry("my-form", partialData);
await client.collection.editDraftEntry("my-form", draft.content.data.id, moreData);
await client.collection.submitDraftEntry("my-form", { id: draft.content.data.id });
```

---

### Blog

Pull blog content from any Levo-hosted blog into your application. Posts come back with everything you'd expect: HTML and structured JSON content, cover images (with responsive variants at multiple widths), reading time, authors, tags, categories, and full SEO metadata.

The blog key is the identifier for your specific blog, visible in the Levo dashboard.

```typescript
// All published posts, newest first
const { content } = await client.blog.getAllBlogs("my-blog-key", {
  page: 1,
  limit: 10,
  sort: { published_at: "desc" },
  where: { status: { equals: "published" } },
});
// content.data → IPost[]
// content.meta.total → total count for pagination

// A single post by its slug
const { content } = await client.blog.getSingleBlog("my-blog-key", "my-post-slug");
// content.data → IPost (includes content.html, content.json, cover_image, og_image, etc.)
```

---

### Media

Upload files to Levo's media library and get back fully resolved objects ready to use in your UI. Images are automatically processed into responsive variants from `320w` up to `2560w`, which you can use directly in `<img srcset>` or `<picture>` elements.

```typescript
const formData = new FormData();
formData.append("files", file);

const { content } = await client.media.mediaBulkUpload(formData);
// content.data[0].location   → full CDN URL
// content.data[0].srcset     → { "320w": "...", "640w": "...", ... }
// content.data[0].metadata   → { mimetype, size }
```

---

## Error Handling

Every module method can throw. Rather than catching raw `WretchError` or `TypeError` objects and trying to parse them, use `getLevoError()` to normalize everything into a predictable `LevoError` shape regardless of what went wrong.

```typescript
import { getLevoError } from "@levo-so/core";

try {
  await client.collection.saveCollectionData({ collection_id, data });
} catch (error) {
  const err = getLevoError(error);

  // err.code     — machine-readable error code (e.g. "VALIDATION_ERROR")
  // err.message  — human-readable description
  // err.status   — HTTP status code

  if (err.hasFieldErrors) {
    // When the API returns field-level validation failures,
    // wire these directly into your form library's error state
    err.fieldErrors.forEach(({ param, message }) => {
      form.setError(param, { message });
    });
  }
}
```

`getLevoError()` covers all failure modes: HTTP 4xx/5xx errors with JSON bodies, network failures (offline, CORS, DNS), request timeouts, and already-normalized `LevoError` instances (passed through as-is, so it's safe to call twice).

A note on displaying errors: in modals and forms, prefer showing `err.message` inline near the action that failed rather than using a toast. Toasts are easy to miss; inline errors give users immediate, contextual feedback.

---

## Querying & Filtering

All list methods accept a query object for filtering, sorting, pagination, and field selection. This is the same query shape across all modules — blog, collections, media.

```typescript
import type { LevoQuery } from "@levo-so/core";

const query: LevoQuery.FindQuery = {
  page: 1,
  limit: 20,
  sort: { created_at: "desc" },

  // Select only the fields you need
  select: { _id: true, title: true, published_at: true },

  // Filtering with AND/OR — nestable arbitrarily
  where: {
    AND: [
      { status: { equals: "published" } },
      { created_at: { gte: "2024-01-01" } },
      { OR: [
        { tag: { contains: "news" } },
        { tag: { contains: "updates" } },
      ]},
    ],
  },
};
```

**Comparison operators:** `equals`, `not`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte`, `contains`, `starts_with`, `ends_with`, `between`, `is_empty`, `is_not_empty`, `has`, `within` (geo radius)

**Logical operators:** `AND`, `OR` — nest at any depth.

---

## Utilities

**`getLevoError(error)`** — Normalize any thrown value into a `LevoError`. See [Error Handling](#error-handling).

**`formatImagePath(path)`** — URI-encodes the filename portion of a path. Use this when constructing image URLs that may contain spaces or special characters.

**`listToColumn(arr)`** — Converts `["name", "email"]` into `{ name: "name", email: "email" }`. Useful for building field selection objects programmatically.

**Type utilities** — `Prettify<T>`, `Mandatory<T, K>`, and `DeepPartial<T>` are exported from the package for use in consuming packages that build on top of `@levo-so/core`.