feat: add ESLint and Prettier configuration for code quality

- Added ESLint with TypeScript support and custom rules.
- Introduced Prettier for consistent code formatting.
- Updated package.json to include lint and format scripts.
- Modified db.ts to ensure proper parsing of BIGINT types from PostgreSQL.
- Enhanced webRoutes to coerce timestamp fields to numbers and format isDeleted as boolean.
This commit is contained in:
2025-10-25 17:33:48 +02:00
parent 09a2b09913
commit 14c80197cc
13 changed files with 1620 additions and 44 deletions
+13 -7
View File
@@ -9,7 +9,7 @@ A collaborative table management Android application built with Jetpack Compose
- Add items with values for each field - Add items with values for each field
- Beautiful Material 3 design with dynamic colors - Beautiful Material 3 design with dynamic colors
- Local Room database for offline support - Local Room database for offline support
- Automatic synchronization with server - Automatic synchronization with server (WebSocket-first with HTTP fallback)
- Soft delete support (items can be recovered on server) - Soft delete support (items can be recovered on server)
## Architecture ## Architecture
@@ -69,7 +69,8 @@ app/src/main/java/com/collabtable/app/
- **Jetpack Compose**: Modern declarative UI toolkit - **Jetpack Compose**: Modern declarative UI toolkit
- **Material 3**: Latest Material Design guidelines - **Material 3**: Latest Material Design guidelines
- **Room**: Local SQLite database - **Room**: Local SQLite database
- **Retrofit**: REST API client - **Retrofit**: REST API client (fallback)
- **OkHttp WebSocket**: Real-time sync channel
- **Kotlin Coroutines**: Asynchronous programming - **Kotlin Coroutines**: Asynchronous programming
- **Flow**: Reactive data streams - **Flow**: Reactive data streams
@@ -97,12 +98,17 @@ app/src/main/java/com/collabtable/app/
## Synchronization ## Synchronization
The app includes sync functionality that: The app uses a WebSocket-first sync strategy with an HTTP fallback:
- Sends local changes to the server
- Receives changes from other clients
- Resolves conflicts using timestamps (latest wins)
To trigger sync, you can call the `SyncRepository.performSync()` method from your code. - WebSocket (`/api/ws`): Sends a `sync` message with local changes and receives deltas plus the new `serverTimestamp`.
- HTTP (`POST /api/sync`): Used automatically as a fallback if the WS exchange fails.
Behavior:
- Sends local changes since the last sync and receives server changes since the last known timestamp.
- Timestamps are used to resolve conflicts (latest wins).
- If the server is protected with `SERVER_PASSWORD`, the app sends `Authorization: Bearer <password>` for both HTTP and WebSocket.
To trigger sync programmatically, call `SyncRepository.performSync()`.
## Building for Release ## Building for Release
+15
View File
@@ -2,6 +2,8 @@ plugins {
id 'com.android.application' id 'com.android.application'
id 'org.jetbrains.kotlin.android' id 'org.jetbrains.kotlin.android'
id 'com.google.devtools.ksp' id 'com.google.devtools.ksp'
id 'org.jlleitschuh.gradle.ktlint'
id 'io.gitlab.arturbosch.detekt'
} }
android { android {
@@ -87,3 +89,16 @@ dependencies {
debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest' debugImplementation 'androidx.compose.ui:ui-test-manifest'
} }
// Ktlint configuration
ktlint {
android.set(true)
ignoreFailures.set(false)
outputToConsole.set(true)
}
// Detekt configuration (uses default rules; customize via detekt.yml if needed)
detekt {
buildUponDefaultConfig = true
allRules = false
}
@@ -36,6 +36,7 @@ object WebSocketSyncClient {
.connectTimeout(15, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS)
.pingInterval(15, TimeUnit.SECONDS)
.build() .build()
private fun buildWebSocketUrl(httpApiBaseUrl: String): String { private fun buildWebSocketUrl(httpApiBaseUrl: String): String {
@@ -127,8 +128,8 @@ object WebSocketSyncClient {
try { try {
socket = client.newWebSocket(httpRequest, listener) socket = client.newWebSocket(httpRequest, listener)
// Wait up to 15s for response // Wait up to 30s for response (WS roundtrip)
val result = withTimeout(15_000) { deferred.await() } val result = withTimeout(30_000) { deferred.await() }
return@withContext result return@withContext result
} catch (e: Exception) { } catch (e: Exception) {
Logger.w("WS", "Falling back to HTTP sync: ${e.message}") Logger.w("WS", "Falling back to HTTP sync: ${e.message}")
+2
View File
@@ -4,4 +4,6 @@ plugins {
id 'com.android.library' version '8.3.0' apply false id 'com.android.library' version '8.3.0' apply false
id 'org.jetbrains.kotlin.android' version '1.9.20' apply false id 'org.jetbrains.kotlin.android' version '1.9.20' apply false
id 'com.google.devtools.ksp' version '1.9.20-1.0.14' apply false id 'com.google.devtools.ksp' version '1.9.20-1.0.14' apply false
id 'org.jlleitschuh.gradle.ktlint' version '12.1.0' apply false
id 'io.gitlab.arturbosch.detekt' version '1.23.6' apply false
} }
+4
View File
@@ -4,6 +4,10 @@ dist
.env .env
.git .git
.gitignore .gitignore
.vscode
.idea
README.md README.md
docker-compose.yml docker-compose.yml
Dockerfile Dockerfile
data/
collabtable.db
+22
View File
@@ -0,0 +1,22 @@
module.exports = {
root: true,
env: {
es2021: true,
node: true,
},
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: null,
},
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
};
+6
View File
@@ -0,0 +1,6 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"printWidth": 100
}
+36 -12
View File
@@ -1,12 +1,12 @@
# CollabTable Server # CollabTable Server
A collaborative list management server built with Node.js, Express, TypeScript, and SQLite. A collaborative table management server built with Node.js, Express, TypeScript, and a pluggable database (SQLite or PostgreSQL).
## Features ## Features
- RESTful API for managing lists, fields, items, and values - RESTful API for managing lists, fields, items, and values
- Real-time synchronization support - Real-time synchronization via WebSocket (primary) with HTTP fallback
- SQLite database with Sequelize ORM - SQLite (better-sqlite3) or PostgreSQL (pg) database backends
- Dockerized deployment with persistent storage - Dockerized deployment with persistent storage
- TypeScript for type safety - TypeScript for type safety
@@ -35,7 +35,7 @@ A collaborative list management server built with Node.js, Express, TypeScript,
The server will be available at `http://localhost:3000` The server will be available at `http://localhost:3000`
**Important:** The SQLite database is stored in a Docker volume named `sqlite_data`, which persists across container restarts and rebuilds. **Important:** The default backend is SQLite with a Docker volume `sqlite_data` for persistence. You can also switch to PostgreSQL by setting environment variables.
## Local Development ## Local Development
@@ -49,9 +49,21 @@ The server will be available at `http://localhost:3000`
cp .env.example .env cp .env.example .env
``` ```
3. Update the database path in `.env` if needed: 3. Choose your database in `.env` (SQLite by default):
``` ```
# Backend: sqlite | postgres (default sqlite)
DB_CLIENT=sqlite
# SQLite
DB_PATH=./data/collabtable.db DB_PATH=./data/collabtable.db
# OR PostgreSQL
# DATABASE_URL=postgres://user:password@localhost:5432/collabtable
# PGHOST=localhost
# PGPORT=5432
# PGUSER=postgres
# PGPASSWORD=yourpassword
# PGDATABASE=collabtable
``` ```
4. Start the development server: 4. Start the development server:
@@ -85,6 +97,13 @@ The server will be available at `http://localhost:3000`
### Sync ### Sync
- `POST /api/sync` - Synchronize data between client and server - `POST /api/sync` - Synchronize data between client and server
### WebSocket
- `GET /api/ws` - WebSocket endpoint. Send JSON messages:
- `{"type":"ping"}` → `{"type":"pong"}`
- `{"type":"sync", "id":"<uuid>", "payload": { ...SyncRequest }}` → `{"type":"syncResponse", "id":"<uuid>", "payload": { ...SyncResponse }}`
Authentication: If `SERVER_PASSWORD` is set, include header `Authorization: Bearer <password>` on HTTP and WebSocket requests.
### Health Check ### Health Check
- `GET /health` - Check server health - `GET /health` - Check server health
@@ -205,27 +224,32 @@ docker-compose up -d --build
## Environment Variables ## Environment Variables
- `PORT` - Server port (default: 3000) - `PORT` - Server port (default: 3000)
- `DB_PATH` - Path to SQLite database file (default: /data/collabtable.db)
- `NODE_ENV` - Environment (development/production) - `NODE_ENV` - Environment (development/production)
- `SERVER_PASSWORD` - Optional shared password for API and WebSocket (recommended)
- `DB_CLIENT` - `sqlite` (default) or `postgres`
- `DB_PATH` - Path to SQLite database file (default: ./data/collabtable.db for local, /data/collabtable.db in Docker)
- `DATABASE_URL` - PostgreSQL connection URL (optional alternative to PG* variables)
- `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE` - PostgreSQL connection parameters
## Data Persistence ## Data Persistence
The SQLite database file is stored in a Docker volume, ensuring data persists across: ### SQLite
- Container restarts The SQLite database file is stored in a Docker volume, ensuring data persists across container lifecycle events.
- Container recreation
- Docker Compose down/up cycles
To backup your data: Backup:
```bash ```bash
docker cp collabtable-server:/data/collabtable.db ./backup.db docker cp collabtable-server:/data/collabtable.db ./backup.db
``` ```
To restore data: Restore:
```bash ```bash
docker cp ./backup.db collabtable-server:/data/collabtable.db docker cp ./backup.db collabtable-server:/data/collabtable.db
docker-compose restart docker-compose restart
``` ```
### PostgreSQL
Use your normal PostgreSQL backup/restore procedures (e.g., `pg_dump`, `pg_restore`). If using the optional Dockerized Postgres service, the data is stored in a named volume (see `docker-compose.yml`).
## License ## License
MIT MIT
+30 -1
View File
@@ -7,8 +7,20 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- PORT=3000 - PORT=3000
- DB_PATH=/data/collabtable.db
- NODE_ENV=production - NODE_ENV=production
# Authentication (optional but recommended)
- SERVER_PASSWORD=change_me
# Database selection: sqlite | postgres (default sqlite)
- DB_CLIENT=sqlite
# SQLite path (used when DB_CLIENT=sqlite)
- DB_PATH=/data/collabtable.db
# PostgreSQL (used when DB_CLIENT=postgres) - choose either DATABASE_URL or the PG* vars
# - DATABASE_URL=postgres://user:password@postgres:5432/collabtable
# - PGHOST=postgres
# - PGPORT=5432
# - PGUSER=postgres
# - PGPASSWORD=yourpassword
# - PGDATABASE=collabtable
ports: ports:
- "3000:3000" - "3000:3000"
volumes: volumes:
@@ -16,8 +28,25 @@ services:
networks: networks:
- collabtable-network - collabtable-network
# Optional PostgreSQL database
# postgres:
# image: postgres:16-alpine
# container_name: collabtable-postgres
# restart: unless-stopped
# environment:
# - POSTGRES_DB=collabtable
# - POSTGRES_USER=postgres
# - POSTGRES_PASSWORD=yourpassword
# ports:
# - "5432:5432"
# volumes:
# - pg_data:/var/lib/postgresql/data
# networks:
# - collabtable-network
volumes: volumes:
sqlite_data: sqlite_data:
# pg_data:
networks: networks:
collabtable-network: collabtable-network:
+1458 -18
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -7,6 +7,8 @@
"start": "node dist/index.js", "start": "node dist/index.js",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts", "dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc", "build": "tsc",
"lint": "eslint src --ext .ts",
"format": "prettier --write \"**/*.{ts,js,json,md,yml,yaml}\"",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"keywords": ["collaboration", "api", "express"], "keywords": ["collaboration", "api", "express"],
@@ -18,6 +20,11 @@
"better-sqlite3": "^11.0.0", "better-sqlite3": "^11.0.0",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"ws": "^8.16.0", "ws": "^8.16.0",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.3.3",
"pg": "^8.11.3" "pg": "^8.11.3"
}, },
"devDependencies": { "devDependencies": {
+4 -1
View File
@@ -1,6 +1,6 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import BetterSqlite3, { Database as SqliteDB } from 'better-sqlite3'; import BetterSqlite3, { Database as SqliteDB } from 'better-sqlite3';
import { Pool, PoolClient, QueryResult } from 'pg'; import { Pool, PoolClient, QueryResult, types as pgTypes } from 'pg';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
@@ -129,6 +129,9 @@ class PostgresAdapter implements DBAdapter {
private pool: Pool; private pool: Pool;
constructor() { constructor() {
// Ensure BIGINT (int8) is parsed as number to avoid string timestamps
// OID 20 = INT8, OID 1700 = NUMERIC (keep default for now)
pgTypes.setTypeParser(20, (val: string) => parseInt(val, 10));
const connectionString = process.env.DATABASE_URL; const connectionString = process.env.DATABASE_URL;
if (connectionString) { if (connectionString) {
this.pool = new Pool({ connectionString }); this.pool = new Pool({ connectionString });
+20 -3
View File
@@ -313,9 +313,26 @@ router.get('/web/data', async (req: Request, res: Response) => {
const values = await dbAdapter.queryAll('SELECT * FROM item_values'); const values = await dbAdapter.queryAll('SELECT * FROM item_values');
// Convert isDeleted from INTEGER to BOOLEAN // Convert isDeleted from INTEGER to BOOLEAN
const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted })); // Coerce timestamps to numbers (pg may return strings)
const formattedFields = (fields as any[]).map(field => ({ ...field, isDeleted: !!field.isDeleted })); const toNum = (v: any) => (typeof v === 'string' ? Number(v) : v);
const formattedItems = (items as any[]).map(item => ({ ...item, isDeleted: !!item.isDeleted })); const formattedLists = (lists as any[]).map(list => ({
...list,
isDeleted: !!list.isDeleted,
createdAt: toNum(list.createdAt),
updatedAt: toNum(list.updatedAt)
}));
const formattedFields = (fields as any[]).map(field => ({
...field,
isDeleted: !!field.isDeleted,
createdAt: toNum(field.createdAt),
updatedAt: toNum(field.updatedAt)
}));
const formattedItems = (items as any[]).map(item => ({
...item,
isDeleted: !!item.isDeleted,
createdAt: toNum(item.createdAt),
updatedAt: toNum(item.updatedAt)
}));
res.json({ res.json({
lists: formattedLists, lists: formattedLists,