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.
Files changed (126) hide show
  1. package/.github/workflows/test.yml +74 -0
  2. package/CLA.md +60 -0
  3. package/CONTRIBUTORS.md +35 -0
  4. package/LICENSE +661 -0
  5. package/README.md +211 -0
  6. package/admin/404.html +33 -0
  7. package/admin/README.md +21 -0
  8. package/admin/index.html +15 -0
  9. package/admin/jsconfig.json +20 -0
  10. package/admin/lib/postbase.js +222 -0
  11. package/admin/package-lock.json +3746 -0
  12. package/admin/package.json +27 -0
  13. package/admin/public/assets/img/admin-ui.png +0 -0
  14. package/admin/public/assets/img/blank-profile-picture-960_720.webp +0 -0
  15. package/admin/public/assets/img/chart-active-users.png +0 -0
  16. package/admin/public/assets/img/icon-transparent.png +0 -0
  17. package/admin/src/App.jsx +48 -0
  18. package/admin/src/auth.js +11 -0
  19. package/admin/src/common/formatDateTime.js +18 -0
  20. package/admin/src/components/AuthPanel.jsx +88 -0
  21. package/admin/src/components/Header.jsx +67 -0
  22. package/admin/src/main.jsx +6 -0
  23. package/admin/src/pages/Dashboard.jsx +24 -0
  24. package/admin/src/pages/Home.jsx +52 -0
  25. package/admin/src/pages/Login.jsx +10 -0
  26. package/admin/src/pages/authentication/Users.jsx +199 -0
  27. package/admin/src/pages/firestore/Database.jsx +29 -0
  28. package/admin/src/pages/storage/files.jsx +29 -0
  29. package/admin/src/postbase.js +15 -0
  30. package/admin/src/styles.css +3 -0
  31. package/admin/tailwind.config.cjs +11 -0
  32. package/admin/template.env +2 -0
  33. package/admin/vite.config.js +21 -0
  34. package/assets/img/HomePageScreenshot.png +0 -0
  35. package/assets/img/better-auth-logo-dark.136b122f.png +0 -0
  36. package/assets/img/better-auth-logo-light.4b03f444.png +0 -0
  37. package/assets/img/expresjs.png +0 -0
  38. package/assets/img/icon-transparent.png +0 -0
  39. package/assets/img/icon.png +0 -0
  40. package/assets/img/letsencrypt-logo-horizontal.png +0 -0
  41. package/assets/img/logo.png +0 -0
  42. package/assets/img/node.js_logo.png +0 -0
  43. package/assets/img/nodejsLight.svg +39 -0
  44. package/assets/img/postgres.png +0 -0
  45. package/backend/README.md +49 -0
  46. package/backend/admin/auth.js +9 -0
  47. package/backend/app.js +68 -0
  48. package/backend/auth.js +92 -0
  49. package/backend/env.js +12 -0
  50. package/backend/lib/postbase/adminClient.js +520 -0
  51. package/backend/lib/postbase/compat/admin.js +44 -0
  52. package/backend/lib/postbase/db.js +17 -0
  53. package/backend/lib/postbase/genericRouter.js +603 -0
  54. package/backend/lib/postbase/local-storage.js +56 -0
  55. package/backend/lib/postbase/metadataCache.js +32 -0
  56. package/backend/lib/postbase/middlewares/auth.js +57 -0
  57. package/backend/lib/postbase/migrations/1765239687559_rtdb-nodes.js +93 -0
  58. package/backend/lib/postbase/package-lock.json +5873 -0
  59. package/backend/lib/postbase/package.json +19 -0
  60. package/backend/lib/postbase/rtdb/router.js +190 -0
  61. package/backend/lib/postbase/rtdb/rulesEngine.js +63 -0
  62. package/backend/lib/postbase/rtdb/ws.js +84 -0
  63. package/backend/lib/postbase/rulesEngine.js +62 -0
  64. package/backend/lib/postbase/storage.js +130 -0
  65. package/backend/lib/postbase/tests/README.md +22 -0
  66. package/backend/lib/postbase/tests/db.js +9 -0
  67. package/backend/lib/postbase/tests/rtdb.rest.test.js +46 -0
  68. package/backend/lib/postbase/tests/rtdb.ws.test.js +113 -0
  69. package/backend/lib/postbase/tests/rules.js +26 -0
  70. package/backend/lib/postbase/tests/testServer.js +46 -0
  71. package/backend/lib/postbase/websocket.js +131 -0
  72. package/backend/local.js +6 -0
  73. package/backend/main.js +20 -0
  74. package/backend/middlewares/auth_middleware.js +10 -0
  75. package/backend/migrations/1762137399366-init.sql +98 -0
  76. package/backend/migrations/1762137399367_init_jsonb_schema.js +68 -0
  77. package/backend/migrations/1762149999999_enable_realtime_changes.js +48 -0
  78. package/backend/migrations/1765224247654_rtdb-nodes.js +93 -0
  79. package/backend/package-lock.json +2374 -0
  80. package/backend/package.json +27 -0
  81. package/backend/postbase_db_rules.js +128 -0
  82. package/backend/postbase_rtdb_rules.js +27 -0
  83. package/backend/postbase_storage_rules.js +45 -0
  84. package/backend/template.env +10 -0
  85. package/backend-systemd/README.md +39 -0
  86. package/backend-systemd/your_website.com.service +12 -0
  87. package/frontend/404.html +33 -0
  88. package/frontend/README.md +25 -0
  89. package/frontend/index.html +15 -0
  90. package/frontend/jsconfig.json +20 -0
  91. package/frontend/lib/postbase/auth.js +132 -0
  92. package/frontend/lib/postbase/compat/firebase/app.js +3 -0
  93. package/frontend/lib/postbase/compat/firebase/auth.js +115 -0
  94. package/frontend/lib/postbase/compat/firebase/database.js +11 -0
  95. package/frontend/lib/postbase/compat/firebase/firestore/lite.js +61 -0
  96. package/frontend/lib/postbase/compat/firebase/storage.js +10 -0
  97. package/frontend/lib/postbase/db.js +657 -0
  98. package/frontend/lib/postbase/package-lock.json +6284 -0
  99. package/frontend/lib/postbase/package.json +17 -0
  100. package/frontend/lib/postbase/rtdb.js +108 -0
  101. package/frontend/lib/postbase/storage.js +293 -0
  102. package/frontend/lib/postbase/tests/rtdb.client.test.js +88 -0
  103. package/frontend/lib/postbase/tests/waitFor.js +13 -0
  104. package/frontend/lib/postbase/utils.js +1 -0
  105. package/frontend/package-lock.json +2977 -0
  106. package/frontend/package.json +24 -0
  107. package/frontend/src/App.jsx +38 -0
  108. package/frontend/src/auth.js +52 -0
  109. package/frontend/src/components/AuthPanel.jsx +85 -0
  110. package/frontend/src/components/Header.jsx +54 -0
  111. package/frontend/src/main.jsx +5 -0
  112. package/frontend/src/pages/Dashboard.jsx +24 -0
  113. package/frontend/src/pages/Home.jsx +178 -0
  114. package/frontend/src/pages/Login.jsx +10 -0
  115. package/frontend/src/postbase.js +14 -0
  116. package/frontend/src/styles.css +1 -0
  117. package/frontend/tailwind.config.cjs +11 -0
  118. package/frontend/template.env +2 -0
  119. package/frontend/vite.config.js +18 -0
  120. package/git/hooks/README.md +31 -0
  121. package/git/hooks/post-receive +26 -0
  122. package/nginx/README.md +84 -0
  123. package/nginx/apt/www.your_website.com.conf +80 -0
  124. package/nginx/homebrew/www.your_website.com.conf +80 -0
  125. package/nginx/letsencrypt/README +14 -0
  126. 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
+ }
@@ -0,0 +1,6 @@
1
+ import './env.js'; // for loading .env file
2
+
3
+ import { app } from "./app.js";
4
+
5
+ const PORT = process.env.POSTBASE_BACKEND_HTTP_PORT || 8081;
6
+ app.listen(PORT, () => console.log(`Postbase backend listening on http://0.0.0.0:${PORT}`));
@@ -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
+ };