postbase 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/test.yml +74 -0
- package/CLA.md +60 -0
- package/CONTRIBUTORS.md +35 -0
- package/LICENSE +661 -0
- package/README.md +211 -0
- package/admin/404.html +33 -0
- package/admin/README.md +21 -0
- package/admin/index.html +15 -0
- package/admin/jsconfig.json +20 -0
- package/admin/lib/postbase.js +222 -0
- package/admin/package-lock.json +3746 -0
- package/admin/package.json +27 -0
- package/admin/public/assets/img/admin-ui.png +0 -0
- package/admin/public/assets/img/blank-profile-picture-960_720.webp +0 -0
- package/admin/public/assets/img/chart-active-users.png +0 -0
- package/admin/public/assets/img/icon-transparent.png +0 -0
- package/admin/src/App.jsx +48 -0
- package/admin/src/auth.js +11 -0
- package/admin/src/common/formatDateTime.js +18 -0
- package/admin/src/components/AuthPanel.jsx +88 -0
- package/admin/src/components/Header.jsx +67 -0
- package/admin/src/main.jsx +6 -0
- package/admin/src/pages/Dashboard.jsx +24 -0
- package/admin/src/pages/Home.jsx +52 -0
- package/admin/src/pages/Login.jsx +10 -0
- package/admin/src/pages/authentication/Users.jsx +199 -0
- package/admin/src/pages/firestore/Database.jsx +29 -0
- package/admin/src/pages/storage/files.jsx +29 -0
- package/admin/src/postbase.js +15 -0
- package/admin/src/styles.css +3 -0
- package/admin/tailwind.config.cjs +11 -0
- package/admin/template.env +2 -0
- package/admin/vite.config.js +21 -0
- package/assets/img/HomePageScreenshot.png +0 -0
- package/assets/img/better-auth-logo-dark.136b122f.png +0 -0
- package/assets/img/better-auth-logo-light.4b03f444.png +0 -0
- package/assets/img/expresjs.png +0 -0
- package/assets/img/icon-transparent.png +0 -0
- package/assets/img/icon.png +0 -0
- package/assets/img/letsencrypt-logo-horizontal.png +0 -0
- package/assets/img/logo.png +0 -0
- package/assets/img/node.js_logo.png +0 -0
- package/assets/img/nodejsLight.svg +39 -0
- package/assets/img/postgres.png +0 -0
- package/backend/README.md +49 -0
- package/backend/admin/auth.js +9 -0
- package/backend/app.js +68 -0
- package/backend/auth.js +92 -0
- package/backend/env.js +12 -0
- package/backend/lib/postbase/adminClient.js +520 -0
- package/backend/lib/postbase/compat/admin.js +44 -0
- package/backend/lib/postbase/db.js +17 -0
- package/backend/lib/postbase/genericRouter.js +603 -0
- package/backend/lib/postbase/local-storage.js +56 -0
- package/backend/lib/postbase/metadataCache.js +32 -0
- package/backend/lib/postbase/middlewares/auth.js +57 -0
- package/backend/lib/postbase/migrations/1765239687559_rtdb-nodes.js +93 -0
- package/backend/lib/postbase/package-lock.json +5873 -0
- package/backend/lib/postbase/package.json +19 -0
- package/backend/lib/postbase/rtdb/router.js +190 -0
- package/backend/lib/postbase/rtdb/rulesEngine.js +63 -0
- package/backend/lib/postbase/rtdb/ws.js +84 -0
- package/backend/lib/postbase/rulesEngine.js +62 -0
- package/backend/lib/postbase/storage.js +130 -0
- package/backend/lib/postbase/tests/README.md +22 -0
- package/backend/lib/postbase/tests/db.js +9 -0
- package/backend/lib/postbase/tests/rtdb.rest.test.js +46 -0
- package/backend/lib/postbase/tests/rtdb.ws.test.js +113 -0
- package/backend/lib/postbase/tests/rules.js +26 -0
- package/backend/lib/postbase/tests/testServer.js +46 -0
- package/backend/lib/postbase/websocket.js +131 -0
- package/backend/local.js +6 -0
- package/backend/main.js +20 -0
- package/backend/middlewares/auth_middleware.js +10 -0
- package/backend/migrations/1762137399366-init.sql +98 -0
- package/backend/migrations/1762137399367_init_jsonb_schema.js +68 -0
- package/backend/migrations/1762149999999_enable_realtime_changes.js +48 -0
- package/backend/migrations/1765224247654_rtdb-nodes.js +93 -0
- package/backend/package-lock.json +2374 -0
- package/backend/package.json +27 -0
- package/backend/postbase_db_rules.js +128 -0
- package/backend/postbase_rtdb_rules.js +27 -0
- package/backend/postbase_storage_rules.js +45 -0
- package/backend/template.env +10 -0
- package/backend-systemd/README.md +39 -0
- package/backend-systemd/your_website.com.service +12 -0
- package/frontend/404.html +33 -0
- package/frontend/README.md +25 -0
- package/frontend/index.html +15 -0
- package/frontend/jsconfig.json +20 -0
- package/frontend/lib/postbase/auth.js +132 -0
- package/frontend/lib/postbase/compat/firebase/app.js +3 -0
- package/frontend/lib/postbase/compat/firebase/auth.js +115 -0
- package/frontend/lib/postbase/compat/firebase/database.js +11 -0
- package/frontend/lib/postbase/compat/firebase/firestore/lite.js +61 -0
- package/frontend/lib/postbase/compat/firebase/storage.js +10 -0
- package/frontend/lib/postbase/db.js +657 -0
- package/frontend/lib/postbase/package-lock.json +6284 -0
- package/frontend/lib/postbase/package.json +17 -0
- package/frontend/lib/postbase/rtdb.js +108 -0
- package/frontend/lib/postbase/storage.js +293 -0
- package/frontend/lib/postbase/tests/rtdb.client.test.js +88 -0
- package/frontend/lib/postbase/tests/waitFor.js +13 -0
- package/frontend/lib/postbase/utils.js +1 -0
- package/frontend/package-lock.json +2977 -0
- package/frontend/package.json +24 -0
- package/frontend/src/App.jsx +38 -0
- package/frontend/src/auth.js +52 -0
- package/frontend/src/components/AuthPanel.jsx +85 -0
- package/frontend/src/components/Header.jsx +54 -0
- package/frontend/src/main.jsx +5 -0
- package/frontend/src/pages/Dashboard.jsx +24 -0
- package/frontend/src/pages/Home.jsx +178 -0
- package/frontend/src/pages/Login.jsx +10 -0
- package/frontend/src/postbase.js +14 -0
- package/frontend/src/styles.css +1 -0
- package/frontend/tailwind.config.cjs +11 -0
- package/frontend/template.env +2 -0
- package/frontend/vite.config.js +18 -0
- package/git/hooks/README.md +31 -0
- package/git/hooks/post-receive +26 -0
- package/nginx/README.md +84 -0
- package/nginx/apt/www.your_website.com.conf +80 -0
- package/nginx/homebrew/www.your_website.com.conf +80 -0
- package/nginx/letsencrypt/README +14 -0
- package/package.json +8 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import { startTestServer } from './testServer.js';
|
|
4
|
+
|
|
5
|
+
let srv;
|
|
6
|
+
let wsClients = [];
|
|
7
|
+
|
|
8
|
+
// Start the server before all tests
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
srv = await startTestServer(); // should return { http server, wss, wsUrl }
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Close all WS clients after each test
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
wsClients.forEach(ws => {
|
|
16
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
17
|
+
ws.close();
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
wsClients.length = 0;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Stop the server after all tests
|
|
24
|
+
afterAll(() => {
|
|
25
|
+
if (srv.wss) srv.wss.close();
|
|
26
|
+
if (srv.server) srv.server.close();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Helper to connect WebSocket and wait for 'open'
|
|
30
|
+
function connectWS(url) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const ws = new WebSocket(url);
|
|
33
|
+
wsClients.push(ws);
|
|
34
|
+
ws.once('open', () => resolve(ws));
|
|
35
|
+
ws.once('error', reject);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function waitForMessage(ws) {
|
|
40
|
+
return new Promise(res => {
|
|
41
|
+
ws.once('message', m => res(JSON.parse(m)));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('exact path listener fires', async () => {
|
|
46
|
+
const ws = await connectWS(srv.wsUrl);
|
|
47
|
+
|
|
48
|
+
ws.send(JSON.stringify({ type: 'sub', path: 'users/u2' }));
|
|
49
|
+
|
|
50
|
+
const msgPromise = waitForMessage(ws);
|
|
51
|
+
|
|
52
|
+
await request(srv.url)
|
|
53
|
+
.put('/rtdb/users/u2')
|
|
54
|
+
.send({ online: true });
|
|
55
|
+
|
|
56
|
+
const msg = await msgPromise;
|
|
57
|
+
expect(msg.path).toBe('users/u2');
|
|
58
|
+
expect(msg.value.online).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('prefix listener fires for subtree', async () => {
|
|
62
|
+
const ws = await connectWS(srv.wsUrl);
|
|
63
|
+
|
|
64
|
+
ws.send(JSON.stringify({
|
|
65
|
+
type: 'sub',
|
|
66
|
+
path: 'users/u3',
|
|
67
|
+
prefix: true,
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
const msgPromise = waitForMessage(ws);
|
|
71
|
+
|
|
72
|
+
await request(srv.url)
|
|
73
|
+
.put('/rtdb/users/u3/profile')
|
|
74
|
+
.send({ name: 'Bob' });
|
|
75
|
+
|
|
76
|
+
const msg = await msgPromise;
|
|
77
|
+
expect(msg.path).toBe('users/u3/profile');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('field listener fires only on change', async () => {
|
|
81
|
+
const ws = await connectWS(srv.wsUrl);
|
|
82
|
+
|
|
83
|
+
ws.send(JSON.stringify({
|
|
84
|
+
type: 'sub',
|
|
85
|
+
path: 'users/u4',
|
|
86
|
+
field: 'status',
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
let msgPromise = waitForMessage(ws);
|
|
90
|
+
|
|
91
|
+
await request(srv.url)
|
|
92
|
+
.put('/rtdb/users/u4')
|
|
93
|
+
.send({ status: 'offline' });
|
|
94
|
+
|
|
95
|
+
let msg = await msgPromise;
|
|
96
|
+
|
|
97
|
+
expect(msg.value).toBe('offline');
|
|
98
|
+
|
|
99
|
+
msgPromise = waitForMessage(ws);
|
|
100
|
+
|
|
101
|
+
// same value → should NOT fire
|
|
102
|
+
await request(srv.url)
|
|
103
|
+
.patch('/rtdb/users/u4')
|
|
104
|
+
.send({ status: 'offline' });
|
|
105
|
+
|
|
106
|
+
// change → should fire
|
|
107
|
+
await request(srv.url)
|
|
108
|
+
.patch('/rtdb/users/u4')
|
|
109
|
+
.send({ status: 'online' });
|
|
110
|
+
|
|
111
|
+
msg = await msgPromise;
|
|
112
|
+
expect(msg.value).toBe('online');
|
|
113
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// backend/postbase_rtdb_rules.js
|
|
2
|
+
export default {
|
|
3
|
+
paths: {
|
|
4
|
+
"/users/$uid": {
|
|
5
|
+
read: () => true,
|
|
6
|
+
write: () => true,
|
|
7
|
+
delete: () => true,
|
|
8
|
+
},
|
|
9
|
+
|
|
10
|
+
"/users/$uid/posts": {
|
|
11
|
+
read: () => true,
|
|
12
|
+
write: () => true,
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
"/users/$uid/profile": {
|
|
16
|
+
read: () => true,
|
|
17
|
+
write: () => true,
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
default: {
|
|
22
|
+
read: false,
|
|
23
|
+
write: false,
|
|
24
|
+
delete: false,
|
|
25
|
+
}
|
|
26
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import { WebSocketServer } from 'ws';
|
|
4
|
+
import bodyParser from 'body-parser';
|
|
5
|
+
|
|
6
|
+
import { pool, resetDb } from './db.js';
|
|
7
|
+
import { authMiddleware } from '../middlewares/auth.js';
|
|
8
|
+
import { createRtdbRouter } from '../rtdb/router.js';
|
|
9
|
+
import { createRtdbWs } from '../rtdb/ws.js';
|
|
10
|
+
import rules from './rules.js';
|
|
11
|
+
|
|
12
|
+
export async function startTestServer() {
|
|
13
|
+
await resetDb();
|
|
14
|
+
|
|
15
|
+
const app = express();
|
|
16
|
+
app.use(bodyParser.json());
|
|
17
|
+
|
|
18
|
+
const server = http.createServer(app);
|
|
19
|
+
const wss = new WebSocketServer({ server });
|
|
20
|
+
|
|
21
|
+
const authenticate = authMiddleware(pool);
|
|
22
|
+
const rtdbWs = createRtdbWs(wss);
|
|
23
|
+
|
|
24
|
+
app.use(
|
|
25
|
+
'/rtdb',
|
|
26
|
+
authenticate,
|
|
27
|
+
createRtdbRouter({
|
|
28
|
+
pool,
|
|
29
|
+
notify: rtdbWs.notify,
|
|
30
|
+
rulesModule: rules,
|
|
31
|
+
})
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
await new Promise(res => server.listen(0, res));
|
|
35
|
+
|
|
36
|
+
const port = server.address().port;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
app,
|
|
40
|
+
server,
|
|
41
|
+
wss,
|
|
42
|
+
url: `http://localhost:${port}`,
|
|
43
|
+
wsUrl: `ws://localhost:${port}`,
|
|
44
|
+
close: () => server.close(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { WebSocketServer } from 'ws';
|
|
2
|
+
|
|
3
|
+
import { createPool } from './db.js';
|
|
4
|
+
import { makePostbaseAdminClient } from './adminClient.js';
|
|
5
|
+
|
|
6
|
+
// FIXME: should not need to create a pool and db just to use db.buildWhere and db.buildOrder
|
|
7
|
+
const pool = createPool({
|
|
8
|
+
connectionString: process.env.DATABASE_URL
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const db = makePostbaseAdminClient({ pool });
|
|
12
|
+
|
|
13
|
+
export function setupWebsocket({ server }) {
|
|
14
|
+
const wss = new WebSocketServer({
|
|
15
|
+
noServer: true, // https://stackoverflow.com/a/65034250/355507
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Upgrade HTTP → WS when path matches /api/db/<fullPath>/stream
|
|
19
|
+
server.on('upgrade', (req, socket, head) => {
|
|
20
|
+
console.log('wss upgrade url:', req.url);
|
|
21
|
+
|
|
22
|
+
// Match everything after /api/db/ up to optional /stream
|
|
23
|
+
const match = req.url.match(/^\/api\/db\/(.+?)\/stream$/);
|
|
24
|
+
if (!match) {
|
|
25
|
+
console.log('No match, destroying socket');
|
|
26
|
+
return socket.destroy();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const fullPath = decodeURIComponent(match[1]); // e.g., "users/u1/posts"
|
|
30
|
+
console.log('Matched collection path:', fullPath);
|
|
31
|
+
|
|
32
|
+
// Extract last segment as collection name, rest as parent path
|
|
33
|
+
const segments = fullPath.split('/');
|
|
34
|
+
const collectionName = segments[segments.length - 1]; // "posts"
|
|
35
|
+
const parentPath = segments.slice(0, -1).join('/'); // "users/u1"
|
|
36
|
+
|
|
37
|
+
// Pass fullPath or collectionName + parentPath to your connection handler
|
|
38
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
39
|
+
wss.emit('connection', ws, { collectionName, parentPath, fullPath });
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
wss.on('connection', async (ws, { collectionName, parentPath, fullPath }) => {
|
|
44
|
+
console.log(`WebSocket connected for table: ${collectionName}`);
|
|
45
|
+
|
|
46
|
+
console.log('Connecting pool...');
|
|
47
|
+
const client = await pool.connect();
|
|
48
|
+
console.log('Connected to pool');
|
|
49
|
+
// Each connection gets its own PG client to LISTEN
|
|
50
|
+
console.log(`LISTEN changes_${collectionName}`);
|
|
51
|
+
await client.query(`LISTEN changes_${collectionName}`);
|
|
52
|
+
|
|
53
|
+
// Receive the initial query definition from the client
|
|
54
|
+
console.log('Attaching ws.once(\'message\')');
|
|
55
|
+
ws.once('message', async (msg) => {
|
|
56
|
+
try {
|
|
57
|
+
const query = JSON.parse(msg.toString());
|
|
58
|
+
console.log('query', query);
|
|
59
|
+
// If subscribing to a subcollection, the client must send parent info:
|
|
60
|
+
// { parent: { collectionName, id, path } }
|
|
61
|
+
if (query.parent && typeof query.parent === 'object') {
|
|
62
|
+
query.filters = query.filters || [];
|
|
63
|
+
// Convert DocumentReference object to a path string
|
|
64
|
+
const parentPath =
|
|
65
|
+
query.parent.path ||
|
|
66
|
+
`${query.parent.collectionName}/${query.parent.id}`;
|
|
67
|
+
|
|
68
|
+
// FIX: Provide fake collectionName & id so buildWhere recognizes it
|
|
69
|
+
query.filters.push({
|
|
70
|
+
field: 'parent',
|
|
71
|
+
op: '==',
|
|
72
|
+
value: {
|
|
73
|
+
path: parentPath,
|
|
74
|
+
collectionName: query.parent.collectionName ?? null,
|
|
75
|
+
id: query.parent.id ?? null,
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
ws.query = query;
|
|
81
|
+
|
|
82
|
+
const { filters = [], order = [], limit = 100, offset = 0 } = query;
|
|
83
|
+
|
|
84
|
+
// Send initial data
|
|
85
|
+
const { whereSql, params } = db.buildWhere(filters);
|
|
86
|
+
const orderSql = db.buildOrder(order);
|
|
87
|
+
const sql = `
|
|
88
|
+
SELECT id, data, created_at, updated_at
|
|
89
|
+
FROM "${collectionName}"
|
|
90
|
+
${whereSql} ${orderSql}
|
|
91
|
+
LIMIT ${limit} OFFSET ${offset}`;
|
|
92
|
+
console.log('sql, params', sql, params);
|
|
93
|
+
const res = await client.query(sql, params);
|
|
94
|
+
console.log('res.rows', res.rows);
|
|
95
|
+
console.log('Sending result back to client');
|
|
96
|
+
ws.send(JSON.stringify({ type: 'init', data: res.rows.map(r => ({ id: r.id, ...r.data })) }));
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error('Error processing web socket message', err);
|
|
99
|
+
console.log('Sending response back to client');
|
|
100
|
+
ws.send(JSON.stringify({ type: 'error', error: err.message || err }));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Handle Postgres notifications
|
|
105
|
+
console.log('Attaching client.on(\'notification\')');
|
|
106
|
+
client.on('notification', async (msg) => {
|
|
107
|
+
console.log('postgres notification received');
|
|
108
|
+
console.log('web socket read state is:', ws.readyState);
|
|
109
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
110
|
+
const payload = JSON.parse(msg.payload);
|
|
111
|
+
console.log('payload from postgres', payload);
|
|
112
|
+
// Only send relevant changes for this subcollection parent
|
|
113
|
+
if (ws.query?.filters?.some(f => f.field === 'parent' && f.value?.path)) {
|
|
114
|
+
const parentFilter = ws.query.filters.find(f => f.field === 'parent');
|
|
115
|
+
if (!payload.data?.parent?.path) return;
|
|
116
|
+
if (payload.data.parent.path !== parentFilter.value.path) return;
|
|
117
|
+
}
|
|
118
|
+
console.log('Sending change event to the client with payload');
|
|
119
|
+
ws.send(JSON.stringify({ type: 'change', data: payload }));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
console.log('Attaching ws.on(\'close\')');
|
|
123
|
+
ws.on('close', () => {
|
|
124
|
+
console.log(`UNLISTEN changes_${collectionName}`);
|
|
125
|
+
client.query(`UNLISTEN changes_${collectionName}`).catch(console.error);
|
|
126
|
+
client.release();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return wss;
|
|
131
|
+
}
|
package/backend/local.js
ADDED
package/backend/main.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import './env.js';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import https from 'node:https';
|
|
4
|
+
|
|
5
|
+
import { app } from './app.js';
|
|
6
|
+
import { setupWebsocket } from './lib/postbase/websocket.js';
|
|
7
|
+
|
|
8
|
+
const httpsOptions = {
|
|
9
|
+
key: fs.readFileSync(process.env.LETSENCRYPT_KEY),
|
|
10
|
+
cert: fs.readFileSync(process.env.LETSENCRYPT_CERT)
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const HTTPS_PORT = process.env.POSTBASE_BACKEND_HTTPS_PORT || 4431;
|
|
14
|
+
|
|
15
|
+
const server = https.createServer(httpsOptions, app);
|
|
16
|
+
|
|
17
|
+
server.listen(HTTPS_PORT,
|
|
18
|
+
() => console.log(`Postbase backend listening on https://0.0.0.0:${HTTPS_PORT}`));
|
|
19
|
+
|
|
20
|
+
const wss = setupWebsocket({ server });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// server/middleware/auth.js
|
|
2
|
+
import { createPool } from '../lib/postbase/db.js';
|
|
3
|
+
import { authMiddleware } from '../lib/postbase/middlewares/auth.js';
|
|
4
|
+
|
|
5
|
+
// Initialize DB pool using env variables
|
|
6
|
+
const pool = createPool({
|
|
7
|
+
connectionString: process.env.DATABASE_URL
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const authenticate = authMiddleware(pool);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
--
|
|
2
|
+
-- PostgreSQL Better-Auth initial migration
|
|
3
|
+
--
|
|
4
|
+
|
|
5
|
+
-- Enable extensions if needed
|
|
6
|
+
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
|
7
|
+
|
|
8
|
+
-- ===== user table =====
|
|
9
|
+
CREATE TABLE IF NOT EXISTS public."user" (
|
|
10
|
+
id text PRIMARY KEY,
|
|
11
|
+
name text NOT NULL,
|
|
12
|
+
email text NOT NULL,
|
|
13
|
+
"emailVerified" boolean NOT NULL,
|
|
14
|
+
image text,
|
|
15
|
+
"createdAt" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
16
|
+
"updatedAt" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
-- Unique constraint
|
|
20
|
+
DO $$
|
|
21
|
+
BEGIN
|
|
22
|
+
IF NOT EXISTS (
|
|
23
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'user_email_key'
|
|
24
|
+
) THEN
|
|
25
|
+
ALTER TABLE public."user" ADD CONSTRAINT user_email_key UNIQUE (email);
|
|
26
|
+
END IF;
|
|
27
|
+
END$$;
|
|
28
|
+
|
|
29
|
+
-- ===== account table =====
|
|
30
|
+
CREATE TABLE IF NOT EXISTS public.account (
|
|
31
|
+
id text PRIMARY KEY,
|
|
32
|
+
"accountId" text NOT NULL,
|
|
33
|
+
"providerId" text NOT NULL,
|
|
34
|
+
"userId" text NOT NULL,
|
|
35
|
+
"accessToken" text,
|
|
36
|
+
"refreshToken" text,
|
|
37
|
+
"idToken" text,
|
|
38
|
+
"accessTokenExpiresAt" timestamptz,
|
|
39
|
+
"refreshTokenExpiresAt" timestamptz,
|
|
40
|
+
scope text,
|
|
41
|
+
password text,
|
|
42
|
+
"createdAt" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
43
|
+
"updatedAt" timestamptz NOT NULL
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
-- Foreign key
|
|
47
|
+
DO $$
|
|
48
|
+
BEGIN
|
|
49
|
+
IF NOT EXISTS (
|
|
50
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'account_userId_fkey'
|
|
51
|
+
) THEN
|
|
52
|
+
ALTER TABLE public.account
|
|
53
|
+
ADD CONSTRAINT account_userId_fkey FOREIGN KEY ("userId") REFERENCES public."user"(id) ON DELETE CASCADE;
|
|
54
|
+
END IF;
|
|
55
|
+
END$$;
|
|
56
|
+
|
|
57
|
+
-- ===== session table =====
|
|
58
|
+
CREATE TABLE IF NOT EXISTS public.session (
|
|
59
|
+
id text PRIMARY KEY,
|
|
60
|
+
"expiresAt" timestamptz NOT NULL,
|
|
61
|
+
token text NOT NULL,
|
|
62
|
+
"createdAt" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
63
|
+
"updatedAt" timestamptz NOT NULL,
|
|
64
|
+
"ipAddress" text,
|
|
65
|
+
"userAgent" text,
|
|
66
|
+
"userId" text NOT NULL
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
-- Unique token
|
|
70
|
+
DO $$
|
|
71
|
+
BEGIN
|
|
72
|
+
IF NOT EXISTS (
|
|
73
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'session_token_key'
|
|
74
|
+
) THEN
|
|
75
|
+
ALTER TABLE public.session ADD CONSTRAINT session_token_key UNIQUE (token);
|
|
76
|
+
END IF;
|
|
77
|
+
END$$;
|
|
78
|
+
|
|
79
|
+
-- Foreign key
|
|
80
|
+
DO $$
|
|
81
|
+
BEGIN
|
|
82
|
+
IF NOT EXISTS (
|
|
83
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'session_userId_fkey'
|
|
84
|
+
) THEN
|
|
85
|
+
ALTER TABLE public.session
|
|
86
|
+
ADD CONSTRAINT session_userId_fkey FOREIGN KEY ("userId") REFERENCES public."user"(id) ON DELETE CASCADE;
|
|
87
|
+
END IF;
|
|
88
|
+
END$$;
|
|
89
|
+
|
|
90
|
+
-- ===== verification table =====
|
|
91
|
+
CREATE TABLE IF NOT EXISTS public.verification (
|
|
92
|
+
id text PRIMARY KEY,
|
|
93
|
+
identifier text NOT NULL,
|
|
94
|
+
value text NOT NULL,
|
|
95
|
+
"expiresAt" timestamptz NOT NULL,
|
|
96
|
+
"createdAt" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
97
|
+
"updatedAt" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL
|
|
98
|
+
);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file 1762137399367_init_jsonb_schema.js
|
|
3
|
+
* Adds JSONB Firestore-like tables alongside Better-Auth.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const shorthands = undefined;
|
|
7
|
+
|
|
8
|
+
export const up = async (pgm) => {
|
|
9
|
+
// enable UUID extension for gen_random_uuid()
|
|
10
|
+
await pgm.sql(`CREATE EXTENSION IF NOT EXISTS "pgcrypto";`);
|
|
11
|
+
|
|
12
|
+
// Trigger function to update updated_at
|
|
13
|
+
await pgm.sql(`
|
|
14
|
+
CREATE OR REPLACE FUNCTION set_updated_at()
|
|
15
|
+
RETURNS TRIGGER AS $$
|
|
16
|
+
BEGIN
|
|
17
|
+
NEW.updated_at = now();
|
|
18
|
+
RETURN NEW;
|
|
19
|
+
END;
|
|
20
|
+
$$ LANGUAGE plpgsql;
|
|
21
|
+
`);
|
|
22
|
+
|
|
23
|
+
const createJsonTable = async (name) => {
|
|
24
|
+
pgm.createTable(name, {
|
|
25
|
+
id: {
|
|
26
|
+
type: 'text',
|
|
27
|
+
primaryKey: true,
|
|
28
|
+
default: pgm.func('gen_random_uuid()'),
|
|
29
|
+
},
|
|
30
|
+
data: {
|
|
31
|
+
type: 'jsonb',
|
|
32
|
+
notNull: true,
|
|
33
|
+
default: '{}',
|
|
34
|
+
},
|
|
35
|
+
created_at: {
|
|
36
|
+
type: 'timestamptz',
|
|
37
|
+
notNull: true,
|
|
38
|
+
default: pgm.func('now()'),
|
|
39
|
+
},
|
|
40
|
+
updated_at: {
|
|
41
|
+
type: 'timestamptz',
|
|
42
|
+
notNull: true,
|
|
43
|
+
default: pgm.func('now()'),
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await pgm.sql(`
|
|
48
|
+
CREATE TRIGGER ${name}_updated_at
|
|
49
|
+
BEFORE UPDATE ON "${name}"
|
|
50
|
+
FOR EACH ROW
|
|
51
|
+
EXECUTE FUNCTION set_updated_at();
|
|
52
|
+
`);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Create the Firestore-style JSONB collections
|
|
56
|
+
await createJsonTable('users');
|
|
57
|
+
await createJsonTable('reviews');
|
|
58
|
+
|
|
59
|
+
// Optional: indexes on JSONB ids
|
|
60
|
+
pgm.createIndex('users', 'id');
|
|
61
|
+
pgm.createIndex('reviews', 'id');
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const down = async (pgm) => {
|
|
65
|
+
pgm.dropTable('users');
|
|
66
|
+
pgm.dropTable('reviews');
|
|
67
|
+
pgm.sql(`DROP FUNCTION IF EXISTS set_updated_at() CASCADE;`);
|
|
68
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file 1762149999999_enable_realtime_changes.js
|
|
3
|
+
* Adds realtime triggers (pg_notify) for Firestore-style JSONB tables.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const shorthands = undefined;
|
|
7
|
+
|
|
8
|
+
export const up = async (pgm) => {
|
|
9
|
+
// --- Shared trigger function for realtime updates ---
|
|
10
|
+
await pgm.sql(`
|
|
11
|
+
CREATE OR REPLACE FUNCTION notify_table_change() RETURNS trigger AS $$
|
|
12
|
+
DECLARE
|
|
13
|
+
payload JSON;
|
|
14
|
+
BEGIN
|
|
15
|
+
IF (TG_OP = 'DELETE') THEN
|
|
16
|
+
payload = json_build_object('op', TG_OP, 'id', OLD.id);
|
|
17
|
+
ELSE
|
|
18
|
+
payload = json_build_object('op', TG_OP, 'id', NEW.id, 'data', NEW.data);
|
|
19
|
+
END IF;
|
|
20
|
+
PERFORM pg_notify('changes_' || TG_TABLE_NAME, payload::text);
|
|
21
|
+
RETURN NEW;
|
|
22
|
+
END;
|
|
23
|
+
$$ LANGUAGE plpgsql;
|
|
24
|
+
`);
|
|
25
|
+
|
|
26
|
+
// --- Helper function to create trigger for a table ---
|
|
27
|
+
const createRealtimeTrigger = async (tableName) => {
|
|
28
|
+
await pgm.sql(`
|
|
29
|
+
CREATE TRIGGER ${tableName}_change
|
|
30
|
+
AFTER INSERT OR UPDATE OR DELETE ON "${tableName}"
|
|
31
|
+
FOR EACH ROW
|
|
32
|
+
EXECUTE FUNCTION notify_table_change();
|
|
33
|
+
`);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// --- Create triggers for each table ---
|
|
37
|
+
await createRealtimeTrigger('users');
|
|
38
|
+
await createRealtimeTrigger('reviews');
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const down = async (pgm) => {
|
|
42
|
+
// Drop all triggers explicitly
|
|
43
|
+
await pgm.sql(`DROP TRIGGER IF EXISTS users_change ON "users" CASCADE;`);
|
|
44
|
+
await pgm.sql(`DROP TRIGGER IF EXISTS reviews_change ON "reviews" CASCADE;`);
|
|
45
|
+
|
|
46
|
+
// Drop shared trigger function
|
|
47
|
+
await pgm.sql(`DROP FUNCTION IF EXISTS notify_table_change() CASCADE;`);
|
|
48
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
|
|
3
|
+
*/
|
|
4
|
+
export const shorthands = undefined;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
|
8
|
+
* @param run {() => void | undefined}
|
|
9
|
+
* @returns {Promise<void> | void}
|
|
10
|
+
*/
|
|
11
|
+
export const up = (pgm) => {
|
|
12
|
+
// ---- Extensions ----
|
|
13
|
+
pgm.createExtension('ltree', { ifNotExists: true });
|
|
14
|
+
|
|
15
|
+
// ---- Table ----
|
|
16
|
+
pgm.createTable('rtdb_nodes', {
|
|
17
|
+
path: {
|
|
18
|
+
type: 'text',
|
|
19
|
+
primaryKey: true,
|
|
20
|
+
notNull: true,
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
parent_path: {
|
|
24
|
+
type: 'text',
|
|
25
|
+
notNull: false,
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
key: {
|
|
29
|
+
type: 'text',
|
|
30
|
+
notNull: true,
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
value: {
|
|
34
|
+
type: 'jsonb',
|
|
35
|
+
notNull: true,
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
version: {
|
|
39
|
+
type: 'bigint',
|
|
40
|
+
notNull: true,
|
|
41
|
+
default: 1,
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
updated_at: {
|
|
45
|
+
type: 'timestamptz',
|
|
46
|
+
notNull: true,
|
|
47
|
+
default: pgm.func('now()'),
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// Generated ltree column for fast subtree queries
|
|
51
|
+
path_ltree: {
|
|
52
|
+
type: 'ltree',
|
|
53
|
+
generated: {
|
|
54
|
+
as: "replace(path, '/', '.')::ltree",
|
|
55
|
+
stored: true,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ---- Indexes ----
|
|
61
|
+
|
|
62
|
+
// Fast exact + prefix lookups (LIKE 'a/b/%')
|
|
63
|
+
pgm.createIndex('rtdb_nodes', 'parent_path', {
|
|
64
|
+
name: 'rtdb_nodes_parent_idx',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
pgm.createIndex('rtdb_nodes', 'value', {
|
|
68
|
+
name: 'rtdb_nodes_value_gin_idx',
|
|
69
|
+
method: 'gin',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
pgm.createIndex('rtdb_nodes', 'path_ltree', {
|
|
73
|
+
name: 'rtdb_nodes_ltree_idx',
|
|
74
|
+
method: 'gist',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ✅ PREFIX INDEX (RAW SQL – avoids TS error)
|
|
78
|
+
pgm.sql(`
|
|
79
|
+
CREATE INDEX rtdb_nodes_path_prefix_idx
|
|
80
|
+
ON rtdb_nodes (path text_pattern_ops)
|
|
81
|
+
`);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
|
86
|
+
* @param run {() => void | undefined}
|
|
87
|
+
* @returns {Promise<void> | void}
|
|
88
|
+
*/
|
|
89
|
+
export const down = (pgm) => {
|
|
90
|
+
pgm.sql(`DROP INDEX IF EXISTS rtdb_nodes_path_prefix_idx`);
|
|
91
|
+
pgm.dropTable('rtdb_nodes', { ifExists: true });
|
|
92
|
+
pgm.dropExtension('ltree', { ifExists: true });
|
|
93
|
+
};
|