Skip to content

Database

ElyOS uses PostgreSQL with Drizzle ORM. The schema lives in the packages/database package, and repositories are located in apps/web/src/lib/server/database/repositories/.

packages/database/src/schemas/
├── auth/ # better-auth tables
│ ├── users/ # Users
│ ├── authentication/ # Sessions, tokens
│ ├── groups/ # Groups
│ ├── roles/ # Roles
│ ├── permissions/ # Permissions
│ └── audit/ # Audit log
└── platform/ # Platform tables
├── apps/ # Application registration
├── chat/ # Chat messages
├── desktop/ # Desktop configuration
├── i18n/ # Translations
├── logging/ # System log
├── notifications/ # Notifications
├── plugins/ # Plugin metadata
├── settings/ # User settings
└── files/ # File metadata
import { db, schema } from '@elyos/database';
import { db, schema } from '@elyos/database';
import { eq, and, like, desc, asc } from 'drizzle-orm';
// All records
const users = await db.select().from(schema.users);
// With filter
const activeUsers = await db
.select()
.from(schema.users)
.where(eq(schema.users.isActive, true));
// Multiple conditions
const result = await db
.select()
.from(schema.users)
.where(
and(
eq(schema.users.isActive, true),
like(schema.users.name, '%admin%')
)
)
.orderBy(desc(schema.users.createdAt))
.limit(20)
.offset(0);
// Single record
const user = await db
.select()
.from(schema.users)
.where(eq(schema.users.id, userId))
.then(rows => rows[0] ?? null);
const [newUser] = await db
.insert(schema.users)
.values({
name: 'Test User',
email: 'test@example.com',
isActive: true
})
.returning();
const [updated] = await db
.update(schema.users)
.set({ name: 'New Name', updatedAt: new Date() })
.where(eq(schema.users.id, userId))
.returning();
await db
.delete(schema.users)
.where(eq(schema.users.id, userId));
const usersWithGroups = await db
.select({
userId: schema.users.id,
userName: schema.users.name,
groupName: schema.groups.name
})
.from(schema.users)
.leftJoin(
schema.userGroups,
eq(schema.users.id, schema.userGroups.userId)
)
.leftJoin(
schema.groups,
eq(schema.userGroups.groupId, schema.groups.id)
);

Database operations are organized in repository classes. Each repository contains CRUD operations and business logic for a specific entity.

src/lib/server/database/repositories/user-repository.ts
import { db, schema } from '@elyos/database';
import { eq, and, like, desc, count } from 'drizzle-orm';
export class UserRepository {
async findById(id: number) {
return db
.select()
.from(schema.users)
.where(eq(schema.users.id, id))
.then(rows => rows[0] ?? null);
}
async findManyPaginated(params: {
limit: number;
offset: number;
search?: string;
}) {
const conditions = [];
if (params.search) {
conditions.push(like(schema.users.name, `%${params.search}%`));
}
return db
.select()
.from(schema.users)
.where(conditions.length ? and(...conditions) : undefined)
.orderBy(desc(schema.users.createdAt))
.limit(params.limit)
.offset(params.offset);
}
async countAll(params: { search?: string } = {}) {
const conditions = [];
if (params.search) {
conditions.push(like(schema.users.name, `%${params.search}%`));
}
const [result] = await db
.select({ count: count() })
.from(schema.users)
.where(conditions.length ? and(...conditions) : undefined);
return result?.count ?? 0;
}
}
// Singleton export
export const userRepository = new UserRepository();
import { userRepository } from '$lib/server/database/repositories';
ExportDescription
userRepositoryUser management
groupRepositoryGroup management
roleRepositoryRole management
permissionRepositoryPermission management
appRepositoryApplication registration
notificationRepositoryNotifications
translationRepositoryTranslations (i18n)
themePresetsRepositoryTheme presets

When adding a new table, work in the packages/database/src/schemas/platform/ directory:

packages/database/src/schemas/platform/items/schema.ts
import { pgTable, serial, text, boolean, timestamp, integer } from 'drizzle-orm/pg-core';
export const items = pgTable('items', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
description: text('description'),
isActive: boolean('is_active').notNull().default(true),
userId: integer('user_id').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow()
});
packages/database/src/schemas/platform/relations.ts
import { relations } from 'drizzle-orm';
import { items } from './items/schema';
import { users } from '../auth/users/schema';
export const itemsRelations = relations(items, ({ one }) => ({
user: one(users, {
fields: [items.userId],
references: [users.id]
})
}));

Always generate a migration after schema changes:

Terminál
# Generate migration
bun db:generate
# Run migration
bun db:migrate
# Visual inspection
bun db:studio

Seed data populates the database with initial data — default users, roles, applications, translations, etc. The ElyOS seed system is idempotent (safe to run multiple times) and dependency-based (automatic ordering).

packages/database/src/seeds/
├── config.ts # Seed definitions and dependencies
├── runner.ts # Seed execution logic
├── init-db.ts # Full database initialization
├── reset.ts # Full database reset (Docker)
├── demo-reset.ts # Demo environment reset
├── sql/ # SQL seed files
│ ├── auth/ # Auth schema seeds
│ │ ├── users.sql
│ │ ├── roles.sql
│ │ ├── groups.sql
│ │ ├── permissions.sql
│ │ └── ...
│ └── platform/ # Platform schema seeds
│ ├── apps.sql
│ ├── locales.sql
│ ├── translations_*.sql
│ └── ...
└── procedures/ # Stored procedures
└── auth/
├── getGroups.sql
└── ...

The config.ts file defines all seeds and their dependencies:

export const seedConfig: Record<string, SeedDefinition> = {
// No dependencies
roles: {
file: 'auth/roles.sql',
dependsOn: [],
description: 'User roles'
},
// Depends on roles seed
role_permissions: {
file: 'auth/role_permissions.sql',
dependsOn: ['roles', 'permissions'],
description: 'Role-permission assignments'
}
};

The seed runner automatically performs topological sorting, ensuring dependencies run in the correct order.

Terminál
# Full database initialization (schema + migration + seed)
bun db:init
# Run seeds only (idempotent, no truncate)
bun db:seed
# Full reset (truncate + seed)
bun db:reset
# Run only specific seeds (no truncate)
bun db:seed --no-truncate --only=users,roles,apps
# Run only specific procedures
bun db:seed --no-truncate --only-procedures=get_groups,get_groups_2

Every seed file uses ON CONFLICT DO UPDATE logic, making it safe to run multiple times:

-- auth/roles.sql
INSERT INTO auth.roles (id, name, description) VALUES
(1, '{"hu": "Rendszergazda", "en": "System Administrator"}',
'{"hu": "Korlátlan jogosultsággal rendelkező szerep", "en": "Role with unlimited privileges"}'),
(2, '{"hu": "Adminisztrátor", "en": "Administrator"}',
'{"hu": "Adminisztrációs feladatok elvégzésére jogosult szerep", "en": "Role authorized for administrative tasks"}')
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description;
-- Update sequence based on max id
SELECT setval('auth.roles_id_seq', (SELECT COALESCE(MAX(id), 0) FROM auth.roles));
SeedDescriptionDependencies
resourcesResource definitions (permission system)-
providersAuth providers (email, Google)-
groupsUser groups-
rolesRoles (admin, user, etc.)-
permissionsPermissionsresources
role_permissionsRole-permission assignmentsroles, permissions
group_permissionsGroup-permission assignmentsgroups, permissions
usersInitial users (admin)-
accountsAuth accountsusers, providers
user_rolesUser-role assignmentsusers, roles
user_groupsUser-group assignmentsusers, groups
role_app_accessRole-app accessapps, roles
group_app_accessGroup-app accessapps, groups
SeedDescriptionDependencies
localesSupported languages (hu, en)-
translations_commonCommon translations (buttons, statuses)locales
translations_settingsSettings app translationslocales
translations_logLog app translationslocales
translations_desktopDesktop environment translationslocales
translations_authAuth pages translationslocales
translations_userUsers app translationslocales
translations_notificationsNotification system translationslocales
translations_plugin_managerPlugin Manager translationslocales
appsApplication registration (metadata)-
email_templatesEmail templates (HU/EN)locales
theme_presetsTheme presetslocales

The users.sql seed creates a system administrator user:

INSERT INTO auth.users (id, full_name, email, email_verified, username, image, user_settings, oauth_image) VALUES
(1, 'ElyOS admin', 'youradminemail@yourdomain.com', true, null, null, '{}', null)
ON CONFLICT (id) DO UPDATE SET
full_name = EXCLUDED.full_name,
email_verified = EXCLUDED.email_verified;

The admin email can be overridden at runtime via the ADMIN_USER_EMAIL environment variable:

.env
ADMIN_USER_EMAIL=admin@example.com

The seed runner automatically updates the admin user’s email:

runner.ts
async function applyAdminEmail() {
const adminEmail = process.env.ADMIN_USER_EMAIL?.trim();
if (!adminEmail) return;
await pool.query(
`UPDATE auth.users SET email = $1 WHERE id = (SELECT id FROM auth.users ORDER BY id ASC LIMIT 1)`,
[adminEmail]
);
}
  1. Create the SQL file:
-- packages/database/src/seeds/sql/platform/my_data.sql
INSERT INTO platform.my_table (id, name, value) VALUES
(1, 'Item 1', 'Value 1'),
(2, 'Item 2', 'Value 2')
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
value = EXCLUDED.value;
SELECT setval('platform.my_table_id_seq', (SELECT COALESCE(MAX(id), 0) FROM platform.my_table));
  1. Register in config.ts:
export const seedConfig: Record<string, SeedDefinition> = {
// ... existing seeds
my_data: {
file: 'platform/my_data.sql',
dependsOn: ['locales'],
description: 'My custom data'
}
};
  1. Update truncate order (if needed):
export const truncateOrder = [
// ... existing tables
'platform.my_table',
];
  1. Run the seed:
Terminál
bun db:seed

The seed system also supports creating stored procedures:

config.ts
export const procedureConfig: Record<string, ProcedureDefinition> = {
get_groups: {
file: 'auth/getGroups.sql',
description: 'Get groups by ID'
}
};
-- procedures/auth/getGroups.sql
CREATE OR REPLACE FUNCTION auth.get_groups(group_ids INTEGER[])
RETURNS TABLE (
id INTEGER,
name JSONB,
description JSONB
) AS $
BEGIN
RETURN QUERY
SELECT g.id, g.name, g.description
FROM auth.groups g
WHERE g.id = ANY(group_ids);
END;
$ LANGUAGE plpgsql;

Docker Compose automatically runs seeds in the db-init container:

db-init:
command: >
sh -c 'bun --filter @elyos/database db:init ${RESET:+-- --reset}'
depends_on:
postgres:
condition: service_healthy

Normal startup (idempotent):

Terminál
bun docker:up

Full reset (truncate + seed):

Terminál
RESET=1 bun docker:up

The db:init command performs these steps:

  1. Check PostgreSQL availability — health check
  2. Create schemasauth, platform, extensions
  3. Enable PostgreSQL extensionspostgres-json-schema
  4. Run Drizzle migrations — apply schema
  5. Run seeds — in dependency order
  6. Create stored procedures — if any
  7. Update admin email — if ADMIN_USER_EMAIL is set
  1. Always use upsert logicON CONFLICT DO UPDATE
  2. Update sequencesSELECT setval(...)
  3. Define dependencies — in the dependsOn array
  4. Use descriptive namesdescription field
  5. Test idempotency — run multiple times
  6. Document data — with SQL comments
  7. Use JSONB for multilingual data{"hu": "...", "en": "..."}

Problem: Seed error — duplicate key value violates unique constraint

Solution: Verify the ON CONFLICT clause is properly configured.


Problem: Sequence not updating — auto-increment conflicts

Solution: Add sequence update at the end of the seed:

SELECT setval('schema.table_id_seq', (SELECT COALESCE(MAX(id), 0) FROM schema.table));

Problem: Dependency error — seed not running in correct order

Solution: Check the dependsOn array in config.ts.

await db.transaction(async (tx) => {
const [user] = await tx
.insert(schema.users)
.values({ name: 'New User', email: 'new@example.com' })
.returning();
await tx
.insert(schema.userGroups)
.values({ userId: user.id, groupId: defaultGroupId });
});
import { ensureDatabaseHealth } from '$lib/server/database/health';
// Check if database is accessible
await ensureDatabaseHealth();
import { validatePaginationParams } from '$lib/server/utils/database';
const { page, limit, offset } = validatePaginationParams(
input.page, // requested page (1-based)
input.pageSize // page size
);