Skip to content

Commit 4ddb102

Browse files
committed
Add postgate client
1 parent 785f321 commit 4ddb102

18 files changed

Lines changed: 934 additions & 218 deletions

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"cSpell.words": ["buildx", "croner", "healthcheck", "openworkers"]
2+
"cSpell.words": ["buildx", "croner", "healthcheck", "openworkers", "postgate", "timestamptz"]
33
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "openworkers-api",
3-
"version": "1.0.3",
3+
"version": "1.0.4",
44
"license": "MIT",
55
"module": "src/index.ts",
66
"type": "module",

scripts/generate-types.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ import {
3636
WorkerLanguageSchema
3737
} from '../src/types/schemas/worker.schema';
3838

39+
import {
40+
DatabaseSchema,
41+
DatabaseCreateInputSchema,
42+
DatabaseRulesSchema,
43+
SqlOperationSchema
44+
} from '../src/types/schemas/database.schema';
45+
3946
// Schema definitions to generate
4047
const schemas = [
4148
// Base schemas
@@ -72,7 +79,13 @@ const schemas = [
7279
{ schema: WorkerSchema, name: 'Worker' },
7380
{ schema: WorkerCreateInputSchema, name: 'WorkerCreateInput' },
7481
{ schema: WorkerUpdateInputSchema, name: 'WorkerUpdateInput' },
75-
{ schema: WorkerLanguageSchema, name: 'WorkerLanguage' }
82+
{ schema: WorkerLanguageSchema, name: 'WorkerLanguage' },
83+
84+
// Database
85+
{ schema: DatabaseSchema, name: 'Database' },
86+
{ schema: DatabaseCreateInputSchema, name: 'DatabaseCreateInput' },
87+
{ schema: DatabaseRulesSchema, name: 'DatabaseRules' },
88+
{ schema: SqlOperationSchema, name: 'SqlOperation' }
7689
];
7790

7891
async function generateTypes() {

src/config/index.ts

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { z } from 'zod';
22

3+
// UUID-like pattern (less strict than RFC 4122)
4+
const uuidLike = z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, 'Invalid UUID format');
5+
36
// Environment schema
47
const EnvironmentSchema = z.enum(['development', 'staging', 'production', 'test']);
58

@@ -11,15 +14,6 @@ const ConfigSchema = z.object({
1114
// Server
1215
port: z.coerce.number().int().positive().default(7000),
1316

14-
// Database (Postgres)
15-
database: z.object({
16-
host: z.string().default('localhost'),
17-
port: z.coerce.number().int().positive().default(5432),
18-
user: z.string().default('postgres'),
19-
password: z.string().default('password'),
20-
name: z.string().default('openworkers')
21-
}),
22-
2317
// JWT
2418
jwt: z.object({
2519
access: z.object({
@@ -36,6 +30,16 @@ const ConfigSchema = z.object({
3630
github: z.object({
3731
clientId: z.string().optional(),
3832
clientSecret: z.string().optional()
33+
}),
34+
35+
// Postgate (SQL proxy)
36+
postgate: z.object({
37+
url: z.string().url().default('http://localhost:6080'),
38+
// Admin database (tenant management functions) - mode schema on public
39+
adminDatabaseId: uuidLike.default('00000000-0000-0000-0000-000000000000'),
40+
// OpenWorkers database (API data) - mode dedicated
41+
openworkersDatabaseId: uuidLike,
42+
jwtSecret: z.string().min(32, 'POSTGATE_JWT_SECRET must be at least 32 characters')
3943
})
4044
});
4145

@@ -48,13 +52,6 @@ function loadConfig(): Config {
4852
const rawConfig = {
4953
nodeEnv: process.env.NODE_ENV,
5054
port: process.env.PORT,
51-
database: {
52-
host: process.env.POSTGRES_HOST,
53-
port: process.env.POSTGRES_PORT,
54-
user: process.env.POSTGRES_USER,
55-
password: process.env.POSTGRES_PASSWORD,
56-
name: process.env.POSTGRES_DB
57-
},
5855
jwt: {
5956
access: {
6057
secret: process.env.JWT_ACCESS_SECRET,
@@ -68,33 +65,34 @@ function loadConfig(): Config {
6865
github: {
6966
clientId: process.env.GITHUB_CLIENT_ID,
7067
clientSecret: process.env.GITHUB_CLIENT_SECRET
68+
},
69+
postgate: {
70+
url: process.env.POSTGATE_URL,
71+
adminDatabaseId: process.env.POSTGATE_ADMIN_DATABASE_ID,
72+
openworkersDatabaseId: process.env.POSTGATE_OPENWORKERS_DATABASE_ID,
73+
jwtSecret: process.env.POSTGATE_JWT_SECRET
7174
}
7275
};
7376

7477
try {
7578
const config = ConfigSchema.parse(rawConfig);
7679

77-
// Sanity check: test database should end with "test"
78-
if (config.nodeEnv === 'test' && !config.database.name.endsWith('test')) {
79-
throw new Error(`Database name should end with "test" in test mode, got "${config.database.name}"`);
80-
}
81-
8280
// Log configuration status
8381
if (config.nodeEnv === 'development') {
84-
console.log('🔧 Running in DEVELOPMENT mode');
82+
console.log('Running in DEVELOPMENT mode');
8583
} else if (config.nodeEnv === 'production') {
86-
console.log('🚀 Running in PRODUCTION mode');
84+
console.log('Running in PRODUCTION mode');
8785
}
8886

8987
// Warn about missing GitHub OAuth
9088
if (!config.github.clientId || !config.github.clientSecret) {
91-
console.warn('⚠️ GitHub OAuth not configured (GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET missing)');
89+
console.warn('GitHub OAuth not configured (GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET missing)');
9290
}
9391

9492
return config;
9593
} catch (error) {
9694
if (error instanceof z.ZodError) {
97-
console.error('Configuration validation failed:');
95+
console.error('Configuration validation failed:');
9896
error.issues.forEach((err) => {
9997
console.error(` - ${err.path.join('.')}: ${err.message}`);
10098
});
@@ -104,8 +102,10 @@ function loadConfig(): Config {
104102
}
105103
}
106104

105+
console.log('Loading configuration...', process.env.POSTGATE_OPENWORKERS_DATABASE_ID);
106+
107107
// Export singleton config instance
108108
export const config = loadConfig();
109109

110110
// Export individual sections for convenience
111-
export const { nodeEnv, port, database, jwt, github } = config;
111+
export const { nodeEnv, port, jwt, github, postgate } = config;

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import workers from './routes/workers';
88
import crons from './routes/crons';
99
import environments from './routes/environments';
1010
import domains from './routes/domains';
11+
import databases from './routes/databases';
1112
import pkg from '../package.json';
1213

1314
export const app = new Hono();
@@ -41,8 +42,8 @@ v1.route('/workers', workers);
4142
v1.route('/crons', crons);
4243
v1.route('/environments', environments);
4344
v1.route('/domains', domains);
45+
v1.route('/databases', databases);
4446
v1.route('/', users);
45-
// TODO: Add more routes
4647

4748
app.route('/api/v1', v1);
4849

src/routes/databases.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { Hono } from 'hono';
2+
import { databasesService } from '../services/databases';
3+
import { DatabaseSchema, DatabaseCreateInputSchema } from '../types';
4+
import { jsonResponse, jsonArrayResponse } from '../utils/validate';
5+
6+
const databases = new Hono();
7+
8+
// GET /databases - List all databases for current user
9+
databases.get('/', async (c) => {
10+
const userId = c.get('userId');
11+
12+
try {
13+
const dbs = await databasesService.findAll(userId);
14+
return jsonArrayResponse(c, DatabaseSchema, dbs);
15+
} catch (error) {
16+
console.error('Failed to fetch databases:', error);
17+
return c.json({ error: 'Failed to fetch databases' }, 500);
18+
}
19+
});
20+
21+
// GET /databases/:id - Get single database
22+
databases.get('/:id', async (c) => {
23+
const userId = c.get('userId');
24+
const id = c.req.param('id');
25+
26+
try {
27+
const db = await databasesService.findById(userId, id);
28+
29+
if (!db) {
30+
return c.json({ error: 'Database not found' }, 404);
31+
}
32+
33+
return jsonResponse(c, DatabaseSchema, db);
34+
} catch (error) {
35+
console.error('Failed to fetch database:', error);
36+
return c.json({ error: 'Failed to fetch database' }, 500);
37+
}
38+
});
39+
40+
// POST /databases - Create new database
41+
databases.post('/', async (c) => {
42+
const userId = c.get('userId');
43+
const body = await c.req.json();
44+
45+
try {
46+
const payload = DatabaseCreateInputSchema.parse(body);
47+
48+
const db = await databasesService.create(userId, payload);
49+
50+
return jsonResponse(c, DatabaseSchema, db, 201);
51+
} catch (error) {
52+
console.error('Failed to create database:', error);
53+
return c.json(
54+
{
55+
error: 'Failed to create database',
56+
message: error instanceof Error ? error.message : 'Unknown error'
57+
},
58+
500
59+
);
60+
}
61+
});
62+
63+
// DELETE /databases/:id - Delete database
64+
databases.delete('/:id', async (c) => {
65+
const userId = c.get('userId');
66+
const id = c.req.param('id');
67+
68+
try {
69+
const deleted = await databasesService.delete(userId, id);
70+
71+
if (!deleted) {
72+
return c.json({ error: 'Database not found' }, 404);
73+
}
74+
75+
return c.json({ deleted: true });
76+
} catch (error) {
77+
console.error('Failed to delete database:', error);
78+
return c.json({ error: 'Failed to delete database' }, 500);
79+
}
80+
});
81+
82+
export default databases;

src/services/databases.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { createAdminSqlClient } from './db/client';
2+
import * as db from './db/databases';
3+
import type { IDatabase, IDatabaseCreateInput } from '../types';
4+
5+
// Admin SQL client for postgate database management
6+
const adminSql = createAdminSqlClient();
7+
8+
interface PostgateDatabaseRow {
9+
id: string;
10+
name: string;
11+
schema_name: string | null;
12+
}
13+
14+
export class DatabasesService {
15+
/**
16+
* Create a new tenant database
17+
* 1. Create entry in postgate_databases (via admin sql)
18+
* 2. Create entry in openworkers databases table
19+
*/
20+
async create(userId: string, input: IDatabaseCreateInput): Promise<IDatabase> {
21+
const { name, allowed_operations, max_rows, timeout_seconds } = input;
22+
23+
// Generate schema name
24+
const schemaName = `tenant_${userId.substring(0, 8)}_${name}`;
25+
26+
// Build rules JSON
27+
const rules = {
28+
allowed_operations: allowed_operations || ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
29+
max_rows: max_rows || 1000,
30+
timeout_seconds: timeout_seconds || 30
31+
};
32+
33+
// Create in postgate
34+
const postgateResult = await adminSql<PostgateDatabaseRow>(
35+
`INSERT INTO postgate_databases (name, backend_type, schema_name, rules)
36+
VALUES ($1, 'schema', $2, $3::jsonb)
37+
RETURNING id, name, schema_name`,
38+
[name, schemaName, JSON.stringify(rules)]
39+
);
40+
41+
if (postgateResult.length === 0) {
42+
throw new Error('Failed to create database in postgate');
43+
}
44+
45+
const postgateDb = postgateResult[0]!;
46+
47+
// Create in openworkers
48+
const owDb = await db.createDatabase(userId, name, postgateDb.id, postgateDb.schema_name);
49+
50+
return {
51+
id: owDb.id,
52+
name: owDb.name,
53+
desc: null,
54+
schemaName: owDb.schemaName ?? undefined,
55+
createdAt: owDb.createdAt,
56+
updatedAt: owDb.updatedAt
57+
};
58+
}
59+
60+
/**
61+
* List all databases for a user
62+
*/
63+
async findAll(userId: string): Promise<IDatabase[]> {
64+
const rows = await db.findAllDatabases(userId);
65+
66+
return rows.map((row) => ({
67+
id: row.id,
68+
name: row.name,
69+
desc: null,
70+
schemaName: row.schemaName ?? undefined,
71+
createdAt: row.createdAt,
72+
updatedAt: row.updatedAt
73+
}));
74+
}
75+
76+
/**
77+
* Get a specific database by ID
78+
*/
79+
async findById(userId: string, id: string): Promise<IDatabase | null> {
80+
const row = await db.findDatabaseById(userId, id);
81+
82+
if (!row) {
83+
return null;
84+
}
85+
86+
return {
87+
id: row.id,
88+
name: row.name,
89+
desc: null,
90+
schemaName: row.schemaName ?? undefined,
91+
createdAt: row.createdAt,
92+
updatedAt: row.updatedAt
93+
};
94+
}
95+
96+
/**
97+
* Delete a database
98+
* 1. Get openworkers db entry to find postgate_id
99+
* 2. Delete from postgate
100+
* 3. Delete from openworkers
101+
*/
102+
async delete(userId: string, id: string): Promise<boolean> {
103+
// Get openworkers entry
104+
const owDb = await db.findDatabaseById(userId, id);
105+
if (!owDb) {
106+
return false;
107+
}
108+
109+
// Delete from postgate
110+
await adminSql(
111+
`DELETE FROM postgate_databases WHERE id = $1::uuid`,
112+
[owDb.postgateId]
113+
);
114+
115+
// Delete from openworkers
116+
const deleted = await db.deleteDatabase(userId, id);
117+
118+
return deleted > 0;
119+
}
120+
}
121+
122+
export const databasesService = new DatabasesService();

src/services/db/client.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,2 @@
1-
import { SQL } from 'bun';
2-
import { database } from '../../config';
3-
4-
// Initialize DB connection
5-
export const sql = new SQL({
6-
host: database.host,
7-
port: database.port,
8-
user: database.user,
9-
password: database.password,
10-
database: database.name,
11-
adapter: 'postgres',
12-
max: 10
13-
});
1+
// Re-export from sql-client
2+
export { sql, createSqlClient, createAdminSqlClient, type PostgateSqlClient, type SqlResult } from './sql-client';

0 commit comments

Comments
 (0)