Skip to main content

Migrations & autoApi

Every module manages its own schema via versioned SQL migrations. The Module Registry runs pressly/goose automatically during install and update — modules never run migrations themselves. Declaring autoApi: true on a model in the manifest is all that is needed to get five auto-generated CRUD endpoints and three RBAC permissions.


Migration File Layout

Each module scaffold contains a migrations/ directory with SQL files in goose format:

my-module/
├── module.manifest.json
├── migrations/
│ ├── 00001_create_contacts.sql
│ ├── 00002_add_status_index.sql
│ └── 00003_add_tags_table.sql
└── ...

Every .sql file uses the goose directives:

-- migrations/00001_create_contacts.sql
-- +goose Up
CREATE TABLE module_crm.contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
email TEXT,
status TEXT NOT NULL DEFAULT 'active',
metadata JSONB,
tenant_id UUID NOT NULL, -- mandatory for every module table
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
idempotency_key UUID UNIQUE
);

CREATE INDEX ON module_crm.contacts(tenant_id);
CREATE INDEX ON module_crm.contacts(status);

-- +goose Down
DROP TABLE IF EXISTS module_crm.contacts;

Table namespace: Every module's tables live in a PostgreSQL schema named module_{moduleSlug}. This guarantees that two different modules can each have a contacts table without conflict (module_crm.contacts vs module_hr.contacts). An advisory lock on the schema name prevents concurrent installation of two modules with the same slug.


Migration Lifecycle

EventWhat happens
InstallModule Registry validates manifest → runs goose up from migrations/ → RLS policy auto-applied to each new table → permissions registered if autoApi: true
UpdateNew module version may contain new migration files → Registry runs goose up (incremental only) → post-migration permission reconciliation
DeactivateTables remain. Data preserved. Module stops loading.
DeleteSoft delete. Data remains with a deleted marker. Full schema purge requires explicit kernel-cli purge-module --module=crm by Platform Owner.

Migration Failure Handling

Automatic rollback is not performed — rolling back SQL migrations can destroy tenant data irreversibly.

SituationBehaviour
Migration N of M fails (SQL error)goose marks migration as pending. Module enters ERROR status. All successful migrations before N remain. No automatic rollback.
Module in ERROR statusModule does not load in UI Shell. Module Registry returns { "status": "error", "migrationError": "..." }. Admin sees the error in Admin → Modules → Status.
Fix pathDeveloper fixes SQL → publishes hotfix → PATCH /api/v1/modules/:id with new version → Registry runs pending migrations. Or: Admin → Modules → Retry Migration.
Manual interventionPlatform Owner via CLI: kernel-cli migration force-version --module=crm --version=3 — clears dirty flag without re-running the failed migration.

autoApi — Auto-generated CRUD

Marking a model autoApi: true in module.manifest.json instructs the Data Layer to generate 5 REST endpoints when the migration completes:

{
"name": "@acme/crm",
"version": "1.2.0",
"models": [
{
"name": "contacts",
"table": "module_crm.contacts",
"autoApi": true
},
{
"name": "companies",
"table": "module_crm.companies",
"autoApi": true
},
{
"name": "internal_config",
"table": "module_crm.internal_config",
"autoApi": false ← not exposed — internal table
}
]
}

Models without autoApi: true are never exposed via the REST API. This is an explicit opt-in — internal tables, audit tables, and configuration tables should not be autoApi: true.


Post-migration Permission Reconciliation

Every time a module update completes, the Data Layer runs a reconciliation pass to keep RBAC permissions in sync with the actual schema:

1. goose migrations complete successfully
2. Data Layer scans information_schema.tables
→ finds all tables in module_crm.* WHERE table_comment = 'autoApi'
3. Compares with registered permissions for this module in IAM
4. New tables (autoApi: true) → register crm.{model}.read/write/delete
5. Removed tables (was autoApi, now gone) → archive crm.{model}.* in IAM
(archived permissions: hidden from role builder, return false on
hasPermission() even if a role still holds them)
6. On partial migration failure:
→ reconciliation NOT run
→ retried at next healthcheck / service restart
→ env: DATA_PERMISSION_RECONCILIATION_ON_MIGRATE=true (default: true)

This prevents orphaned permissions — role builder never shows permissions for tables that no longer exist.


Schema Introspection

The Data Layer reads information_schema at service start and caches the result. The cache is refreshed after any module install/update.

List all models for the current module

GET https://api.septemcore.com/v1/data/crm/models
Authorization: Bearer <access_token>
{
"data": [
{
"name": "contacts",
"table": "module_crm.contacts",
"autoApi": true,
"columns": 10,
"relations": ["companies", "tags"]
},
{
"name": "companies",
"table": "module_crm.companies",
"autoApi": true,
"columns": 6,
"relations": []
}
]
}

SDK:

const models = await kernel.data().listModels();
// [{ name: 'contacts', autoApi: true, ... }, ...]

Describe a specific model

GET https://api.septemcore.com/v1/data/crm/models/contacts
Authorization: Bearer <access_token>
{
"name": "contacts",
"table": "module_crm.contacts",
"autoApi": true,
"columns": [
{ "name": "id", "type": "uuid", "nullable": false, "pk": true },
{ "name": "name", "type": "text", "nullable": false },
{ "name": "email", "type": "text", "nullable": true },
{ "name": "status", "type": "text", "nullable": false, "default": "active" },
{ "name": "metadata", "type": "jsonb", "nullable": true },
{ "name": "tenant_id", "type": "uuid", "nullable": false },
{ "name": "created_at", "type": "timestamptz", "nullable": false },
{ "name": "updated_at", "type": "timestamptz", "nullable": false },
{ "name": "deleted_at", "type": "timestamptz", "nullable": true }
],
"indexes": [
{ "name": "contacts_tenant_id_idx", "columns": ["tenant_id"] },
{ "name": "contacts_status_idx", "columns": ["status"] }
],
"relations": [
{ "name": "company", "fk": "company_id", "references": "module_crm.companies" }
]
}

SDK:

const schema = await kernel.data().describeModel('contacts');
// schema.columns, schema.relations, schema.indexes

Required Permissions

ActionPermission
List models{module}.*.read (any read permission for the module)
Describe model{module}.*.read
Trigger migration retryAdmin UI only (modules.install system permission)
Force version via CLIPlatform Owner only