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:
@@ -9,7 +9,7 @@ A collaborative table management Android application built with Jetpack Compose
|
||||
- Add items with values for each field
|
||||
- Beautiful Material 3 design with dynamic colors
|
||||
- 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)
|
||||
|
||||
## Architecture
|
||||
@@ -69,7 +69,8 @@ app/src/main/java/com/collabtable/app/
|
||||
- **Jetpack Compose**: Modern declarative UI toolkit
|
||||
- **Material 3**: Latest Material Design guidelines
|
||||
- **Room**: Local SQLite database
|
||||
- **Retrofit**: REST API client
|
||||
- **Retrofit**: REST API client (fallback)
|
||||
- **OkHttp WebSocket**: Real-time sync channel
|
||||
- **Kotlin Coroutines**: Asynchronous programming
|
||||
- **Flow**: Reactive data streams
|
||||
|
||||
@@ -97,12 +98,17 @@ app/src/main/java/com/collabtable/app/
|
||||
|
||||
## Synchronization
|
||||
|
||||
The app includes sync functionality that:
|
||||
- Sends local changes to the server
|
||||
- Receives changes from other clients
|
||||
- Resolves conflicts using timestamps (latest wins)
|
||||
The app uses a WebSocket-first sync strategy with an HTTP fallback:
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'com.google.devtools.ksp'
|
||||
id 'org.jlleitschuh.gradle.ktlint'
|
||||
id 'io.gitlab.arturbosch.detekt'
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -87,3 +89,16 @@ dependencies {
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
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
|
||||
}
|
||||
|
||||
+3
-2
@@ -36,6 +36,7 @@ object WebSocketSyncClient {
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.pingInterval(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private fun buildWebSocketUrl(httpApiBaseUrl: String): String {
|
||||
@@ -127,8 +128,8 @@ object WebSocketSyncClient {
|
||||
|
||||
try {
|
||||
socket = client.newWebSocket(httpRequest, listener)
|
||||
// Wait up to 15s for response
|
||||
val result = withTimeout(15_000) { deferred.await() }
|
||||
// Wait up to 30s for response (WS roundtrip)
|
||||
val result = withTimeout(30_000) { deferred.await() }
|
||||
return@withContext result
|
||||
} catch (e: Exception) {
|
||||
Logger.w("WS", "Falling back to HTTP sync: ${e.message}")
|
||||
|
||||
@@ -4,4 +4,6 @@ plugins {
|
||||
id 'com.android.library' version '8.3.0' 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 'org.jlleitschuh.gradle.ktlint' version '12.1.0' apply false
|
||||
id 'io.gitlab.arturbosch.detekt' version '1.23.6' apply false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ dist
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.idea
|
||||
README.md
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
data/
|
||||
collabtable.db
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100
|
||||
}
|
||||
+36
-12
@@ -1,12 +1,12 @@
|
||||
# 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
|
||||
|
||||
- RESTful API for managing lists, fields, items, and values
|
||||
- Real-time synchronization support
|
||||
- SQLite database with Sequelize ORM
|
||||
- Real-time synchronization via WebSocket (primary) with HTTP fallback
|
||||
- SQLite (better-sqlite3) or PostgreSQL (pg) database backends
|
||||
- Dockerized deployment with persistent storage
|
||||
- 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`
|
||||
|
||||
**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
|
||||
|
||||
@@ -49,9 +49,21 @@ The server will be available at `http://localhost:3000`
|
||||
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
|
||||
|
||||
# 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:
|
||||
@@ -85,6 +97,13 @@ The server will be available at `http://localhost:3000`
|
||||
### Sync
|
||||
- `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
|
||||
- `GET /health` - Check server health
|
||||
|
||||
@@ -205,27 +224,32 @@ docker-compose up -d --build
|
||||
## Environment Variables
|
||||
|
||||
- `PORT` - Server port (default: 3000)
|
||||
- `DB_PATH` - Path to SQLite database file (default: /data/collabtable.db)
|
||||
- `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
|
||||
|
||||
The SQLite database file is stored in a Docker volume, ensuring data persists across:
|
||||
- Container restarts
|
||||
- Container recreation
|
||||
- Docker Compose down/up cycles
|
||||
### SQLite
|
||||
The SQLite database file is stored in a Docker volume, ensuring data persists across container lifecycle events.
|
||||
|
||||
To backup your data:
|
||||
Backup:
|
||||
```bash
|
||||
docker cp collabtable-server:/data/collabtable.db ./backup.db
|
||||
```
|
||||
|
||||
To restore data:
|
||||
Restore:
|
||||
```bash
|
||||
docker cp ./backup.db collabtable-server:/data/collabtable.db
|
||||
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
|
||||
|
||||
MIT
|
||||
|
||||
@@ -7,8 +7,20 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORT=3000
|
||||
- DB_PATH=/data/collabtable.db
|
||||
- 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:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
@@ -16,8 +28,25 @@ services:
|
||||
networks:
|
||||
- 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:
|
||||
sqlite_data:
|
||||
# pg_data:
|
||||
|
||||
networks:
|
||||
collabtable-network:
|
||||
|
||||
Generated
+1458
-18
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"build": "tsc",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"format": "prettier --write \"**/*.{ts,js,json,md,yml,yaml}\"",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": ["collaboration", "api", "express"],
|
||||
@@ -18,6 +20,11 @@
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import dotenv from 'dotenv';
|
||||
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 fs from 'fs';
|
||||
|
||||
@@ -129,6 +129,9 @@ class PostgresAdapter implements DBAdapter {
|
||||
private pool: Pool;
|
||||
|
||||
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;
|
||||
if (connectionString) {
|
||||
this.pool = new Pool({ connectionString });
|
||||
|
||||
@@ -313,9 +313,26 @@ router.get('/web/data', async (req: Request, res: Response) => {
|
||||
const values = await dbAdapter.queryAll('SELECT * FROM item_values');
|
||||
|
||||
// Convert isDeleted from INTEGER to BOOLEAN
|
||||
const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted }));
|
||||
const formattedFields = (fields as any[]).map(field => ({ ...field, isDeleted: !!field.isDeleted }));
|
||||
const formattedItems = (items as any[]).map(item => ({ ...item, isDeleted: !!item.isDeleted }));
|
||||
// Coerce timestamps to numbers (pg may return strings)
|
||||
const toNum = (v: any) => (typeof v === 'string' ? Number(v) : v);
|
||||
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({
|
||||
lists: formattedLists,
|
||||
|
||||
Reference in New Issue
Block a user