Skip to content

Database subsystem #189

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 8 commits into
base: dev
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 45 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,18 +1,55 @@
#? Rename or copy this file to ".env" and set the variables there.
#?
#?####################
#? VARIABLE STRUCTURE:
#?####################
#? [ default ] : type < min - max >
#? ^ ^ ^
#? | | |
#? | | +---- RANGE between two values (a & b)
#? | +-------------- TYPE of the variable
#? +------------------------ DEFAULT value applied if not set
#?
#?###################
#? COMMENT STRUCTURE:
#?###################
#? "#?#..." or "###..." are used to comment a section line.
#? "#?" is used to comment a help line.
#? "##" is used to comment a description line.
#? "#" is used to comment a variable line.
#?
#? You should remove the comment on variable lines only if you want to set the variable.

##########
## SERVER:
# Set log verbosity [3]:integer
# (0=none <- 1=error <- 2=warn <- 3=info <- 4=debug)
##########
## Set log verbosity [3]:integer<0-4>
#? (0=none; 1=error; 2=warn; 3=info; 4=debug)
#LOGLEVEL=3

# Port for the server [4000]:integer
## Port for the server [4000]:integer
#PORT=4000

# Root path for the server (NOT IMPLEMENTED) [/api]:string
# (Everything will be served under this path)
## Root path for the server (NOT IMPLEMENTED) [/api]:string
#? (Everything will be served under this path)
#PATH=/api

# Is website served over HTTPS? [true]:boolean
## Is website served over HTTPS? [true]:boolean
#TLS=true

############
## DOCUMENT:
# Maximum document size in kilobytes [1024]:integer
#DOCUMENT_MAXSIZE=1024
############
## Maximum uploaded size in kilobytes [1024]:integer
#DOCUMENT_MAXSIZE=1024

## Compression type to use on saved documents [brotli]:string
#? (This doesn't apply retroactively to already saved documents)
#? (none; deflate; brotli)
#DOCUMENT_COMPRESSION=brotli

## Set DEFLATE compression level [1]:integer<0-9>
#DOCUMENT_COMPRESSION_DEFLATE_LEVEL=1

## Set Brotli compression level [1]:integer<0-11>
#DOCUMENT_COMPRESSION_BROTLI_LEVEL=1
20 changes: 10 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -74,18 +74,18 @@ jobs:
run: |
bun run build:standalone:darwin-arm64
chmod 755 ./dist/backend
tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ backend | gzip --best >./dist/backend_${{ steps.tags-artifact.outputs.tag }}_darwin-arm64.tar.gz
tar -tzf ./dist/backend_${{ steps.tags-artifact.outputs.tag }}_darwin-arm64.tar.gz >/dev/null
tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ backend | xz -z -6 >./dist/backend_${{ steps.tags-artifact.outputs.tag }}_darwin-arm64.tar.xz
tar -tJf ./dist/backend_${{ steps.tags-artifact.outputs.tag }}_darwin-arm64.tar.xz >/dev/null

bun run build:standalone:linux-amd64
bun run build:standalone:linux-glibc-amd64
chmod 755 ./dist/backend
tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ backend | gzip --best >./dist/backend_${{ steps.tags-artifact.outputs.tag }}_linux-amd64.tar.gz
tar -tzf ./dist/backend_${{ steps.tags-artifact.outputs.tag }}_linux-amd64.tar.gz >/dev/null
tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ backend | xz -z -6 >./dist/backend_${{ steps.tags-artifact.outputs.tag }}_linux-glibc-amd64.tar.xz
tar -tJf ./dist/backend_${{ steps.tags-artifact.outputs.tag }}_linux-glibc-amd64.tar.xz >/dev/null

bun run build:standalone:linux-arm64
bun run build:standalone:linux-glibc-arm64
chmod 755 ./dist/backend
tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ backend | gzip --best >./dist/backend_${{ steps.tags-artifact.outputs.tag }}_linux-arm64.tar.gz
tar -tzf ./dist/backend_${{ steps.tags-artifact.outputs.tag }}_linux-arm64.tar.gz >/dev/null
tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ backend | xz -z -6 >./dist/backend_${{ steps.tags-artifact.outputs.tag }}_linux-glibc-arm64.tar.xz
tar -tJf ./dist/backend_${{ steps.tags-artifact.outputs.tag }}_linux-glibc-arm64.tar.xz >/dev/null

bun run build:standalone:windows-amd64
chmod 755 ./dist/backend.exe
@@ -98,7 +98,7 @@ jobs:
with:
name: ${{ steps.tags-artifact.outputs.extended }}
tag: ${{ steps.tags-artifact.outputs.extended }}
artifacts: dist/*.tar.gz,dist/*.zip
artifacts: dist/*.tar.xz,dist/*.zip
makeLatest: true
prerelease: ${{ github.ref != 'refs/heads/stable' }}
generateReleaseNotes: ${{ github.ref == 'refs/heads/stable' }}
@@ -108,7 +108,7 @@ jobs:
uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018 # v1.4.4
with:
subject-path: |
dist/*.tar.gz
dist/*.tar.xz
dist/*.zip

container:
Binary file modified bun.lockb
Binary file not shown.
14 changes: 8 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -9,8 +9,10 @@
"build:server": "bun build ./src/server.ts --outfile=./dist/server.js --target=bun --minify --sourcemap=inline",
"build:standalone": "bun build ./src/server.ts --outfile=./dist/backend --compile --minify --sourcemap=inline",
"build:standalone:darwin-arm64": "bun run build:standalone -- --target=bun-darwin-arm64",
"build:standalone:linux-amd64": "bun run build:standalone -- --target=bun-linux-x64-modern",
"build:standalone:linux-arm64": "bun run build:standalone -- --target=bun-linux-arm64",
"build:standalone:linux-glibc-amd64": "bun run build:standalone -- --target=bun-linux-x64-modern",
"build:standalone:linux-glibc-arm64": "bun run build:standalone -- --target=bun-linux-arm64",
"build:standalone:linux-musl-amd64": "bun run build:standalone -- --target=bun-linux-x64-modern-musl",
"build:standalone:linux-musl-arm64": "bun run build:standalone -- --target=bun-linux-arm64-musl",
"build:standalone:windows-amd64": "bun run build:standalone -- --target=bun-windows-x64-modern",
"clean:git:all": "bun run clean:git:untracked && bun run clean:git:gc && bun run clean:git:hooks",
"clean:git:all:force": "bun run clean:git:untracked:force && bun run clean:git:gc && bun run clean:git:hooks",
@@ -31,15 +33,15 @@
"start:server": "bun run ./dist/server.js"
},
"dependencies": {
"@hono/zod-openapi": "~0.18.0",
"@hono/zod-openapi": "~0.18.3",
"env-var": "~7.5.0",
"hono": "~4.6.10"
"hono": "~4.6.12"
},
"devDependencies": {
"@biomejs/biome": "~1.9.4",
"@types/bun": "^1.1.13",
"@types/bun": "^1.1.14",
"lefthook": "~1.8.4",
"sort-package-json": "~2.11.0"
"sort-package-json": "~2.12.0"
},
"peerDependencies": {
"typescript": "5.5.4"
12 changes: 7 additions & 5 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { env } from '@x-util/env.ts';
import { env } from './env.ts';

export const config = {
protocol: env.tls ? 'https://' : 'http://',
apiPath: '/api',
storagePath: 'storage/',
documentNameLengthMin: 2,
documentNameLengthDefault: 8,
documentNameLengthMax: 32,
documentNameLengthDefault: 8
documentNameLengthMin: 2,
protocol: env.tls ? 'https://' : 'http://',
storageDataPath: './storage/data/',
storageDatabaseFile: './storage/database.db',
storagePath: './storage/'
} as const;
85 changes: 85 additions & 0 deletions src/database/Database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Database as SQLite } from 'bun:sqlite';
import { existsSync, mkdirSync } from 'node:fs';
import { logger } from '@x-util/logger.ts';
import { config } from '../config.ts';
import { env } from '../env.ts';
import { shutdown } from '../server.ts';
import { map, migrations } from './migrations.ts';

export class Database {
private readonly database: SQLite;

public constructor() {
// TODO: Move this out of the constructor
if (!existsSync(config.storagePath)) {
mkdirSync(config.storagePath);
}

this.database = new SQLite(env.debugDatabaseEphemeral ? undefined : config.storageDatabaseFile, {
strict: true
});

this.migration();
}

public get instance(): SQLite {
return this.database;
}

public close(graceful = true): void {
this.database.close(!graceful);

logger.debug('Database closed', graceful ? 'gracefully.' : 'forcefully.');
}

private migration(): void {
const currentVersion = this.database
.prepare<{ user_version: number }, null>('PRAGMA user_version')
.get(null)?.user_version;

if (currentVersion === undefined) {
logger.error('Failed to get the current database version. Aborting...');

shutdown(1);
return;
}

const migrationEntries = Object.entries(migrations).map(
([key, value]) => [Number(key), value] as [number, string]
);

if (currentVersion === migrationEntries.length) {
logger.info('Database already up to date.');
return;
}

if (currentVersion > migrationEntries.length) {
logger.error(
'Database version is higher than the available migrations. This might indicate that you are running an older version of the backend. Aborting...'
);

shutdown(1);
return;
}

// TODO: Check for WAL files existence
for (const [i, sql] of migrationEntries) {
if (i > currentVersion) {
try {
this.database.transaction(() => {
this.database.run(sql);
this.database.run(`PRAGMA user_version = ${i}`);
})();
} catch (error) {
logger.error(error);
logger.error(`Error running migration "${map[i]}", database reverted to a prior state.`);

shutdown(1);
return;
}

logger.info(`Database migration "${map[i]}" ran successfully.`);
}
}
}
}
42 changes: 42 additions & 0 deletions src/database/migrations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export enum map {
initialConfig_0001 = 1,
initial_0002 = 2
}

/**
* @remarks Keep the migrations in reverse order by their version number.
*/
export const migrations: { [migration: number]: string } = {
/**
* @experimental May be subject to change
*/
[map.initial_0002]: `
CREATE TABLE user
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT,
created_at TEXT,
accessed_at TEXT
) STRICT;

CREATE TABLE document
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
version INTEGER,
name TEXT,
password TEXT,
created_at TEXT,
accessed_at TEXT,
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
) STRICT;
`,

/**
* @experimental May be subject to change
*/
[map.initialConfig_0001]: `
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
`
} as const;
12 changes: 12 additions & 0 deletions src/database/query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { database } from '../server.ts';

// TODO: Remove this when other queries are implemented
export const databaseVersion = {
get: (): number | undefined => {
return database.instance.prepare<{ user_version: number }, null>('PRAGMA user_version').get(null)?.user_version;
},

set: (version: number): void => {
database.instance.run(`PRAGMA user_version = ${version}`);
}
};
27 changes: 13 additions & 14 deletions src/document/validator.ts → src/document/assert.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { validator } from '@x-util/validator.ts';
import { config } from '../config.ts';
import { errorHandler } from '../server/errorHandler.ts';
import type { Document } from '../types/Document.ts';
import { ErrorCode } from '../types/ErrorHandler.ts';
import { ValidatorUtils } from '../utils/ValidatorUtils.ts';
import { crypto } from './crypto.ts';

export const validator = {
validateName: (name: string): void => {
export const assert = {
name: (name: string): void => {
if (
!ValidatorUtils.isValidBase64URL(name) ||
!ValidatorUtils.isLengthWithinRange(
!validator.isBase64URL(name) ||
!validator.isLengthWithinRange(
Bun.stringWidth(name),
config.documentNameLengthMin,
config.documentNameLengthMax
@@ -19,39 +19,38 @@ export const validator = {
}
},

validateNameLength: (length: number | undefined): void => {
nameLength: (length?: number): void => {
if (
length &&
!ValidatorUtils.isLengthWithinRange(length, config.documentNameLengthMin, config.documentNameLengthMax)
!validator.isLengthWithinRange(length, config.documentNameLengthMin, config.documentNameLengthMax)
) {
errorHandler.send(ErrorCode.documentInvalidNameLength);
}
},

validatePassword: (password: string, dataHash: Document['header']['passwordHash']): void => {
password: (password: string, dataHash: Document['header']['passwordHash']): void => {
if (dataHash && !crypto.compare(password, dataHash)) {
errorHandler.send(ErrorCode.documentInvalidPassword);
}
},

validatePasswordLength: (password: string | undefined): void => {
passwordLength: (password?: string): void => {
if (
password &&
(ValidatorUtils.isEmptyString(password) ||
!ValidatorUtils.isLengthWithinRange(Bun.stringWidth(password), 1, 255))
(validator.isEmptyString(password) || !validator.isLengthWithinRange(Bun.stringWidth(password), 1, 255))
) {
errorHandler.send(ErrorCode.documentInvalidPasswordLength);
}
},

validateSecret: (secret: string, secretHash: Document['header']['secretHash']): void => {
secret: (secret: string, secretHash: Document['header']['secretHash']): void => {
if (!crypto.compare(secret, secretHash)) {
errorHandler.send(ErrorCode.documentInvalidSecret);
}
},

validateSecretLength: (secret: string): void => {
if (!ValidatorUtils.isLengthWithinRange(Bun.stringWidth(secret), 1, 255)) {
secretLength: (secret: string): void => {
if (!validator.isLengthWithinRange(Bun.stringWidth(secret), 1, 255)) {
errorHandler.send(ErrorCode.documentInvalidSecretLength);
}
}
Loading