Skip to content

Decorators & Schema

All decorators are importable directly from 'pulsabase'. TypeScript experimentalDecorators and emitDecoratorMetadata must be enabled in tsconfig.json.

import { primary, text, integer, boolean, uuid, jsonb, timestamp,
varchar, float, date, password, spatialGeometry, vector,
column, foreignId, unique, relation, ModelSchema } from 'pulsabase';

UUID primary key. Auto-generated on INSERT using gen_random_uuid().

@primary
id: string;

SQL: id UUID DEFAULT gen_random_uuid() PRIMARY KEY


ParameterDefault
nullabletrue
defaultValueundefined
@text(false) // NOT NULL
name: string;
@text(true, 'pending') // nullable with DEFAULT
status: string;

@varchar(length, nullable?, defaultValue?)

Section titled “@varchar(length, nullable?, defaultValue?)”
@varchar(255, false)
slug: string;

@integer(false)
age: number;

PostgreSQL DOUBLE PRECISION.

@float()
price: number;

@boolean(false, 'false') // NOT NULL DEFAULT false
is_active: boolean;

For non-primary UUID columns (e.g., FK without the @foreignId constraint).

@uuid()
external_id: string;

PostgreSQL JSONB — queryable JSON.

@jsonb()
metadata: Record<string, any>;

@timestamp(false, 'NOW()')
published_at: string;

PostgreSQL DATE (date only, no time).


TEXT NOT NULL with automatic bcrypt hashing before storage. Value is never returned in queries.

@password()
password_hash: string;

PostgreSQL + PostGIS geometry column.

@spatialGeometry('Point', 4326)
location: any;
// Insert:
await pb.from(Place).insert({
name: 'Eiffel Tower',
location: 'SRID=4326;POINT(2.2945 48.8584)',
});

Default: geomType = 'Geometry', srid = 4326.


PostgreSQL + pgvector column for AI embeddings.

@vector(1536)
embedding: number[];
// Insert:
await pb.from(Document).insert({ title: 'AI Guide', embedding: [0.1, 0.2, ...] });

Full control — raw column options.

@column({ type: 'decimal(10,2)', nullable: false, default: '0.00' })
amount: string;
PropertyTypeDescription
typestringSQL type string
nullablebooleanNOT NULL if false (default: true)
lengthnumberFor varchar(n)
dimensionsnumberFor vector(n)
uniquebooleanAdd UNIQUE constraint
defaultstringSQL DEFAULT expression
primarybooleanMark as primary key
password'bcrypt'Enable bcrypt auto-hash

Add a UNIQUE constraint to any column. Can be combined with any column decorator.

@text(false)
@unique()
email: string;

@relation({
type: 'many-to-many',
target: () => User,
joinTable: 'room_members',
foreignKey: 'room_id',
inverseJoinColumn: 'user_id',
pivotColumns: ['role'],
pivotTimestamps: true,
})
members: User[];
PropertyTypeDescription
type'one-to-one' | 'one-to-many' | 'many-to-one' | 'many-to-many'Relation type
target() => ModelClassLazy reference to the related model (avoids circular imports)
foreignKeystringColumn on the pivot/join table pointing to the parent
joinColumnstringColumn on the parent table (defaults to 'id')
inverseJoinColumnstringColumn on the pivot pointing to the related record
joinTablestringPivot table name (M2M only)
pivotColumnsstring[]Extra columns to include from the pivot table
pivotTimestampsbooleanInclude created_at/updated_at from the pivot

Declares a UUID column with a PostgreSQL FOREIGN KEY constraint.

@column({ type: 'uuid', nullable: false })
@foreignId({ references: 'id', on: 'users', onDelete: 'CASCADE' })
user_id: string;
OptionTypeOptions
referencesstringColumn on the referenced table (usually 'id')
onstringReferenced table name
onDeletestring'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION'
onUpdatestringSame options as onDelete

The static schema block is the most critical part of any model — it defines the table, real-time behavior, indexes, validation, and Row-Level Security policies that gate every database operation.

PropertyTypeRequiredDescription
tableNamestringPostgreSQL table name
timestampsbooleanAuto-manage created_at / updated_at with a DB trigger
publicationbooleanInclude in PostgreSQL logical replication publication (enables real-time for this table)
accessstringShorthand RLS preset — generates policies automatically (cannot be combined with policies)
indexesIndexDefinition[]Custom database indexes
validationobjectPer-field client-side validation rules (see Validation Rules)
crossValidationCrossValidationRule[]Cross-field validation rules (fire before the request is sent)
policiesPolicyDefinition[]Explicit RLS policies — full control over who can do what

Indexes are declared in the model and applied to the database by db:sync. They control query performance — always index columns used in frequent WHERE, JOIN, or ORDER BY clauses.

import { IndexDefinition } from 'pulsabase';
static schema: ModelSchema = {
tableName: 'posts',
indexes: [
// ... examples below
],
};
PropertyTypeDefaultDescription
columnsstring[]Required. Columns to include in the index
uniquebooleanfalseUNIQUE constraint — prevents duplicate values
namestringautoCustom index name. Auto-generated as idx_{table}_{cols} if omitted
typestring'BTREE'Index method — passed directly as USING {type} in DDL. Any type PostgreSQL supports works.
opclassstringOperator class — appended to each column definition (e.g. vector_cosine_ops)
wherestringSQL condition for a partial index — only matching rows are indexed

The default PostgreSQL index. Optimal for equality checks, range queries, and sorting.

// Single column — most common case
{ columns: ['email'], unique: true } // → idx_users_email_unique
{ columns: ['status'] } // → idx_posts_status
{ columns: ['user_id', 'created_at'] } // Composite: filter by user, sort by date

Generated DDL: CREATE INDEX "idx_posts_status" ON "posts" ("status");

Index only a subset of rows. Much smaller and faster when your queries always include a fixed filter.

// Only index active users — queries filtering `status = 'active'` get a tiny, fast index
{ columns: ['email'], where: "status = 'active'" }
// Only index unpublished posts — for admin queues
{ columns: ['created_at'], where: "published = false" }
// Only index soft-deleted records
{ columns: ['deleted_at'], where: 'deleted_at IS NOT NULL' }

Generated DDL: CREATE INDEX "idx_users_email" ON "users" ("email") WHERE status = 'active';

Enforce uniqueness across multiple columns (e.g., one membership per user per room).

// A user can only be in a room once
{ columns: ['room_id', 'user_id'], unique: true }
// A user can only like a post once
{ columns: ['user_id', 'post_id'], unique: true }

Use for JSONB columns (querying nested fields) or full-text search (tsvector).

// Index a JSONB column for fast key/value lookups
{ columns: ['metadata'], type: 'GIN' }
// Full-text: index a tsvector generated column
{ columns: ['search_vector'], type: 'GIN' }

Use for geometry columns (PostGIS spatial queries) or range types.

// Spatial index — required for ST_DWithin, ST_Intersects, etc. to be fast
{ columns: ['location'], type: 'GIST' }
// Multiple geometry columns
{ columns: ['pickup_location'], type: 'GIST' }
{ columns: ['dropoff_location'], type: 'GIST' }

Without a GIST index, spatial queries perform a full table scan. Always add one to geometry columns.

The recommended index for vector columns with pgvector. Enables fast approximate nearest-neighbor (ANN) search.

// L2 distance (Euclidean) — for OpenAI text-embedding-ada-002 (1536 dims)
{
columns: ['embedding'],
type: 'HNSW',
opclass: 'vector_l2_ops',
}
// Cosine distance — for normalized embeddings (recommended for semantic search)
{
columns: ['embedding'],
type: 'HNSW',
opclass: 'vector_cosine_ops',
}
// Inner product — for dot-product similarity
{
columns: ['embedding'],
type: 'HNSW',
opclass: 'vector_ip_ops',
}
Operator classDistance metricSDK operator
vector_l2_opsEuclidean (L2)<->
vector_cosine_opsCosine distance<=>
vector_ip_opsNegative inner product<#>

IVFFlat (alternative to HNSW for large datasets)

Section titled “IVFFlat (alternative to HNSW for large datasets)”
{
columns: ['embedding'],
type: 'IVFFlat',
opclass: 'vector_cosine_ops',
}

Use HNSW for most cases. IVFFlat requires a training phase (VACUUM ANALYZE) after initial data load.

Extremely compact index for very large, append-only tables (logs, events, time series). Only useful when rows are inserted in physical order (e.g., by created_at).

// Great for event/audit log tables with millions of rows
{ columns: ['created_at'], type: 'BRIN' }

Row-Level Security (policies: PolicyDefinition[])

Section titled “Row-Level Security (policies: PolicyDefinition[])”

This is the security boundary

RLS policies are enforced at the PostgreSQL level — they cannot be bypassed by the SDK or any direct query. Every SELECT, INSERT, UPDATE, and DELETE is filtered by these policies based on the user’s JWT claims.

static schema: ModelSchema = {
tableName: 'posts',
policies: [
// ... examples below
],
};
PropertyTypeDescription
action('SELECT' | 'INSERT' | 'UPDATE' | 'DELETE')[]Required. SQL operations this policy applies to
publicbooleanAllow access with no JWT (anonymous/public access)
rolestring | string[]Required IDP role(s). Multiple roles = OR (any one is sufficient)
permissionstring | string[]Required IDP permission(s). Multiple = OR
ownerstringColumn that must equal jwt.sub (current user’s ID)
memberOfstringFK column — user must be a member of the group via a pivot table
pivotWhereRecord<string, any>Extra condition on the pivot row (e.g., { role: 'admin' })

Logic rules:

  • Between different fields: AND — all specified conditions must be satisfied
  • Within role / permission arrays: OR — any one value satisfies the condition
{ action: ['SELECT'], public: true }

Generated SQL: USING (true) — no restriction.

{ action: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'], role: 'authenticated' }

Generated SQL:

USING (
current_setting('request.jwt.claims', true)::json->>'roles' LIKE '%"authenticated"%'
)

Owner-only — user can only touch their own rows

Section titled “Owner-only — user can only touch their own rows”
{ action: ['SELECT', 'UPDATE', 'DELETE'], owner: 'user_id' }

Generated SQL:

USING (
"user_id"::text = current_setting('request.jwt.claims', true)::json->>'sub'
)

The owner value is the column name on the table that stores the user’s ID. It is compared against the sub claim from the JWT.

{ action: ['INSERT'], owner: 'user_id' }

Generated SQL:

WITH CHECK (
"user_id"::text = current_setting('request.jwt.claims', true)::json->>'sub'
)

Prevents any user from inserting rows on behalf of someone else.

// Single role
{ action: ['DELETE'], role: 'admin' }
// Any of multiple roles (OR)
{ action: ['UPDATE'], role: ['admin', 'moderator'] }

Generated SQL (single role):

USING (
current_setting('request.jwt.claims', true)::json->>'roles' LIKE '%"admin"%'
)

Generated SQL (multiple roles — OR):

USING (
current_setting('request.jwt.claims', true)::json->>'roles' LIKE '%"admin"%'
OR current_setting('request.jwt.claims', true)::json->>'roles' LIKE '%"moderator"%'
)
// Single permission
{ action: ['INSERT', 'UPDATE'], permission: 'content.write' }
// Any of multiple permissions (OR — one is sufficient)
{ action: ['DELETE'], permission: ['content.delete', 'content.admin'] }

Generated SQL (single):

USING (
current_setting('request.jwt.claims', true)::json->>'permissions' LIKE '%"content.write"%'
)

Generated SQL (multiple — OR):

USING (
current_setting('request.jwt.claims', true)::json->>'permissions' LIKE '%"content.delete"%'
OR current_setting('request.jwt.claims', true)::json->>'permissions' LIKE '%"content.admin"%'
)
// Admin can manage all; owners can manage only their own
{ action: ['UPDATE', 'DELETE'], role: 'admin' },
{ action: ['UPDATE', 'DELETE'], owner: 'user_id' },

Two separate policy entries are OR-ed by PostgreSQL — a row passes if any policy matches.

// Only room members can read messages
{ action: ['SELECT'], memberOf: 'room_id' }

Generated SQL:

USING (
EXISTS (
SELECT 1 FROM "messages" AS _membership_check
WHERE _membership_check."room_id" = "room_id"
AND _membership_check."user_id" = (current_setting('request.jwt.claims', true)::json->>'sub')::uuid
)
)
// Only room admins can delete messages
{ action: ['DELETE'], memberOf: 'room_id', pivotWhere: { role: 'admin' } }

Generated SQL:

USING (
EXISTS (
SELECT 1 FROM "messages" AS _membership_check
WHERE _membership_check."room_id" = "room_id"
AND _membership_check."user_id" = (current_setting('request.jwt.claims', true)::json->>'sub')::uuid
AND _membership_check."role" = 'admin'
)
)
// A social media post — public read, owner write, admin override
static schema: ModelSchema = {
tableName: 'posts',
timestamps: true,
publication: true, // Enable real-time
indexes: [
{ columns: ['user_id'] },
{ columns: ['status', 'created_at'] },
// Full-text search index on title + body
{ columns: ['search_vector'], type: 'GIN' },
],
policies: [
// Anyone (even logged out) can read published posts
{ action: ['SELECT'], public: true },
// Only authenticated users can create posts
{ action: ['INSERT'], role: 'authenticated', owner: 'user_id' },
// Only the author can edit or delete their own posts
{ action: ['UPDATE', 'DELETE'], owner: 'user_id' },
// Admins can do anything
{ action: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'], role: 'admin' },
],
};

access Presets (shorthand — cannot combine with policies)

Section titled “access Presets (shorthand — cannot combine with policies)”

When using access, the CLI generates a standard set of policies automatically. Use policies directly when you need custom logic.

ValueEquivalent Policies Generated
'public'SELECT public: true | INSERT/UPDATE/DELETE role: ‘authenticated’
'private'All actions: owner: ‘user_id’ (requires a user_id column)
'authenticated'All actions: role: ‘authenticated’
'members-read'SELECT: memberOf FK column
'members-manage'All actions: memberOf FK column