Skip to main content

Data Model

:::info Source Sourced from services/authoring-service/06-DATA_MODEL.md in the documentation repo. :::

Companion: 02 Domain Model · 12 Data Models · 13 Security & Tenancy


1. Database Overview

AttributeValue
EnginePostgreSQL 16
Schemaauthoring
Connection poolPgBouncer (transaction mode), max 50 per instance
MigrationsDrizzle Kit, versioned sequentially
Tenant isolationRow-Level Security (RLS) on every table
Session configSET app.tenant_id = '...' per request
Extensionspg_trgm, btree_gin, pgcrypto

2. Schema Overview

┌────────────────────────────────────────────────────────────────┐
│ authoring (schema) │
├────────────────────────────────────────────────────────────────┤
│ course_drafts ◄───┐ │
│ │ │ │
│ │ │ │
│ ▼ │ │
│ module_drafts │ │
│ │ │ │
│ ▼ │ │
│ lesson_drafts │ │
│ │ │ │
│ ▼ │ │
│ blocks ───► block_versions │
│ │
│ collaboration_sessions ──► yjs_state │
│ publish_sagas │
│ ai_jobs │
│ draft_collaborators │
│ outbox │
│ event_inbox │
│ idempotency_keys │
│ scorm_imports │
└────────────────────────────────────────────────────────────────┘

3. Table: course_drafts

CREATE TABLE authoring.course_drafts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
published_course_id uuid,
title jsonb NOT NULL, -- I18nString
default_locale text NOT NULL,
state text NOT NULL DEFAULT 'editing'
CHECK (state IN ('editing','in_review','approved','publishing','published_idle')),
draft_version bigint NOT NULL DEFAULT 1,
settings jsonb NOT NULL DEFAULT '{}',
created_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz, -- soft delete
CONSTRAINT chk_title_has_default CHECK (title ? default_locale)
);

CREATE INDEX idx_drafts_tenant_state ON authoring.course_drafts (tenant_id, state) WHERE deleted_at IS NULL;
CREATE INDEX idx_drafts_tenant_updated ON authoring.course_drafts (tenant_id, updated_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_drafts_published_course ON authoring.course_drafts (published_course_id) WHERE published_course_id IS NOT NULL;

ALTER TABLE authoring.course_drafts ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_drafts_tenant ON authoring.course_drafts
USING (tenant_id = current_setting('app.tenant_id')::uuid);

4. Table: module_drafts

CREATE TABLE authoring.module_drafts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
course_draft_id uuid NOT NULL REFERENCES authoring.course_drafts(id) ON DELETE CASCADE,
title jsonb NOT NULL,
sort_order integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (course_draft_id, sort_order) DEFERRABLE INITIALLY DEFERRED
);

CREATE INDEX idx_modules_draft ON authoring.module_drafts (course_draft_id, sort_order);

ALTER TABLE authoring.module_drafts ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_modules_tenant ON authoring.module_drafts
USING (tenant_id = current_setting('app.tenant_id')::uuid);

5. Table: lesson_drafts

CREATE TABLE authoring.lesson_drafts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
module_draft_id uuid NOT NULL REFERENCES authoring.module_drafts(id) ON DELETE CASCADE,
course_draft_id uuid NOT NULL REFERENCES authoring.course_drafts(id) ON DELETE CASCADE,
title jsonb NOT NULL,
sort_order integer NOT NULL,
estimated_minutes integer,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (module_draft_id, sort_order) DEFERRABLE INITIALLY DEFERRED
);

CREATE INDEX idx_lessons_module ON authoring.lesson_drafts (module_draft_id, sort_order);
CREATE INDEX idx_lessons_draft ON authoring.lesson_drafts (course_draft_id);

ALTER TABLE authoring.lesson_drafts ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_lessons_tenant ON authoring.lesson_drafts
USING (tenant_id = current_setting('app.tenant_id')::uuid);

6. Table: blocks

CREATE TABLE authoring.blocks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
course_draft_id uuid NOT NULL REFERENCES authoring.course_drafts(id) ON DELETE CASCADE,
lesson_draft_id uuid NOT NULL REFERENCES authoring.lesson_drafts(id) ON DELETE CASCADE,
kind text NOT NULL
CHECK (kind IN (
'text','heading','list','callout','divider',
'image','image_grid','video','audio','embed','code_snippet',
'quiz','branching','hotspot','drag_drop','sortable',
'click_reveal','flashcards','accordion','tabs',
'timeline','gallery','button','downloadable_attachment',
'interaction','ai'
)),
status text NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','draft_ai','reviewed','published')),
sort_order integer NOT NULL,
required boolean NOT NULL DEFAULT false,
data jsonb NOT NULL, -- kind-specific payload
ai_provenance jsonb, -- AIProvenance VO
reviewed_by uuid,
reviewed_at timestamptz,
created_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (lesson_draft_id, sort_order) DEFERRABLE INITIALLY DEFERRED,
CONSTRAINT chk_ai_provenance CHECK (
(status = 'draft_ai' AND ai_provenance IS NOT NULL)
OR (status <> 'draft_ai')
),
CONSTRAINT chk_ai_not_required CHECK (
NOT (status = 'draft_ai' AND required = true)
)
);

CREATE INDEX idx_blocks_lesson ON authoring.blocks (lesson_draft_id, sort_order);
CREATE INDEX idx_blocks_draft ON authoring.blocks (course_draft_id);
CREATE INDEX idx_blocks_status ON authoring.blocks (course_draft_id, status);
CREATE INDEX idx_blocks_ai_pending ON authoring.blocks (course_draft_id) WHERE status = 'draft_ai';

-- GIN index on jsonb data for search/filter
CREATE INDEX idx_blocks_data ON authoring.blocks USING GIN (data jsonb_path_ops);

ALTER TABLE authoring.blocks ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_blocks_tenant ON authoring.blocks
USING (tenant_id = current_setting('app.tenant_id')::uuid);

7. Table: block_versions

Immutable audit history of every block change.

CREATE TABLE authoring.block_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
block_id uuid NOT NULL,
course_draft_id uuid NOT NULL,
version_number integer NOT NULL,
snapshot jsonb NOT NULL, -- full block state at version
change_type text NOT NULL
CHECK (change_type IN ('created','updated','ai_generated','reviewed','deleted')),
changed_by uuid NOT NULL,
changed_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (block_id, version_number)
);

CREATE INDEX idx_block_versions_block ON authoring.block_versions (block_id, version_number DESC);
CREATE INDEX idx_block_versions_draft ON authoring.block_versions (course_draft_id, changed_at DESC);

ALTER TABLE authoring.block_versions ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_block_versions_tenant ON authoring.block_versions
USING (tenant_id = current_setting('app.tenant_id')::uuid);

8. Table: draft_collaborators

CREATE TABLE authoring.draft_collaborators (
course_draft_id uuid NOT NULL REFERENCES authoring.course_drafts(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
role text NOT NULL DEFAULT 'editor'
CHECK (role IN ('owner','editor','reviewer','viewer')),
added_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (course_draft_id, user_id)
);

CREATE INDEX idx_collab_user ON authoring.draft_collaborators (tenant_id, user_id);

ALTER TABLE authoring.draft_collaborators ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_collab_tenant ON authoring.draft_collaborators
USING (tenant_id = current_setting('app.tenant_id')::uuid);

9. Table: collaboration_sessions

CREATE TABLE authoring.collaboration_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
course_draft_id uuid NOT NULL REFERENCES authoring.course_drafts(id) ON DELETE CASCADE,
started_at timestamptz NOT NULL DEFAULT now(),
last_activity_at timestamptz NOT NULL DEFAULT now(),
ended_at timestamptz
);

CREATE INDEX idx_sessions_draft ON authoring.collaboration_sessions (course_draft_id) WHERE ended_at IS NULL;

ALTER TABLE authoring.collaboration_sessions ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_sessions_tenant ON authoring.collaboration_sessions
USING (tenant_id = current_setting('app.tenant_id')::uuid);

10. Table: yjs_state

Stores Yjs binary update log per draft. M4+ only.

CREATE TABLE authoring.yjs_state (
course_draft_id uuid PRIMARY KEY REFERENCES authoring.course_drafts(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
snapshot bytea NOT NULL, -- Y.encodeStateAsUpdate
snapshot_version bigint NOT NULL,
updates bytea, -- concatenated updates since snapshot
update_count integer NOT NULL DEFAULT 0,
last_updated_at timestamptz NOT NULL DEFAULT now()
);

ALTER TABLE authoring.yjs_state ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_yjs_tenant ON authoring.yjs_state
USING (tenant_id = current_setting('app.tenant_id')::uuid);

Compaction policy: when update_count > 500, merge updates into snapshot and reset.

11. Table: publish_sagas

CREATE TABLE authoring.publish_sagas (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
course_draft_id uuid NOT NULL REFERENCES authoring.course_drafts(id),
current_step text NOT NULL
CHECK (current_step IN ('building','cataloging','bundling','ready','failed','compensating')),
started_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz,
timeout_at timestamptz NOT NULL,
failure_reason text,
completed_steps jsonb NOT NULL DEFAULT '[]', -- SagaStepRecord[]
triggering_user_id uuid NOT NULL,
UNIQUE (course_draft_id) DEFERRABLE INITIALLY DEFERRED -- only one active saga per draft
);

CREATE INDEX idx_sagas_timeout ON authoring.publish_sagas (timeout_at) WHERE completed_at IS NULL;
CREATE INDEX idx_sagas_step ON authoring.publish_sagas (current_step) WHERE completed_at IS NULL;

ALTER TABLE authoring.publish_sagas ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_sagas_tenant ON authoring.publish_sagas
USING (tenant_id = current_setting('app.tenant_id')::uuid);

12. Table: ai_jobs

CREATE TABLE authoring.ai_jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
course_draft_id uuid NOT NULL REFERENCES authoring.course_drafts(id) ON DELETE CASCADE,
lesson_id uuid,
block_id uuid,
job_type text NOT NULL
CHECK (job_type IN ('generate_block','improve_block','generate_quiz','translate','summarize')),
status text NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued','running','succeeded','failed','cancelled')),
prompt_id text NOT NULL,
prompt_version text NOT NULL,
ai_gateway_job_id text, -- correlation with ai-gateway-service
input jsonb NOT NULL,
result jsonb,
error text,
requested_by uuid NOT NULL,
requested_at timestamptz NOT NULL DEFAULT now(),
started_at timestamptz,
completed_at timestamptz
);

CREATE INDEX idx_ai_jobs_draft ON authoring.ai_jobs (course_draft_id, requested_at DESC);
CREATE INDEX idx_ai_jobs_status ON authoring.ai_jobs (status) WHERE status IN ('queued','running');
CREATE INDEX idx_ai_jobs_gateway ON authoring.ai_jobs (ai_gateway_job_id) WHERE ai_gateway_job_id IS NOT NULL;

ALTER TABLE authoring.ai_jobs ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_ai_jobs_tenant ON authoring.ai_jobs
USING (tenant_id = current_setting('app.tenant_id')::uuid);

13. Table: scorm_imports

CREATE TABLE authoring.scorm_imports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
course_draft_id uuid NOT NULL REFERENCES authoring.course_drafts(id) ON DELETE CASCADE,
source_url text NOT NULL,
scorm_version text NOT NULL CHECK (scorm_version IN ('1.2','2004')),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','parsing','mapping','succeeded','failed')),
manifest jsonb,
warnings jsonb NOT NULL DEFAULT '[]',
error text,
requested_by uuid NOT NULL,
requested_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);

CREATE INDEX idx_scorm_draft ON authoring.scorm_imports (course_draft_id);

ALTER TABLE authoring.scorm_imports ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_scorm_tenant ON authoring.scorm_imports
USING (tenant_id = current_setting('app.tenant_id')::uuid);

14. Table: outbox

(See 05-EVENT_SCHEMAS.md §6.)

15. Table: event_inbox

(See 05-EVENT_SCHEMAS.md §7.)

16. Table: idempotency_keys

CREATE TABLE authoring.idempotency_keys (
key text PRIMARY KEY,
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
request_hash text NOT NULL,
response_status integer NOT NULL,
response_body jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL DEFAULT (now() + interval '24 hours')
);

CREATE INDEX idx_idempotency_expires ON authoring.idempotency_keys (expires_at);

Cleanup job runs hourly: DELETE FROM idempotency_keys WHERE expires_at < now().

17. Views (Read Projections)

17.1 v_draft_summary

CREATE VIEW authoring.v_draft_summary AS
SELECT
d.id,
d.tenant_id,
d.title,
d.state,
d.default_locale,
d.draft_version,
d.created_by,
d.created_at,
d.updated_at,
COUNT(DISTINCT m.id) AS module_count,
COUNT(DISTINCT l.id) AS lesson_count,
COUNT(DISTINCT b.id) AS block_count,
COUNT(DISTINCT b.id) FILTER (WHERE b.status = 'draft_ai') AS pending_ai_blocks,
COUNT(DISTINCT dc.user_id) AS collaborator_count
FROM authoring.course_drafts d
LEFT JOIN authoring.module_drafts m ON m.course_draft_id = d.id
LEFT JOIN authoring.lesson_drafts l ON l.course_draft_id = d.id
LEFT JOIN authoring.blocks b ON b.course_draft_id = d.id
LEFT JOIN authoring.draft_collaborators dc ON dc.course_draft_id = d.id
WHERE d.deleted_at IS NULL
GROUP BY d.id;

18. Indexes Summary

TableIndexPurpose
course_drafts(tenant_id, state)Filter by state in lists
course_drafts(tenant_id, updated_at DESC)Recent-first listings
blocks(lesson_draft_id, sort_order)Ordered block retrieval
blocks(course_draft_id, status)Status filtering
blocks (partial)(course_draft_id) where status='draft_ai'Pending AI blocks
blocks (GIN)data jsonb_path_opsJSON payload search
publish_sagas(timeout_at) where completed_at IS NULLTimeout poller
ai_jobs(status) where status IN (queued,running)Active jobs scan
outbox(occurred_at) where published_at IS NULLOutbox relay

19. Storage Estimates

TableRows per 1000 draftsDisk
course_drafts1,000~1 MB
module_drafts5,000~2 MB
lesson_drafts25,000~8 MB
blocks250,000~500 MB (average block data ~2 KB)
block_versions2,500,000 (10 versions avg)~5 GB
outbox500,000 (cleared as published)~200 MB steady
yjs_state1,000~100 MB (100 KB avg per draft)

20. Retention & Archival

TableHot retentionArchive
course_draftsIndefinite (active tenants)7y after tenant closure
block_versions2 yearsS3 parquet after 2y
outboxUntil publishedDeleted 48h after published
event_inbox90 daysDeleted
idempotency_keys24 hoursDeleted
ai_jobs90 daysS3 parquet
publish_sagas180 daysS3 parquet

21. Backup Strategy

  • Daily logical backups (pg_dump) + PITR via WAL archiving
  • Per-tenant export available via compliance API
  • Backup restore tested quarterly in staging