Memo stores relational data in Neon Postgres (schema memo), high-volume telemetry in a MongoDB Atlas time-series collection, and project files in Cloudflare R2.
Memo owns the entire root URL space of memo.matters.ai. Two disjoint surfaces share it:
| Surface | Path prefix | Owned by |
|---|---|---|
| Owner | /u/<username>/… | Clerk-authenticated owner (Astro SSR + React islands) |
| Visitor | /<slug>/… | Per-link JWT (Edge / SSR) |
| App | /, /sign-in, /sign-up, /health, /404, /500, /api/*, /oauth/*, /.well-known/*, /_* | System routes |
Visitor slugs are at the root — this is what makes share URLs short (memo.matters.ai/acme-deck instead of memo.matters.ai/v/acme-deck). The trade-off is that a slug like api would collide with the /api/* surface, so memo enforces a global reservation list at link create time.
SLUG_RESERVED#Defined in src/lib/validation/link.ts. Any link-create request with slug ∈ SLUG_RESERVED is rejected with "this slug is reserved by memo; pick a different one". The list is the union of:
404, 500, health, sign-in, sign-up, favicon.ico, robots.txt, sitemap.xml.api, oauth, u, well-known (the .well-known/* set), _actions, _astro, _image, _server-islands (Astro internals).app, v.The [vitest suite](/memo/operations#verify-pipeline) enforces parity between SLUG_RESERVED and isVisitorPath (in src/lib/routing.ts) — every named app surface MUST be reserved, and that contract is checked on every CI run.
/^[a-z0-9][a-z0-9-_]{1,63}$/
- or _).a–z, 0–9, _, -.citext), but the schema also lowercases the input via z.string().trim().toLowerCase() so the cookie name and DB key match.Validation lives in src/lib/validation/link.ts (linkCreateSchema / linkUpdateSchema) and is re-applied at every entrypoint: the owner UI's link drawer, the /api/owner/links POST, and the MCP create_link tool.
memo#The full Drizzle schema lives in apps/memo/src/lib/db/schema.ts and is mirrored by a clean baseline apps/memo/drizzle/0000_init.sql (+ meta/0000_snapshot.json) followed by apps/memo/drizzle/0001_slimy_morg.sql (the collaboration tables — project_members + project_invites). The migration chain was collapsed to the 0000 baseline after the prior snapshots drifted/became non-linear, then resumed linearly from there. The pre-Phase-1 → current incremental ALTERs are archived in apps/memo/drizzle-legacy/ for upgrading an existing database; a live DB is kept in sync with schema.ts via drizzle-kit push. See the Runbook for apply paths. The schema requires two Postgres extensions which the migration enables on bootstrap:
CREATE EXTENSION IF NOT EXISTS "citext"; -- case-insensitive username, email, slug
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid() defaults
| Column | Type | Notes |
|---|---|---|
id | uuid PK | gen_random_uuid() default |
clerk_user_id | text | Unique. Bridge from Clerk → memo |
username | citext | Unique. Used as path segment |
email | citext | |
plan | text | 'free' default |
created_at | timestamptz |
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
owner_id | uuid → users.id ON DELETE CASCADE | |
name | text | Free-form |
slug | text | Unique per owner |
thumbnail_url | text | |
current_version_id | uuid | Soft FK to versions.id |
created_at, updated_at | timestamptz |
owner_id stays the creator forever; shared access is additive via the two tables below. A user can act on a project when requireProjectAccess(projectId, userId) passes — i.e. they own it or have a project_members row. accessibleProjectIds(userId) returns the owned ∪ member union that backs the dashboard's "shared with me" listings. See Collaboration.
One row per user granted shared access to a project. Graded roles now ship — owner / admin / editor / viewer (src/lib/auth/roles.ts); new invites default to editor. The DB column default stays 'owner' for backward-compat with rows created before RBAC. See Collaboration.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | gen_random_uuid() default |
project_id | uuid → projects.id ON DELETE CASCADE | |
user_id | uuid → users.id ON DELETE CASCADE | |
role | text | 'owner' DB default (backward-compat). Graded RBAC ships: owner / admin / editor / viewer; new invites default to editor |
invited_by_user_id | uuid? → users.id ON DELETE SET NULL | Who sent the invite |
created_at | timestamptz |
UNIQUE on (project_id, user_id); indexed on user_id (powers accessibleProjectIds).
A pending invite for an email that has no memo account yet. Resolved into a project_members row on signup via acceptPendingInvites (src/lib/auth/owner.ts).
| Column | Type | Notes |
|---|---|---|
id | uuid PK | gen_random_uuid() default |
project_id | uuid → projects.id ON DELETE CASCADE | |
email | citext | Invitee address (case-insensitive) |
role | text | 'owner' default |
token | text | Unique invite token |
invited_by_user_id | uuid? → users.id ON DELETE SET NULL | |
created_at | timestamptz | |
accepted_at | timestamptz? | Set when the invite resolves on signup |
UNIQUE on (project_id, email) and UNIQUE on token.
Each Publish creates a new immutable row. Soft-delete via current_version_id only.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
project_id | uuid → projects.id ON DELETE CASCADE | |
version_number | int | Unique per project |
message | text | Optional changelog |
file_count | int | |
total_bytes | int | |
compile_status | text | 'ok' or 'error' |
compile_log | jsonb | { warnings, durationMs } |
created_at | timestamptz |
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
version_id | uuid → versions.id ON DELETE CASCADE | |
path | text | Project-relative (index.html, assets/app.js) |
mime | text | From extension via lib/mime.ts |
size | int | Bytes |
sha256 | text | Hex digest |
r2_key | text | <userId>/<projectId>/<versionId>/<path> |
Unique on (version_id, path).
The wide table — one row per shareable URL.
Notable columns:
slug citext unique — global slug across all owners, enforces the URL space.version_id ON DELETE RESTRICT — refuses to drop a version someone is still linking to.password_hash text, password_salt text — argon2id hash with per-row salt and env pepper.allow_emails text[], block_emails text[], allow_domains text[], block_domains text[] — composable allowlist/blocklist.require_agreement_id → agreements.id ON DELETE SET NULL.expires_at timestamptz — checked by every gate; flipped to inactive by the expire-links cron.watermark, allow_downloads, active — booleans.welcome_*, custom_button_*, branding — per-link white-label fields.NDA templates owned by a user, immutable once created (v1).
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
owner_id | uuid → users.id ON DELETE CASCADE | |
title | text | |
content_html | text | Rich-text body (Tiptap output) |
pdf_r2_key | text | Optional pre-rendered PDF |
pdf_config | jsonb? | Fillable-PDF field definitions; signers' inputs are stored in signatures.field_values |
version_hash | text | sha256(content_html) — recorded into every signature row |
A signature is captured against a link gate OR a space gate — exactly one of link_id / space_id is set (link_id is nullable; a space id must not go into it, since it FKs to links).
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
link_id | uuid? → links.id ON DELETE CASCADE | NULL for space signatures |
space_id | uuid? → spaces.id ON DELETE CASCADE | NULL for link signatures |
visitor_email | citext | From the JWT at the time of signing |
agreement_id | uuid → agreements.id ON DELETE RESTRICT | |
agreement_version_hash | text | Frozen at sign time |
signature_svg | text | Rendered from typed name |
field_values | jsonb? | Captured values for an agreement's fillable PDF fields (see agreements.pdf_config) |
ip | inet | |
user_agent | text | |
signed_pdf_r2_key | text | Populated by render-signed-pdf cron |
signed_at | timestamptz |
The canonical "this person engaged with the deck" row. One row per gate-JWT lifecycle (sub UUID = the cookie). Multiple visits per email + per persistent-id are normal — visits are sessions, not people.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | Reused as the visitor JWT sub claim. Insert is ON CONFLICT DO NOTHING so the same cookie within 24h doesn't create duplicate rows. |
link_id | uuid? → links.id ON DELETE CASCADE | NULL for space visits (served at /s/<slug>/p/<id>/). |
space_id | uuid? → spaces.id ON DELETE CASCADE | Set for space visits; NULL for link visits. Exactly one of link_id/space_id is set. |
project_id | uuid? → projects.id ON DELETE CASCADE | Which project a space visit viewed (link visits derive it via the link). |
visitor_email | citext | NULL when requireEmail=false and the visitor didn't volunteer one. Case-insensitive for cross-visit aggregation. |
ip | inet | From Astro.clientAddress. |
ua | text | Raw user-agent string. |
country | text | Two-letter code from Vercel's x-vercel-ip-country header. NULL off-Vercel (local dev). |
city | text | URL-decoded from Vercel's x-vercel-ip-city header (decodes New%20York → New York). NULL off-Vercel. |
started_at | timestamptz | now() default. Set at insert. |
ended_at | timestamptz | NULL until the first page_leave beacon event arrives. The /api/beacon handler does UPDATE … WHERE ended_at IS NULL so only the first call sets it (idempotency). |
duration_ms | int | Date.now() - startedAt at page_leave time. Includes idle time when the tab was hidden but unclosed. |
completion_pct | int | Max scroll depth percentage seen across the visit's page_leave events. 0..100. |
logrocket_session_url | text | Set by /api/recording/logrocket when the LogRocket client SDK round-trips the session URL after init. |
clarity_recording_id | text | Reserved; Clarity is project-scoped so usually NULL. |
visitor_persistent_id | uuid | Value of the memo_vp cookie (365-day rolling) — minted via crypto.randomUUID() if absent. Different from id: id rotates every 24h with the gate cookie, visitor_persistent_id rotates only when the visitor clears cookies or uses a new device. Used by the cross-visit signal cron to detect return patterns. Indexed (visits_persistent_idx). |
engagement_score | int | Computed once at page_leave time, in the same UPDATE as duration_ms. Formula: completionPct × 5 + log10(durationSec + 1) × 100 + min(20, sectionsEntered) × 25. Range ~0..1300. See Observability → Engagement score. |
utm_source, utm_medium, utm_campaign, utm_term, utm_content | text? | Snapshot of the link's utm_params (a jsonb column on links) frozen at visit time. Powers GET /api/owner/analytics/campaigns. |
Indexes: visits_link_idx on link_id, visits_space_idx on space_id, visits_project_idx on project_id, visits_started_idx on started_at, visits_persistent_idx on visitor_persistent_id.
Derived behavioural classifications, one row per (visit_id, kind). The Activity tab's signal chips and the people page's per-row badges read from this table. Per-visit signals (skim, deep_read) are emitted inline by /api/beacon on page_leave; cross-visit signals (return, share_detected) are emitted by the derive-cross-visit-signals cron every 10 minutes.
| Column | Type | Notes |
|---|---|---|
id | bigserial PK | |
visit_id | uuid → visits.id ON DELETE CASCADE | The signal disappears when the visit does. |
kind | text | Free-form string. Today: skim / deep_read / video_watched (per-visit, from /api/beacon on page_leave) and return / share_detected (cross-visit cron). video_watched fires when any media_progress event reaches ≥80%. Adding new kinds is non-breaking — readers default to a neutral chip for unknown kinds. |
payload | jsonb | Snapshot of the inputs that produced the classification — used for explainable tooltips ("why is this a skim?"). Shape varies by kind — see Observability → Behavioural signals. |
created_at | timestamptz | now() default. The cross-visit cron's ON CONFLICT DO UPDATE refreshes this when re-classifying. |
Indexes:
visit_signals_visit_kind_idx — UNIQUE on (visit_id, kind). Enables idempotent inserts: ON CONFLICT DO NOTHING (per-visit) and ON CONFLICT (visit_id, kind) DO UPDATE (cross-visit). Also speeds up "all signals for one visit" lookups.visit_signals_kind_idx — on kind. Speeds up cohort queries like "all deep_read visits in the last 7 days".Machine-to-memo bearer tokens minted by the owner at /settings/tokens.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
owner_id | uuid → users.id ON DELETE CASCADE | |
name | text | Owner-given label, e.g. "Claude on my mac" |
token_hash | text | Unique. sha256 of the raw token. Raw never stored. |
token_prefix | text | UI-only display string, e.g. mp_aB3xK91… |
scopes | text[] | Subset of the 5 PAT scopes |
expires_at | timestamptz? | Optional |
last_used_at | timestamptz? | Best-effort, updated on each resolve |
revoked_at | timestamptz? | Set by revoke; checked on every lookup |
created_at | timestamptz |
Third-party apps that have registered via RFC 7591 Dynamic Client Registration.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
client_id | text | Unique. Public ID handed back during registration (mc_…). |
client_secret_hash | text? | sha256 of the secret if a confidential client. Memo never issues these in practice. |
name | text | Display name from registration (claude.ai etc.). |
redirect_uris | text[] | Pre-registered callback URLs. Token + authorize routes enforce match. |
grant_types | text[] | Default: ["authorization_code", "refresh_token"]. |
token_endpoint_auth_method | text | Default "none" (public + PKCE). |
client_uri, logo_uri | text? | Surfaced on the consent screen. |
software_id, software_version, metadata | — | Pass-through metadata from registration. |
created_at | timestamptz |
Short-lived (10 min) one-shot codes minted at /oauth/authorize, redeemed at /oauth/token.
| Column | Type | Notes |
|---|---|---|
code | text PK | The actual code (mac_…). Sent to the client and returned by it. |
client_id | uuid → oauth_clients.id ON DELETE CASCADE | |
user_id | uuid → users.id ON DELETE CASCADE | |
redirect_uri | text | Frozen at code-mint time. Must match on exchange. |
scopes | text[] | Approved scopes from consent. |
code_challenge | text | PKCE challenge. Verified against code_verifier on exchange. |
code_challenge_method | text | S256 only. |
resource | text? | RFC 8707 resource indicator (always /api/mcp for memo). |
expires_at | timestamptz | |
consumed_at | timestamptz? | Set on successful exchange. Single use. |
created_at | timestamptz |
The bearer tokens /api/mcp accepts. Hashed exactly like PATs.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
client_id | uuid → oauth_clients.id ON DELETE CASCADE | |
user_id | uuid → users.id ON DELETE CASCADE | |
token_hash | text | Unique. sha256 of the raw ma_… token. |
token_prefix | text | UI-only display |
scopes | text[] | Subset of the 5 PAT scopes |
expires_at | timestamptz | 1h after issuance |
last_used_at, revoked_at | timestamptz? | |
created_at | timestamptz |
Single-use refresh tokens. Memo rotates them on every successful refresh (sets rotated_at on the old row, mints a new one).
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
client_id | uuid → oauth_clients.id ON DELETE CASCADE | |
user_id | uuid → users.id ON DELETE CASCADE | |
token_hash | text | Unique. sha256 of the raw mr_… token. |
scopes | text[] | |
expires_at | timestamptz | Default 30 days |
rotated_at | timestamptz? | Set when the row is exchanged. Replay returns invalid_grant. |
revoked_at | timestamptz? | Set by /oauth/revoke or by revoking the matching access token. |
created_at | timestamptz |
One row per visit with a self-hosted rrweb recording. The actual event blob lives in R2 at recordings/<projectId>/<visitId>.json.gz — this side-table holds the lifecycle metadata so the Activity UI can light up the Replay column without touching R2.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
visit_id | uuid → visits.id ON DELETE CASCADE | Unique; one recording per visit |
link_id | uuid? → links.id ON DELETE CASCADE | NULL for space recordings |
space_id | uuid? → spaces.id ON DELETE CASCADE | Set for space recordings. The R2 blob is keyed by project_id+visit_id (scope-agnostic), so the owner replay loader works for both. |
project_id | uuid → projects.id ON DELETE CASCADE | |
r2_key | text | Pointer to the gzipped event stream |
event_count | int | rrweb event count |
byte_size | int | gzipped size on R2 |
started_at, ended_at | timestamptz? | ended_at filled when final: true event arrives |
Outbound webhook subscriptions. Secret stored hash-only — raw shown once at create.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
owner_id | uuid → users.id ON DELETE CASCADE | |
url | text | Receiver URL |
secret_hash | text | sha256 of the raw secret used to sign deliveries |
secret_prefix | text | UI display string e.g. whsec_aB3xK91… |
events | text[] | Subset of the 8 webhook event names |
active | bool | Owner-toggleable |
description | text? | Free-form |
last_delivery_at | timestamptz? | Updated on successful POST |
created_at | timestamptz |
Append-only delivery log. The cron picks up delivered_at IS NULL AND next_attempt_at <= now() rows.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | Also surfaced as memo-delivery-id header |
endpoint_id | uuid → webhook_endpoints.id ON DELETE CASCADE | |
event | text | One of the 8 event names |
payload | jsonb | Full event body |
attempts | int | 0..6 |
status_code | int? | HTTP status of the most-recent attempt |
error_message | text? | Most-recent error |
delivered_at | timestamptz? | NULL until a 2xx |
next_attempt_at | timestamptz? | Cron picks rows where <= now() |
created_at | timestamptz |
projects gained a notes_md text NULL column in v3 — owner-only Markdown rendered above the editor as project context.
Append-only ledger of every state-changing action.
| Column | Type | Notes |
|---|---|---|
id | bigserial PK | |
actor_user_id | uuid → users.id ON DELETE SET NULL | Owner action |
actor_visitor_email | text | Visitor action (e.g. NDA sign) |
action | text | dotted form (e.g. project.publish, link.update, nda.sign) |
target_type | text | project, version, link, agreement, signature |
target_id | text | |
meta | jsonb | Action-specific payload |
ip | inet | |
ts | timestamptz |
Indexed on (target_type, target_id) and ts.
These tables back the data-room surface. Full feature docs: Spaces, Teams & Diligence, Q&A, Integrations.
Multi-project data room. Mirrors the links access-control columns so the gate code is shared.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
owner_id | uuid → users.id ON DELETE CASCADE | |
slug | citext | Unique (case-insensitive, matches links.slug — squatting prevention) |
name, description | text | |
branding | jsonb NOT NULL '{}' | { primaryColor, fontFamily, logoUrl } |
require_email, verify_email, watermark, allow_downloads, active | bool | Gate config (same semantics as links) |
password_hash, password_salt | text? | argon2id |
allow_emails[], block_emails[], allow_domains[], block_domains[] | text[] NOT NULL '{}' | Allow/block lists |
require_agreement_id | uuid? → agreements.id ON DELETE SET NULL | NDA gate |
expires_at | timestamptz? | |
created_at, updated_at | timestamptz |
Indexes: spaces_slug_idx UNIQUE on slug, spaces_owner_idx on owner_id.
Membership join — which projects are in a space and their order.
| Column | Type | Notes |
|---|---|---|
space_id | uuid → spaces.id ON DELETE CASCADE | |
project_id | uuid → projects.id ON DELETE CASCADE | |
position | int default 0 | Display order (drag-to-reorder) |
folder_id | uuid? | Soft grouping pointer (no folders table) |
Composite UNIQUE on (space_id, project_id) + per-column indexes.
Named email cohorts within a space. space_visitor_groups (space_id, group_name, permission text default "view") and space_group_emails (space_id, group_name, email), each with a composite UNIQUE.
Immutable data-room access trail. bigserial PK, space_id → spaces.id CASCADE, actor_email?, action, target_type, target_id, meta jsonb, ts. Indexed on space_id and ts. The native viewer writes a project.viewed row per served project.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
owner_id | uuid → users.id ON DELETE CASCADE | |
name | text | |
member_emails | text[] NOT NULL '{}' | Lowercased; backs the team-visibility link gate |
created_at, updated_at | timestamptz |
links gained visibility text (default "public") + team_id uuid? → teams.id ON DELETE SET NULL to drive checkTeamAccess.
Per-space due-diligence checklist. diligence_items: space_id CASCADE, title, description?, status (default open), category?, priority int, assignee_email?, due_date?, related_file_path?, timestamps (indexed on space_id, status). diligence_comments: item_id → diligence_items.id CASCADE, author_email, body, created_at.
In-viewer Q&A. visit_id → visits.id CASCADE, link_id → links.id CASCADE, question, answer?, answered_at?, page_path?, created_at. Indexed on visit_id, link_id. See Q&A.
Encrypted cloud-storage OAuth tokens (Drive/Dropbox/Box). user_id CASCADE, provider, provider_account_id?, access_token_encrypted (AES-256-GCM), refresh_token_encrypted?, scopes[], token_type, expires_at?, timestamps. UNIQUE (user_id, provider) (user_provider_idx) → atomic upsert. See Integrations.
events#{
ts: ISODate, // timeField, server-stamped at ingest; used for the 90-day TTL
visit_id: "uuid", // metaField (clusters one visit together)
link_id: "uuid|null", // denormalised; null for space visits
space_id: "uuid|null", // set for space visits (served at /s/<slug>/p/<id>/)
project_id: "uuid", // denormalised so cross-link/space queries are cheap
type:
| "page_view"
| "page_leave"
| "scroll_depth"
| "click_zone"
| "focus_change"
| "gate_passed" // reserved
| "download" // reserved
| "section_enter" // memo v3.x — IntersectionObserver crossed 50% visibility
| "section_exit" // memo v3.x — element left viewport; payload.ms = dwell
| "media_progress", // <video>/<audio> timeupdate; ≥80% emits the video_watched signal
payload: { ... } // per-type shape
}
Per-type payload shapes (payload.*):
type | Required keys | Optional keys |
|---|---|---|
page_view | path (string), w (number), h (number), title (string), referrer (string) | — |
scroll_depth | pct (0..100) | — |
click_zone | tag (string), zone (string) | section (string, nearest [data-memo-section] ancestor's id, or null) |
focus_change | hidden (boolean) | — |
section_enter | section (string), firstView (boolean) | — |
section_exit | section (string), ms (number — dwell) | ratio (number, intersection ratio at exit, percent), final (boolean, true when synthesised by pagehide) |
page_leave | depth (number, max scroll percent) | — |
media_progress | element (string, e.g. video_0), currentTime (number), duration (number), percent (0..1) | — |
Collection setup (in src/lib/db/mongo.ts):
timeseries.timeField: "ts" + metaField: "visit_id" + granularity: "seconds".expireAfterSeconds: 90 × 24 × 3600 → 90-day TTL.Recommended secondary index (run once per environment):
db.events.createIndex({ project_id: 1, type: 1, ts: -1 })
This makes /api/owner/projects/<id>/analytics/sections (which does find({ project_id, type: "section_exit" })) O(matches) instead of a partial scan.
Why Mongo here:
ts.See Observability for the full beacon flow, per-event payload contracts, sample queries, and aggregation patterns.
staging/<projectId>/<path> # writable editing buffer
<userId>/<projectId>/<versionId>/<path> # immutable published assets
signatures/<signatureId>.pdf # signed-NDA PDFs (rendered async)
staging/ is wiped per project on every Publish (we copy what compiled successfully into the version prefix, the staging dir keeps the source-of-truth).<userId>/<projectId>/<versionId>/ is never overwritten, so cached CDNs are safe in a future v2.normalizeProjectPath() in lib/path-safety.ts. The regex restricts each segment to [A-Za-z0-9._-]+.