Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/cross-schema-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Cross-Schema Type Safety

## Problem

`fluent-api` (TypeScript / Drizzle) and `fluent-ai` (Python / SQLAlchemy + Alembic) share a single PostgreSQL database. Each service owns a distinct schema:

- `fluent-api` owns `public`, `pgboss`, `drizzle`
- `fluent-ai` owns `ai`

When `fluent-api` needs to read or write tables in the `ai` schema (e.g. `ai_suggestion_jobs`, `ai_suggestions`), Drizzle's query builder requires table definitions in the codebase. Previously these definitions were inlined in `src/db/schema.ts`, which created three problems:

1. **Schema ownership was unclear** — AI tables sat alongside public tables in the same file.
2. **Drizzle Kit generated migrations for AI tables** — even though they are managed by Alembic in `fluent-ai`.
3. **Schema drift risk** — changes in `fluent-ai` could silently diverge from the Drizzle stubs in `fluent-api`.

## Decision

Extract externally-owned schema stubs into a dedicated `src/db/external/` directory.

### What changed

- `src/db/external/ai-schema.ts` — read-only Drizzle stubs for the `ai` schema.
- `src/db/schema.ts` — no longer contains AI table definitions or the unused `aiSuggestionJobStatusEnum`.
- Consumers (`ai-suggestions.repository.ts`, `clean-ai-jobs.ts`) import AI tables from `@/db/external/ai-schema`.

### Why this approach

We evaluated several options:

| Approach | Verdict |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| **HTTP API boundary** | Overkill for same-DB data. Adds network hops, serialization, auth, and latency to solve a type-definition problem. |
| **Raw SQL + Zod** | Loses Drizzle joins and relational inference. More boilerplate, not less. |
| **Shared npm package** | Impossible — `fluent-ai` is Python, not Node.js. |
| **Views in `public`** | Breaks inserts/updates on `ai` tables that `fluent-api` legitimately writes to. |
| **Co-located stubs (`src/db/external/`)** | Keeps Drizzle type safety with minimal complexity. Makes ownership explicit. No new infrastructure. |

### Trade-offs

- **Staleness risk**: If `fluent-ai` changes a column, `fluent-api`'s stub becomes stale. Mitigation: regenerate from the database when schema changes occur.
- **Regeneration is manual today**: We do not yet automate introspection in CI. This is acceptable because the `ai` schema changes infrequently.

## Regenerating stubs

When `fluent-ai` migrates the `ai` schema, update the stubs:

```bash
npx drizzle-kit introspect --tables='ai.*' --out=./src/db/external/ai-schema.ts
```

Review the diff, commit, and run `npm run typecheck` before merging.

## Future considerations

- If the ecosystem grows and services split onto separate databases, the natural next step is an HTTP API boundary (or async events via PgBoss) rather than cross-database queries.
- If schema drift becomes painful, automate introspection in CI: run it after `fluent-ai` migrations, diff against the checked-in file, and fail the build on unexpected changes.
1 change: 1 addition & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import '@/domains/chapter-assignments/editor-state/user-chapter-assignment-edito
import '@/domains/projects/users/project-users.route';
import '@/domains/users/projects/user-projects.route';
import '@/domains/chapter-assignments/presence/chapter-assignments-presence.route';
import '@/domains/ai-suggestions/ai-suggestions.route';
configureOpenAPI(server);

export default server;
164 changes: 164 additions & 0 deletions src/db/external/ai-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { z } from '@hono/zod-openapi';
/**
* EXTERNALLY OWNED — `ai` schema stubs for Drizzle type safety.
*
* These tables live in the `ai` schema and are owned by `fluent-ai`
* (Alembic-managed). `fluent-api` needs read/write access to them via
* `role_web_data` / `role_pgboss_user` grants, so we maintain lightweight
* Drizzle stubs here.
*
* DO NOT hand-edit column definitions. When `fluent-ai` changes the
* `ai` schema, regenerate this file from the database:
*
* npx drizzle-kit introspect --tables='ai.*' --out=./src/db/external/ai-schema.ts
*
* Then review the diff and commit.
*/
import {
boolean,
index,
integer,
pgSchema,
serial,
text,
timestamp,
uniqueIndex,
varchar,
} from 'drizzle-orm/pg-core';
import { createSchemaFactory } from 'drizzle-zod';

import { bible_texts, bibles, project_units, users } from '@/db/schema';

export const aiSchema = pgSchema('ai');

export const ai_suggestion_jobs = aiSchema.table(
'ai_suggestion_jobs',
{
id: serial('id').primaryKey(),
projectUnitId: integer('project_unit_id')
.notNull()
.references(() => project_units.id, { onDelete: 'cascade' }),
bibleId: integer('bible_id')
.notNull()
.references(() => bibles.id),
bookCode: varchar('book_code', { length: 50 }).notNull(),
chapterNumber: integer('chapter_number').notNull(),
verseStart: integer('verse_start').notNull(),
verseEnd: integer('verse_end').notNull(),
status: varchar('status', { length: 20 }).notNull().default('queued'),
retryCount: integer('retry_count').notNull().default(0),
errorMessage: text('error_message'),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => [
index('idx_ai_jobs_project_unit').on(table.projectUnitId),
index('idx_ai_jobs_status').on(table.status),
uniqueIndex('uq_ai_jobs_range').on(
table.projectUnitId,
table.bibleId,
table.bookCode,
table.chapterNumber,
table.verseStart,
table.verseEnd
),
]
);

export const ai_suggestions = aiSchema.table(
'ai_suggestions',
{
id: serial('id').primaryKey(),
bibleTextId: integer('bible_text_id')
.notNull()
.references(() => bible_texts.id, { onDelete: 'cascade' }),
projectUnitId: integer('project_unit_id')
.notNull()
.references(() => project_units.id, { onDelete: 'cascade' }),
suggestedText: text('suggested_text').notNull(),
modelInfo: varchar('model_info', { length: 100 }),
createdAt: timestamp('created_at').defaultNow(),
},
(table) => [
index('idx_ai_suggestions_bible_text').on(table.bibleTextId),
uniqueIndex('uq_ai_suggestions_per_text_unit').on(table.bibleTextId, table.projectUnitId),
]
);

export const ai_suggestion_usage_log = aiSchema.table(
'ai_suggestion_usage_log',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
bibleTextId: integer('bible_text_id')
.notNull()
.references(() => bible_texts.id, { onDelete: 'cascade' }),
projectUnitId: integer('project_unit_id')
.notNull()
.references(() => project_units.id, { onDelete: 'cascade' }),
wasUsed: boolean('was_used').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => [
index('idx_ai_usage_user').on(table.userId),
index('idx_ai_usage_project_unit').on(table.projectUnitId),
uniqueIndex('uq_ai_usage_user_text').on(table.userId, table.bibleTextId),
]
);

const { createInsertSchema, createSelectSchema } = createSchemaFactory({
zodInstance: z,
});

export const selectAiSuggestionJobsSchema = createSelectSchema(ai_suggestion_jobs);
export const selectAiSuggestionsSchema = createSelectSchema(ai_suggestions);
export const selectAiSuggestionUsageLogSchema = createSelectSchema(ai_suggestion_usage_log);

export const insertAiSuggestionJobsSchema = createInsertSchema(ai_suggestion_jobs, {
projectUnitId: (schema) => schema.int(),
bibleId: (schema) => schema.int(),
bookCode: (schema) => schema.min(1),
chapterNumber: (schema) => schema.int().min(1),
verseStart: (schema) => schema.int().min(1),
verseEnd: (schema) => schema.int().min(1),
})
.required({
projectUnitId: true,
bibleId: true,
bookCode: true,
chapterNumber: true,
verseStart: true,
verseEnd: true,
})
.omit({
id: true,
status: true,
retryCount: true,
errorMessage: true,
createdAt: true,
updatedAt: true,
});

export const insertAiSuggestionsSchema = createInsertSchema(ai_suggestions, {
bibleTextId: (schema) => schema.int(),
projectUnitId: (schema) => schema.int(),
suggestedText: (schema) => schema.min(1),
})
.required({ bibleTextId: true, projectUnitId: true, suggestedText: true })
.omit({ id: true, createdAt: true });

export const insertAiSuggestionUsageLogSchema = createInsertSchema(ai_suggestion_usage_log, {
userId: (schema) => schema.int(),
bibleTextId: (schema) => schema.int(),
projectUnitId: (schema) => schema.int(),
})
.required({ userId: true, bibleTextId: true, projectUnitId: true, wasUsed: true })
.omit({ id: true, createdAt: true });

export const patchAiSuggestionJobsSchema = insertAiSuggestionJobsSchema.partial();
export const patchAiSuggestionsSchema = insertAiSuggestionsSchema.partial();
export const patchAiSuggestionUsageLogSchema = insertAiSuggestionUsageLogSchema.partial();
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "chapter_assignments" ADD COLUMN "is_ai_enabled" boolean DEFAULT false NOT NULL;
Loading