feat: design db, configure Docker compose to spin up Postgres
This commit is contained in:
commit
598fef10b7
4
.env.example
Normal file
4
.env.example
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
DB_HOST=localhost
|
||||||
|
DB_USER=gb-light
|
||||||
|
DB_NAME=gb-light
|
||||||
|
DB_PASS=localPassword
|
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
# Configs
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Packages
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Artifacts
|
||||||
|
/build
|
||||||
|
/.svelte-kit
|
||||||
|
/package
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
/.volumes
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
34
README.md
Normal file
34
README.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# GB-Lux
|
||||||
|
|
||||||
|
This is a prototype/draft of a light control system.
|
||||||
|
At the moment it is at database design stage.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Docker](https://docs.docker.com/get-docker/)
|
||||||
|
- [Bun](https://bun.sh/)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Clone the repository
|
||||||
|
|
||||||
|
2. Install dependencies
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Copy `.env.example` to `.env` and fill in the values or leave them as is:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
4. Spin up database:
|
||||||
|
```bash
|
||||||
|
docker compose -f dev.compose.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Apply database migrations:
|
||||||
|
```bash
|
||||||
|
bun db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Now you can connect to the database with your favorite client (e.g. [DBeaver](https://dbeaver.io/)) using the credentials from `.env` file.
|
28
dev.compose.yml
Normal file
28
dev.compose.yml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
container_name: postgres
|
||||||
|
image: postgres:15-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./.volumes/postgres:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
- PGUSER=${DB_USER}
|
||||||
|
- POSTGRES_USER=${DB_USER}
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASS}
|
||||||
|
- POSTGRES_DB=${DB_NAME}
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 512M
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "pg_isready", "-U", "${POSTGRES_USER}"]
|
||||||
|
interval: 1m
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
21
package.json
Normal file
21
package.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "gb-lux",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "docker compose -f dev.compose.yml up",
|
||||||
|
"dev:docker:build": "docker compose -f dev.compose.yml build",
|
||||||
|
"dev:docker:clean": "docker rmi $(docker images -f \"dangling=true\" -q)",
|
||||||
|
"db:migrate": "bun ./sql/migrate.js",
|
||||||
|
"db:purge": "bun ./sql/purge.js",
|
||||||
|
"db:seed": "bun ./sql/seed.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"postgres": "3.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"dotenv": "16.3.2",
|
||||||
|
"@types/bun": "latest"
|
||||||
|
}
|
||||||
|
}
|
7
sql/lib/db.js
Normal file
7
sql/lib/db.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import postgres from 'postgres';
|
||||||
|
import { env } from './env.js';
|
||||||
|
|
||||||
|
export const sql = postgres({
|
||||||
|
...env.db,
|
||||||
|
transform: postgres.camel,
|
||||||
|
});
|
14
sql/lib/env.js
Normal file
14
sql/lib/env.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === 'prod' || process.env.NODE_ENV === 'production';
|
||||||
|
config({ path: isProd ? '.env.prod' : '.env' });
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
isProd,
|
||||||
|
db: {
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
pass: process.env.DB_PASS,
|
||||||
|
db: process.env.DB_NAME,
|
||||||
|
},
|
||||||
|
};
|
77
sql/migrate.js
Normal file
77
sql/migrate.js
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { readdirSync, readFileSync } from 'fs';
|
||||||
|
import { extname, join } from 'path';
|
||||||
|
import { sql } from './lib/db.js';
|
||||||
|
|
||||||
|
const migrationsFolder = './sql/migrations';
|
||||||
|
const fileExtension = '.sql';
|
||||||
|
|
||||||
|
async function applyMigrations() {
|
||||||
|
try {
|
||||||
|
// check if migrations table exists
|
||||||
|
const tableExists =
|
||||||
|
await sql`SELECT exists (SELECT FROM information_schema.tables WHERE table_name = 'migrations')`;
|
||||||
|
|
||||||
|
// create migrations table if it does not exist
|
||||||
|
if (!tableExists[0].exists) {
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
console.log('Migrations table was created.\n');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Failed to create migrations table', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let migrations = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// get list of migrations from database
|
||||||
|
const migrationsResult = await sql`SELECT name FROM migrations`;
|
||||||
|
migrations = migrationsResult.map(({ name }) => name);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Failed to get migrations from database', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// read directory and get .sql files
|
||||||
|
const files = readdirSync(migrationsFolder);
|
||||||
|
|
||||||
|
const migrationsToApply = files
|
||||||
|
.filter(file => extname(file) === fileExtension)
|
||||||
|
.map(file => file.replace(fileExtension, ''))
|
||||||
|
.filter(file => !migrations.includes(file))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
if (!migrationsToApply.length) {
|
||||||
|
console.log('✅ No new migrations to apply.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
console.log(`Applying ${migrationsToApply.length} migration(s):`);
|
||||||
|
|
||||||
|
for (let migration of migrationsToApply) {
|
||||||
|
console.log('- ' + migration);
|
||||||
|
// read SQL file
|
||||||
|
const sqlScript = readFileSync(join(migrationsFolder, migration + fileExtension), 'utf8');
|
||||||
|
// execute the SQL script
|
||||||
|
await sql.begin(async tx => {
|
||||||
|
await tx.unsafe(sqlScript);
|
||||||
|
// record that this migration has been run
|
||||||
|
await tx`INSERT INTO migrations (name) VALUES (${migration})`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✅ Migrations successfully applied.');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('\n❌ Failed to apply migrations\n', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyMigrations();
|
148
sql/migrations/2024-01-22_13-26_init.sql
Normal file
148
sql/migrations/2024-01-22_13-26_init.sql
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
CREATE TABLE organisations
|
||||||
|
(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
role INT2 NOT NULL DEFAULT 0, -- 0 - client, 1 - Yume, maybe more roles in the future
|
||||||
|
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE users
|
||||||
|
(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
organisation_id INT4 NOT NULL,
|
||||||
|
permission INT2,
|
||||||
|
email VARCHAR(50),
|
||||||
|
first_name VARCHAR(50),
|
||||||
|
last_name VARCHAR(50),
|
||||||
|
photo_url VARCHAR(200),
|
||||||
|
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (organisation_id) REFERENCES organisations (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE keys
|
||||||
|
(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INT4 NOT NULL,
|
||||||
|
kind INT2 NOT NULL,
|
||||||
|
identification VARCHAR(50) NOT NULL,
|
||||||
|
secret VARCHAR(60),
|
||||||
|
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sessions
|
||||||
|
(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
token VARCHAR(32) NOT NULL,
|
||||||
|
user_id INT4 NOT NULL,
|
||||||
|
key_id INT4 NOT NULL,
|
||||||
|
expires_at timestamp(3) without time zone,
|
||||||
|
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
FOREIGN KEY (key_id) REFERENCES keys (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE projects
|
||||||
|
(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
organisation_id INT4 NOT NULL,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (organisation_id) REFERENCES organisations (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE device_groups
|
||||||
|
(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
project_id INT4 NOT NULL,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
force_status INT2 NOT NULL DEFAULT 0, -- 0 - no force, 1 - force on, 2 - force off
|
||||||
|
lux_threshold INT2, -- Triggers the light if the lux level is below this threshold
|
||||||
|
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE device_group_daily_schedule
|
||||||
|
(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
device_group_id INT4 NOT NULL,
|
||||||
|
day_of_week INT2 NOT NULL, -- 0 - Sunday, 1 - Monday, 2 - Tuesday, 3 - Wednesday, 4 - Thursday, 5 - Friday, 6 - Saturday
|
||||||
|
start_time TIME NOT NULL,
|
||||||
|
end_time TIME NOT NULL,
|
||||||
|
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (device_group_id) REFERENCES device_groups (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- We might need to separate light, gateways and sensors into different tables. For now, let's discuss it.
|
||||||
|
CREATE TABLE devices
|
||||||
|
(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
uuid VARCHAR(36) NOT NULL,
|
||||||
|
gbtb_id VARCHAR(50) NOT NULL, -- QUESTION: What type is gbtb_id? Is it a string or an integer?
|
||||||
|
device_group_id INT4 NOT NULL,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
lamppost_number VARCHAR(50), -- QUESTION: What type is lamppost_number? Is it a string or an integer?
|
||||||
|
started_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, -- for calculating the uptime/running hours
|
||||||
|
lux_level INT2,
|
||||||
|
role INT2 NOT NULL DEFAULT 0, -- 0 - light, 1 - motion sensor, 2 - gateway, maybe more roles in the future
|
||||||
|
|
||||||
|
-- geolocation. We can use Postgres plugin "PostGIS" for this, but it's not necessary for now
|
||||||
|
latitude FLOAT4,
|
||||||
|
longitude FLOAT4,
|
||||||
|
altitude FLOAT4,
|
||||||
|
|
||||||
|
status INT2 NOT NULL DEFAULT 0, -- 0 - off, 1 - on, maybe more statuses in the future
|
||||||
|
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (device_group_id) REFERENCES device_groups (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE measures
|
||||||
|
(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
device_id INT4 NOT NULL,
|
||||||
|
measure_type INT2 NOT NULL DEFAULT 0, -- 0 - temperature, 1 - humidity, 2 - light, 3 - motion, 4 - voltage, 5 - power consumption, maybe more in the future
|
||||||
|
value INT4 NOT NULL,
|
||||||
|
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE commands
|
||||||
|
(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
organisation_id INT4 NOT NULL,
|
||||||
|
originator_id INT4,
|
||||||
|
originator_type INT2 NOT NULL DEFAULT 0, -- 0 - user, 1 - device, 2 - device group, 3 - organisation, maybe more in the future
|
||||||
|
command_type INT2 NOT NULL, -- 0 - on, 1 - off, 2 - force on, 3 - force off, 4 - reboot, 5 - reset, etc
|
||||||
|
comment VARCHAR(100),
|
||||||
|
entity INT4 NOT NULL,
|
||||||
|
entity_type INT2 NOT NULL DEFAULT 0, -- 0 - device, 1 - device group, 2 - organisation, 3 - user, maybe more in the future
|
||||||
|
scheduled_at TIMESTAMP(3), -- NULL if the command is executed immediately
|
||||||
|
executed_at TIMESTAMP(3), -- NULL if the command is not executed yet
|
||||||
|
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (organisation_id) REFERENCES organisations (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
-- No other FKs, because we don't want to delete the history when the entity is deleted
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE alerts
|
||||||
|
(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
organisation_id INT4 NOT NULL,
|
||||||
|
alert_type INT2 NOT NULL, -- 0 - temperature, 1 - humidity, 2 - light, 3 - motion, 4 - voltage, 5 - power consumption, maybe more in the future
|
||||||
|
entity INT4 NOT NULL,
|
||||||
|
entity_type INT2 NOT NULL DEFAULT 0, -- 0 - device, 1 - device group, 2 - organisation, 3 - user, maybe more in the future
|
||||||
|
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (organisation_id) REFERENCES organisations (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
-- No other FKs, because we don't want to delete the history when the entity is deleted
|
||||||
|
);
|
45
sql/purge.js
Normal file
45
sql/purge.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { env } from './lib/env.js';
|
||||||
|
import { sql } from './lib/db.js';
|
||||||
|
|
||||||
|
/** Drops all tables from the database.
|
||||||
|
* if NODE_ENV!='prod' || 'production' and not --prod - then abort */
|
||||||
|
async function purge() {
|
||||||
|
if (env.isProd && !process.argv.includes('--prod')) {
|
||||||
|
console.error('❌ Cannot purge database in production');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
let tables = [];
|
||||||
|
try {
|
||||||
|
console.log('Fetching table names...');
|
||||||
|
const result = await sql`
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
`;
|
||||||
|
tables = result.map(({ tableName }) => tableName);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Failed to fetch table names', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tables.length) {
|
||||||
|
console.log('\n✅ No tables to purge.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Purging ${tables.length} tables:`);
|
||||||
|
for (let table of tables) {
|
||||||
|
console.log('- ' + table);
|
||||||
|
await sql`DROP TABLE ${sql(table)} CASCADE`;
|
||||||
|
}
|
||||||
|
console.log('\n✅ Database purged.');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('\n❌ Failed to purge database\n', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void purge();
|
Loading…
Reference in New Issue
Block a user