Compare commits

...
10 Commits
Author SHA1 Message Date
Justin Lin ae790dc6a2 feat: create migration script 2026-10-12 10:26:29 +11:00
Justin Lin 4ed8358738 feat: create model 2026-10-11 19:30:38 +11:00
Justin Lin ba5026fa56 feat: init database package 2026-10-11 18:21:20 +11:00
Justin Lin 4cf79a164c infra: add postgres and adminer to development docker compose 2026-10-11 18:11:20 +11:00
Justin Lin e449f9987b Bun migration (#218) 2026-10-11 17:57:47 +11:00
Justin Lin 419440a14f doc: remove NodeJS installation setup in Windows setup 2026-10-11 17:51:56 +11:00
Justin Lin 54225528b3 refactor: replace Jest with bun 2026-10-11 17:43:22 +11:00
Justin Lin 634dc8f987 refactor: use bun in Dockerfile and CI workflow 2026-10-11 17:24:12 +11:00
Justin Lin 77577d9b8d refactor: use bun to execute server and analytics 2026-10-11 17:04:46 +11:00
Justin Lin 2feedc329c refactor: use bun for package management
+ add package.json to each packages
+ update import path
2026-10-11 16:50:01 +11:00
106 changed files with 10063 additions and 9198 deletions

No files matched your search

+2 -1
View File
@@ -2,4 +2,5 @@
.git
**/dist
**/tsconfig.tsbuildinfo
data
data
postgres-data
+2 -1
View File
@@ -2,4 +2,5 @@ vite.config.js
.gitignore
data
mongo-init.js
dist
dist
postgres-data
+3 -1
View File
@@ -2,7 +2,9 @@ module.exports = {
root: true,
env: {
node: true,
jest: true,
},
globals: {
Bun: 'readonly',
},
plugins: ['@typescript-eslint', 'cypress', 'vue'],
extends: [
+9 -6
View File
@@ -11,18 +11,21 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: oven-sh/setup-bun@v2
with:
node-version: 20
bun-version: 1.3.14
- name: Install dependencies
run: yarn install
run: bun install --frozen-lockfile
- name: Type check
run: bun run type-check
- name: Build
run: yarn build
run: bun run build
- name: Lint
run: yarn lint
run: bun run lint
- name: Test
run: yarn test
run: bun run test
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
run: docker-compose -f docker-compose.testing.yml build base backend app-test cypress
- name: Build Test Quests
run: docker-compose -f docker-compose.testing.yml run --rm backend yarn build:quests
run: docker-compose -f docker-compose.testing.yml run --rm backend bun run build:quests
- name: Run Cypress Tests
env:
+2 -1
View File
@@ -1,4 +1,4 @@
config
config
.quest
tmp
services/terminal-service/terminal-service
@@ -25,6 +25,7 @@ pids
*.seed
*.pid.lock
data
postgres-data
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
+4 -4
View File
@@ -4,10 +4,10 @@
{
"type": "node",
"request": "launch",
"name": "File Graph Yarn Jest",
"runtimeExecutable": "yarn",
"args": ["test", "--runInBand"],
"cwd": "${workspaceFolder}/packages/file-graph",
"name": "File Graph Tests",
"runtimeExecutable": "bun",
"args": ["test", "packages/file-graph"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
+11 -12
View File
@@ -5,16 +5,15 @@
## TLDR
yarn install
bun install
cp .env.sample .env
docker compose up -d
yarn dev
bun run dev
## Requirements
- NodeJS v20+
- Bun: v1.3+
- Docker
- Yarn: v1.22
## Known Issues
@@ -26,7 +25,7 @@ Windows user please refer to [docs/windows-setup.md](docs/windows-setup.md) for
git clone https://github.com/lancatlin/linux-odyssey.git
cd linux-odyssey
yarn install
bun install
## Project Structures
@@ -104,7 +103,7 @@ Customized `entrypoint.sh` for each quest is not yet supported.
To build all quests:
yarn build:quests
bun run build:quests
The base image is built with docker-compose, so you can use `docker compose build base` to rebuild it. Once you rebuild the base image, you have to rebuild all quests.
@@ -134,11 +133,11 @@ Copy `.env.sample` to `.env` and customize it for your needs.
Run dev server (frontend, backend):
yarn dev
bun run dev
To develop analytics dashboard:
yarn analytics
bun run analytics
The default username and password is `alex` and `Alex1234`.
@@ -146,12 +145,12 @@ The default username and password is `alex` and `Alex1234`.
Unit tests:
yarn test:unit
bun run test
Run Cypress locally, using the current development containers:
docker compose up -d
yarn test:e2e
bun run test:e2e
Run Cypress in container, create brand-new containers along with it:
@@ -183,7 +182,7 @@ Start the service
# Server side
docker compose -f docker-compose.prod.yml pull
yarn build:quests
bun run build:quests
docker compose -f docker-compose.prod.yml up -d
### Necessary Configuration
@@ -197,4 +196,4 @@ You can copy from `.env.sample`
You can generate a random key by running
yarn setup
bun run setup
+11 -4
View File
@@ -1,17 +1,24 @@
# Base stage
FROM node:20-alpine AS build
FROM oven/bun:1.3.14-alpine AS build
WORKDIR /app
COPY package.json yarn.lock ./
COPY package.json bun.lock ./
COPY app/package.json ./app/package.json
COPY server/package.json ./server/package.json
RUN yarn install --frozen-lockfile
COPY services/analytics/package.json ./services/analytics/package.json
COPY tests/package.json ./tests/package.json
COPY packages/constants/package.json ./packages/constants/package.json
COPY packages/file-graph/package.json ./packages/file-graph/package.json
COPY packages/game/package.json ./packages/game/package.json
COPY packages/models/package.json ./packages/models/package.json
COPY packages/utils/package.json ./packages/utils/package.json
RUN bun install --frozen-lockfile --filter app
COPY tsconfig.json ./tsconfig.json
COPY server ./server
COPY packages ./packages
COPY app ./app
RUN yarn workspace app build
RUN bun run --filter app build
# Production stage
FROM nginx:stable-alpine AS prod
+4
View File
@@ -16,6 +16,10 @@
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/vue-fontawesome": "^3.0.3",
"@odyssey/constants": "workspace:*",
"@odyssey/file-graph": "workspace:*",
"@odyssey/game": "workspace:*",
"@odyssey/utils": "workspace:*",
"@trpc/client": "^11.1.0",
"@types/dompurify": "^3.0.5",
"axios": "^1.20.0",
+1 -1
View File
@@ -2,7 +2,7 @@
import { useI18n } from 'vue-i18n'
import { ref, onMounted, watch, computed } from 'vue'
import { useToast } from 'vue-toastification'
import { DAG } from '../../../packages/utils'
import { DAG } from '@odyssey/utils'
import { trpc } from '../utils/trpc'
import useUserProfile from '../store/userProfile'
import QuestIntro from './QuestIntro.vue'
+1 -1
View File
@@ -2,10 +2,10 @@
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import type { QuestLocale } from '@odyssey/constants'
import { useSession } from '../../store/session'
import { openBugReport, openQuestSurvey } from '../../utils/formbricks'
import { trpc } from '../../utils/trpc'
import type { QuestLocale } from '../../../../packages/constants'
const { t, locale } = useI18n()
const toast = useToast()
@@ -1,11 +1,8 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { QuestLocale } from '@odyssey/constants'
import { SUPPORTED_LOCALES, LOCALE_NAME_MAP } from '@odyssey/constants'
import { setLocale } from '../../i18n'
import type { QuestLocale } from '../../../../packages/constants'
import {
SUPPORTED_LOCALES,
LOCALE_NAME_MAP,
} from '../../../../packages/constants'
const { t, locale } = useI18n({ useScope: 'global' })
+2 -2
View File
@@ -1,6 +1,6 @@
import { createI18n } from 'vue-i18n'
import type { QuestLocale } from '../../../packages/constants'
import { SUPPORTED_LOCALES } from '../../../packages/constants'
import type { QuestLocale } from '@odyssey/constants'
import { SUPPORTED_LOCALES } from '@odyssey/constants'
import en from './locales/en.json'
import zhTW from './locales/zh-TW.json'
+3 -6
View File
@@ -1,10 +1,7 @@
import { defineStore } from 'pinia'
import type { QuestLocale } from '../../../packages/constants'
import {
FileGraph,
type FileGraphUpdateEvent,
} from '../../../packages/file-graph'
import type { IResponse, ITask } from '../../../packages/game'
import type { QuestLocale } from '@odyssey/constants'
import { FileGraph, type FileGraphUpdateEvent } from '@odyssey/file-graph'
import type { IResponse, ITask } from '@odyssey/game'
import type { SessionDetail } from '../../../server/src/routers/sessionRouter'
import Socket from '../utils/socket'
import SocketTerminal from '../utils/terminal'
+2 -2
View File
@@ -1,5 +1,5 @@
import type { FileGraph } from '../../packages/file-graph'
import type { IResponse, ITask } from '../../packages/game'
import type { FileGraph } from '@odyssey/file-graph'
import type { IResponse, ITask } from '@odyssey/game'
export interface Task {
id: number
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { isValidUsername } from '../../../packages/utils'
import { isValidUsername } from '@odyssey/utils'
import AuthForm from '../components/AuthForm.vue'
import DynamicBackground from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue'
+2 -2
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import validator from 'validator'
import { isValidUsername } from '../../../packages/utils'
import { passwordPolicy } from '../../../packages/constants'
import { isValidUsername } from '@odyssey/utils'
import { passwordPolicy } from '@odyssey/constants'
import AuthForm from '../components/AuthForm.vue'
import DynamicBackground from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue'
-10
View File
@@ -1,19 +1,9 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@game': path.resolve(__dirname, '../packages/game'),
'@models': path.resolve(__dirname, '../packages/models'),
'@utils': path.resolve(__dirname, '../packages/utils'),
'@constants': path.resolve(__dirname, '../packages/constants'),
'@file-graph': path.resolve(__dirname, '../packages/file-graph'),
},
},
server: {
host: '0.0.0.0',
proxy: {
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
}
+3172
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
[test]
coverage = true
coverageDir = "coverage"
+21 -1
View File
@@ -22,7 +22,6 @@ services:
db:
image: mongo:6
restart: always
networks:
- internal
ports:
@@ -30,6 +29,26 @@ services:
volumes:
- ./data:/data/db
postgres:
image: postgres:18.6
networks:
- internal
ports:
- 5432:5432
volumes:
- ./postgres-data:/var/lib/postgresql
environment:
POSTGRES_USER: commander
POSTGRES_DB: odyssey-dev
POSTGRES_PASSWORD: mysecretpassword
adminer:
image: adminer
networks:
- internal
ports:
- 18080:8080
base:
image: linuxodyssey/quest-base
networks:
@@ -37,6 +56,7 @@ services:
build:
context: .
dockerfile: ./quests/Dockerfile
command: exit 0
extra_hosts:
- 'host.docker.internal:host-gateway'
labels:
+9 -15
View File
@@ -74,29 +74,23 @@ sudo apt update
sudo apt install -y git curl ca-certificates build-essential
```
### Install Node.js 20
### Install Bun
```bash
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
curl -fsSL https://bun.sh/install | bash
```
### Install Yarn 1.22
```bash
sudo npm install -g yarn@1.22.22
```
Restart your terminal (or run `source ~/.bashrc`) so `bun` is on your `PATH`.
Verify the installations:
```bash
git --version
node --version
npm --version
yarn --version
bun --version
```
The Node.js version should be `20.x` or later, and the Yarn version should be `1.22.x`.
The Node.js version should be `20.x` or later, and the Bun version should be `1.3` or later.
## 4. Clone the Project
@@ -130,10 +124,10 @@ code .
From the project root, run:
```bash
yarn install
bun install
```
Warnings during installation may be harmless. The installation succeeded if Yarn finishes with a `Done` message and does not display `error Command failed`.
Warnings during installation may be harmless. The installation succeeded if Bun finishes with an `installed` message and does not display an `error:` message.
If a `gyp` error appears, confirm that it belongs to an optional dependency before ignoring it.
@@ -180,7 +174,7 @@ docker compose down
Start the frontend and backend development servers:
```bash
yarn dev
bun run dev
```
Keep the terminal running and open the following address in your browser:
@@ -194,5 +188,5 @@ To stop the development servers, press `Ctrl+C`.
You can verify that the project builds successfully by running:
```bash
yarn build
bun run build
```
-201
View File
@@ -1,201 +0,0 @@
/**
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*/
import type { Config } from 'jest'
const config: Config = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/tmp/jest_rs",
// Automatically clear mock calls, instances, contexts and results before every test
// clearMocks: false,
// Indicates whether the coverage information should be collected while executing the test
collectCoverage: true,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
coverageDirectory: 'coverage',
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// Indicates which provider should be used to instrument code for coverage
coverageProvider: 'v8',
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// The default configuration for fake timers
// fakeTimers: {
// "enableGlobally": false
// },
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "mjs",
// "cjs",
// "jsx",
// "ts",
// "tsx",
// "json",
// "node"
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1', // Redirect .js imports to .ts files
},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: undefined,
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state before every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state and implementation before every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
// testEnvironment: "jest-environment-node",
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",
// A map from regular expressions to paths to transformers
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
}
export default config
+18 -25
View File
@@ -13,31 +13,27 @@
"tests"
],
"scripts": {
"app": "yarn workspace app dev",
"build": "yarn workspace server build && yarn workspace app build && yarn workspace analytics build",
"dev": "concurrently -n \"App,Server\" -c \"bgBlue.bold,bgGreen.bold\" \"yarn workspace app dev\" \"yarn workspace server dev\"",
"server": "yarn workspace server dev",
"app": "bun run --filter app dev",
"build": "bun run --filter app build",
"dev": "concurrently -n \"App,Server\" -c \"bgBlue.bold,bgGreen.bold\" \"bun run --filter app dev\" \"bun run --filter server dev\"",
"server": "bun run --filter server dev",
"lint": "eslint .",
"format": "prettier --ignore-path .prettierignore --write app server",
"test": "yarn node --experimental-vm-modules --no-warnings $(yarn bin jest)",
"test:e2e": "yarn workspace tests test",
"test:open": "yarn workspace tests test:open",
"analytics": "yarn workspace analytics dev",
"setup": "yarn workspace server setup",
"clean": "yarn workspace server clean",
"build:quests": "yarn workspace server build:quests"
"test": "bun test",
"test:e2e": "bun run --filter tests test",
"test:open": "bun run --filter tests test:open",
"analytics": "bun run --filter analytics dev",
"setup": "bun run --filter server setup",
"clean": "bun run --filter server clean",
"build:quests": "bun run --filter server build:quests",
"type-check": "bun run --filter server --filter analytics --filter app type-check"
},
"devDependencies": {
"@babel/core": "^7.29.6",
"@babel/preset-env": "^7.24.0",
"@babel/preset-typescript": "^7.23.3",
"@jest/globals": "^29.7.0",
"@types/jest": "^29.5.12",
"@types/bun": "^1.4.2",
"@types/node": "^20.10.4",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vue/eslint-config-prettier": "^7.1.0",
"babel-jest": "^29.7.0",
"concurrently": "^8.2.2",
"eslint": "^8.56.0",
"eslint-config-airbnb-base": "^15.0.0",
@@ -48,16 +44,13 @@
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-vue": "^9.15.1",
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.3",
"vue-eslint-parser": "^9.4.2",
"zod-to-json-schema": "^3.24.5"
"vue-eslint-parser": "^9.4.2"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"resolutions": {
"**/@mapbox/node-pre-gyp/tar": "^7.5.19",
"**/@cypress/request/form-data": "^2.5.4"
"packageManager": "bun@1.3.14",
"overrides": {
"@mapbox/node-pre-gyp/tar": "^7.5.19",
"@cypress/request/form-data": "^2.5.4"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@odyssey/constants",
"version": "0.0.0",
"private": true,
"main": "index.ts",
"types": "index.ts",
"exports": {
".": "./index.ts"
},
"dependencies": {
"zod": "^3.24.2"
}
}
+5
View File
@@ -0,0 +1,5 @@
# Copy this file to `.env` and replace the placeholder with your real connection string.
# Connection string for PostgreSQL.
# Requires PostgreSQL >= 15.
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
+6
View File
@@ -0,0 +1,6 @@
src/prisma/contract.json linguist-generated
src/prisma/contract.d.ts linguist-generated
src/prisma/ops.json linguist-generated
src/prisma/migration.json linguist-generated
migrations/snapshots/**/contract.json linguist-generated
migrations/snapshots/**/contract.d.ts linguist-generated
+36
View File
@@ -0,0 +1,36 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
node_modules/
dist/
+15
View File
@@ -0,0 +1,15 @@
# @odyssey/database
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.3.14. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
@@ -0,0 +1,7 @@
{
"from": null,
"to": "6fecb00f7a40489f8c1a9a4dad506cc6d4b95789cbb32519634132f006530c04",
"providedInvariants": [],
"createdAt": "2026-10-11T08:21:15.726Z",
"migrationHash": "99b3a1febb9f739bf258c99ebe1dbfe692b86792e8eb511fc7eef8e985e8e8cd"
}
@@ -0,0 +1,268 @@
#!/usr/bin/env -S node
import type { Contract as End } from '../../snapshots/6fecb00f7a40489f8c1a9a4dad506cc6d4b95789cbb32519634132f006530c04/contract';
import endContract from '../../snapshots/6fecb00f7a40489f8c1a9a4dad506cc6d4b95789cbb32519634132f006530c04/contract.json' with { type: 'json' };
import {
Migration,
MigrationCLI,
checkExpression,
col,
primaryKey,
} from '@prisma/orm-postgres/migration';
export default class M extends Migration<never, End> {
override readonly endContractJson = endContract;
override get operations() {
return [
this.createSchema({ schema: 'public' }),
this.createTable({
schema: 'public',
table: 'Command',
columns: [
col('command', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
col('error', 'text', { codecRef: { codecId: 'pg/text@1' } }),
col('id', 'BIGSERIAL', { notNull: true, codecRef: { codecId: 'pg/int8@1' } }),
col('output', 'text', { codecRef: { codecId: 'pg/text@1' } }),
col('pwd', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
col('sessionId', 'int8', { notNull: true, codecRef: { codecId: 'pg/int8@1' } }),
col('stage', 'text', { codecRef: { codecId: 'pg/text@1' } }),
col('timestamp', 'timestamptz', {
notNull: true,
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
}),
],
constraints: [primaryKey(['id'])],
}),
this.createTable({
schema: 'public',
table: 'LoginAttempt',
columns: [
col('id', 'BIGSERIAL', { notNull: true, codecRef: { codecId: 'pg/int8@1' } }),
col('ip', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
col('loginMethod', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
col('message', 'text', { codecRef: { codecId: 'pg/text@1' } }),
col('success', 'bool', { notNull: true, codecRef: { codecId: 'pg/bool@1' } }),
col('time', 'timestamptz', {
notNull: true,
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
}),
col('userAgent', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
col('userId', 'int8', { codecRef: { codecId: 'pg/int8@1' } }),
col('username', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
],
constraints: [
primaryKey(['id']),
checkExpression(
'LoginAttempt_loginMethod_check_f8485f11',
`"loginMethod" IN ('password', 'google', 'github')`,
),
],
}),
this.createTable({
schema: 'public',
table: 'Session',
columns: [
col('completedEvents', 'text[]', {
notNull: true,
codecRef: { codecId: 'pg/text@1', many: true },
}),
col('containerId', 'text', { codecRef: { codecId: 'pg/text@1' } }),
col('containerName', 'text', { codecRef: { codecId: 'pg/text@1' } }),
col('createdAt', 'timestamptz', {
notNull: true,
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
}),
col('finishedAt', 'timestamptz', { codecRef: { codecId: 'pg/timestamptz-temporal@1' } }),
col('graph', 'jsonb', { notNull: true, codecRef: { codecId: 'pg/jsonb@1' } }),
col('id', 'BIGSERIAL', { notNull: true, codecRef: { codecId: 'pg/int8@1' } }),
col('lastActivityAt', 'timestamptz', {
notNull: true,
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
}),
col('locale', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
col('quest', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
col('status', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
col('userId', 'int8', { notNull: true, codecRef: { codecId: 'pg/int8@1' } }),
],
constraints: [
primaryKey(['id']),
checkExpression(
'Session_completedEvents_elem_not_null_b55d23f4',
'array_position("completedEvents", NULL) IS NULL',
),
checkExpression(
'Session_status_check_8fa5bbc6',
`"status" IN ('active', 'finished', 'inactive')`,
),
],
}),
this.createTable({
schema: 'public',
table: 'User',
columns: [
col('createdAt', 'timestamptz', {
notNull: true,
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
}),
col('email', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
col('id', 'BIGSERIAL', { notNull: true, codecRef: { codecId: 'pg/int8@1' } }),
col('isGuest', 'bool', { notNull: true, codecRef: { codecId: 'pg/bool@1' } }),
col('name', 'text', { codecRef: { codecId: 'pg/text@1' } }),
col('updatedAt', 'timestamptz', {
notNull: true,
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
}),
col('username', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
],
constraints: [primaryKey(['id'])],
}),
this.createTable({
schema: 'public',
table: 'UserLoginMethod',
columns: [
col('createdAt', 'timestamptz', {
notNull: true,
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
}),
col('displayName', 'text', { codecRef: { codecId: 'pg/text@1' } }),
col('hashedPassword', 'text', { codecRef: { codecId: 'pg/text@1' } }),
col('oauthId', 'text', { codecRef: { codecId: 'pg/text@1' } }),
col('type', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
col('updatedAt', 'timestamptz', {
notNull: true,
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
}),
col('userId', 'int8', { notNull: true, codecRef: { codecId: 'pg/int8@1' } }),
],
constraints: [
primaryKey(['userId', 'type']),
checkExpression(
'UserLoginMethod_type_check_6814314f',
`"type" IN ('password', 'google', 'github')`,
),
],
}),
this.createTable({
schema: 'public',
table: 'UserProgress',
columns: [
col('finishedAt', 'timestamptz', { codecRef: { codecId: 'pg/timestamptz-temporal@1' } }),
col('quest', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
col('startedAt', 'timestamptz', {
notNull: true,
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
}),
col('userId', 'int8', { notNull: true, codecRef: { codecId: 'pg/int8@1' } }),
],
constraints: [primaryKey(['userId', 'quest'])],
}),
this.addUnique({
schema: 'public',
table: 'User',
constraint: 'User_email_key',
columns: ['email'],
}),
this.addUnique({
schema: 'public',
table: 'User',
constraint: 'User_username_key',
columns: ['username'],
}),
this.addUnique({
schema: 'public',
table: 'UserLoginMethod',
constraint: 'UserLoginMethod_type_oauthId_key',
columns: ['type', 'oauthId'],
}),
this.createIndex({
schema: 'public',
table: 'Command',
index: 'Command_sessionId_idx_29f415d4',
columns: ['sessionId'],
}),
this.createIndex({
schema: 'public',
table: 'LoginAttempt',
index: 'LoginAttempt_userId_idx_a489d58a',
columns: ['userId'],
}),
this.createIndex({
schema: 'public',
table: 'Session',
index: 'Session_userId_idx_a489d58a',
columns: ['userId'],
}),
this.createIndex({
schema: 'public',
table: 'Session',
index: 'Session_userId_quest_idx_b13626bf',
columns: ['userId', 'quest'],
}),
this.createIndex({
schema: 'public',
table: 'Session',
index: 'Session_userId_status_idx_e4a128ba',
columns: ['userId', 'status'],
}),
this.createIndex({
schema: 'public',
table: 'UserLoginMethod',
index: 'UserLoginMethod_userId_idx_a489d58a',
columns: ['userId'],
}),
this.createIndex({
schema: 'public',
table: 'UserProgress',
index: 'UserProgress_userId_idx_a489d58a',
columns: ['userId'],
}),
this.addForeignKey({
schema: 'public',
table: 'Command',
foreignKey: {
name: 'Command_sessionId_fkey',
columns: ['sessionId'],
references: { schema: 'public', table: 'Session', columns: ['id'] },
},
}),
this.addForeignKey({
schema: 'public',
table: 'LoginAttempt',
foreignKey: {
name: 'LoginAttempt_userId_fkey',
columns: ['userId'],
references: { schema: 'public', table: 'User', columns: ['id'] },
},
}),
this.addForeignKey({
schema: 'public',
table: 'Session',
foreignKey: {
name: 'Session_userId_fkey',
columns: ['userId'],
references: { schema: 'public', table: 'User', columns: ['id'] },
},
}),
this.addForeignKey({
schema: 'public',
table: 'UserLoginMethod',
foreignKey: {
name: 'UserLoginMethod_userId_fkey',
columns: ['userId'],
references: { schema: 'public', table: 'User', columns: ['id'] },
},
}),
this.addForeignKey({
schema: 'public',
table: 'UserProgress',
foreignKey: {
name: 'UserProgress_userId_fkey',
columns: ['userId'],
references: { schema: 'public', table: 'User', columns: ['id'] },
},
}),
];
}
}
MigrationCLI.run(import.meta.url, M);
@@ -0,0 +1,862 @@
[
{
"id": "schema.public",
"label": "Create schema \"public\"",
"operationClass": "additive",
"target": {
"id": "postgres"
},
"precheck": [],
"execute": [
{
"description": "Create schema \"public\"",
"sql": "CREATE SCHEMA IF NOT EXISTS \"public\"",
"params": []
}
],
"postcheck": []
},
{
"id": "table.Command",
"label": "Create table \"Command\"",
"summary": "Creates table \"Command\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "table",
"name": "Command"
}
},
"precheck": [
{
"description": "ensure table \"Command\" does not exist",
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
"params": [
"\"public\".\"Command\""
]
}
],
"execute": [
{
"description": "create table \"Command\"",
"sql": "CREATE TABLE \"public\".\"Command\" (\n \"command\" text NOT NULL,\n \"error\" text,\n \"id\" BIGSERIAL NOT NULL,\n \"output\" text,\n \"pwd\" text NOT NULL,\n \"sessionId\" int8 NOT NULL,\n \"stage\" text,\n \"timestamp\" timestamptz NOT NULL,\n PRIMARY KEY (\"id\")\n)",
"params": []
}
],
"postcheck": [
{
"description": "verify table \"Command\" exists",
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
"params": [
"\"public\".\"Command\""
]
}
]
},
{
"id": "table.LoginAttempt",
"label": "Create table \"LoginAttempt\"",
"summary": "Creates table \"LoginAttempt\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "table",
"name": "LoginAttempt"
}
},
"precheck": [
{
"description": "ensure table \"LoginAttempt\" does not exist",
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
"params": [
"\"public\".\"LoginAttempt\""
]
}
],
"execute": [
{
"description": "create table \"LoginAttempt\"",
"sql": "CREATE TABLE \"public\".\"LoginAttempt\" (\n \"id\" BIGSERIAL NOT NULL,\n \"ip\" text NOT NULL,\n \"loginMethod\" text NOT NULL,\n \"message\" text,\n \"success\" bool NOT NULL,\n \"time\" timestamptz NOT NULL,\n \"userAgent\" text NOT NULL,\n \"userId\" int8,\n \"username\" text NOT NULL,\n PRIMARY KEY (\"id\"),\n CONSTRAINT \"LoginAttempt_loginMethod_check_f8485f11\" CHECK (\"loginMethod\" IN ('password', 'google', 'github'))\n)",
"params": []
}
],
"postcheck": [
{
"description": "verify table \"LoginAttempt\" exists",
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
"params": [
"\"public\".\"LoginAttempt\""
]
}
]
},
{
"id": "table.Session",
"label": "Create table \"Session\"",
"summary": "Creates table \"Session\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "table",
"name": "Session"
}
},
"precheck": [
{
"description": "ensure table \"Session\" does not exist",
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
"params": [
"\"public\".\"Session\""
]
}
],
"execute": [
{
"description": "create table \"Session\"",
"sql": "CREATE TABLE \"public\".\"Session\" (\n \"completedEvents\" text[] NOT NULL,\n \"containerId\" text,\n \"containerName\" text,\n \"createdAt\" timestamptz NOT NULL,\n \"finishedAt\" timestamptz,\n \"graph\" jsonb NOT NULL,\n \"id\" BIGSERIAL NOT NULL,\n \"lastActivityAt\" timestamptz NOT NULL,\n \"locale\" text NOT NULL,\n \"quest\" text NOT NULL,\n \"status\" text NOT NULL,\n \"userId\" int8 NOT NULL,\n PRIMARY KEY (\"id\"),\n CONSTRAINT \"Session_completedEvents_elem_not_null_b55d23f4\" CHECK (array_position(\"completedEvents\", NULL) IS NULL),\n CONSTRAINT \"Session_status_check_8fa5bbc6\" CHECK (\"status\" IN ('active', 'finished', 'inactive'))\n)",
"params": []
}
],
"postcheck": [
{
"description": "verify table \"Session\" exists",
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
"params": [
"\"public\".\"Session\""
]
}
]
},
{
"id": "table.User",
"label": "Create table \"User\"",
"summary": "Creates table \"User\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "table",
"name": "User"
}
},
"precheck": [
{
"description": "ensure table \"User\" does not exist",
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
"params": [
"\"public\".\"User\""
]
}
],
"execute": [
{
"description": "create table \"User\"",
"sql": "CREATE TABLE \"public\".\"User\" (\n \"createdAt\" timestamptz NOT NULL,\n \"email\" text NOT NULL,\n \"id\" BIGSERIAL NOT NULL,\n \"isGuest\" bool NOT NULL,\n \"name\" text,\n \"updatedAt\" timestamptz NOT NULL,\n \"username\" text NOT NULL,\n PRIMARY KEY (\"id\")\n)",
"params": []
}
],
"postcheck": [
{
"description": "verify table \"User\" exists",
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
"params": [
"\"public\".\"User\""
]
}
]
},
{
"id": "table.UserLoginMethod",
"label": "Create table \"UserLoginMethod\"",
"summary": "Creates table \"UserLoginMethod\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "table",
"name": "UserLoginMethod"
}
},
"precheck": [
{
"description": "ensure table \"UserLoginMethod\" does not exist",
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
"params": [
"\"public\".\"UserLoginMethod\""
]
}
],
"execute": [
{
"description": "create table \"UserLoginMethod\"",
"sql": "CREATE TABLE \"public\".\"UserLoginMethod\" (\n \"createdAt\" timestamptz NOT NULL,\n \"displayName\" text,\n \"hashedPassword\" text,\n \"oauthId\" text,\n \"type\" text NOT NULL,\n \"updatedAt\" timestamptz NOT NULL,\n \"userId\" int8 NOT NULL,\n PRIMARY KEY (\"userId\", \"type\"),\n CONSTRAINT \"UserLoginMethod_type_check_6814314f\" CHECK (\"type\" IN ('password', 'google', 'github'))\n)",
"params": []
}
],
"postcheck": [
{
"description": "verify table \"UserLoginMethod\" exists",
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
"params": [
"\"public\".\"UserLoginMethod\""
]
}
]
},
{
"id": "table.UserProgress",
"label": "Create table \"UserProgress\"",
"summary": "Creates table \"UserProgress\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "table",
"name": "UserProgress"
}
},
"precheck": [
{
"description": "ensure table \"UserProgress\" does not exist",
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
"params": [
"\"public\".\"UserProgress\""
]
}
],
"execute": [
{
"description": "create table \"UserProgress\"",
"sql": "CREATE TABLE \"public\".\"UserProgress\" (\n \"finishedAt\" timestamptz,\n \"quest\" text NOT NULL,\n \"startedAt\" timestamptz NOT NULL,\n \"userId\" int8 NOT NULL,\n PRIMARY KEY (\"userId\", \"quest\")\n)",
"params": []
}
],
"postcheck": [
{
"description": "verify table \"UserProgress\" exists",
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
"params": [
"\"public\".\"UserProgress\""
]
}
]
},
{
"id": "unique.User.User_email_key",
"label": "Add unique constraint on \"User\" (email)",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "unique",
"name": "User_email_key",
"table": "User"
}
},
"precheck": [
{
"description": "ensure constraint \"User_email_key\" does not exist",
"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
"params": [
"User_email_key",
"public",
"\"public\".\"User\""
]
}
],
"execute": [
{
"description": "add unique constraint \"User_email_key\"",
"sql": "ALTER TABLE \"public\".\"User\" ADD CONSTRAINT \"User_email_key\" UNIQUE (\"email\")"
}
],
"postcheck": [
{
"description": "verify constraint \"User_email_key\" exists",
"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
"params": [
"User_email_key",
"public",
"\"public\".\"User\""
]
}
]
},
{
"id": "unique.User.User_username_key",
"label": "Add unique constraint on \"User\" (username)",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "unique",
"name": "User_username_key",
"table": "User"
}
},
"precheck": [
{
"description": "ensure constraint \"User_username_key\" does not exist",
"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
"params": [
"User_username_key",
"public",
"\"public\".\"User\""
]
}
],
"execute": [
{
"description": "add unique constraint \"User_username_key\"",
"sql": "ALTER TABLE \"public\".\"User\" ADD CONSTRAINT \"User_username_key\" UNIQUE (\"username\")"
}
],
"postcheck": [
{
"description": "verify constraint \"User_username_key\" exists",
"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
"params": [
"User_username_key",
"public",
"\"public\".\"User\""
]
}
]
},
{
"id": "unique.UserLoginMethod.UserLoginMethod_type_oauthId_key",
"label": "Add unique constraint on \"UserLoginMethod\" (type, oauthId)",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "unique",
"name": "UserLoginMethod_type_oauthId_key",
"table": "UserLoginMethod"
}
},
"precheck": [
{
"description": "ensure constraint \"UserLoginMethod_type_oauthId_key\" does not exist",
"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
"params": [
"UserLoginMethod_type_oauthId_key",
"public",
"\"public\".\"UserLoginMethod\""
]
}
],
"execute": [
{
"description": "add unique constraint \"UserLoginMethod_type_oauthId_key\"",
"sql": "ALTER TABLE \"public\".\"UserLoginMethod\" ADD CONSTRAINT \"UserLoginMethod_type_oauthId_key\" UNIQUE (\"type\", \"oauthId\")"
}
],
"postcheck": [
{
"description": "verify constraint \"UserLoginMethod_type_oauthId_key\" exists",
"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
"params": [
"UserLoginMethod_type_oauthId_key",
"public",
"\"public\".\"UserLoginMethod\""
]
}
]
},
{
"id": "index.Command.Command_sessionId_idx_29f415d4",
"label": "Create index \"Command_sessionId_idx_29f415d4\" on \"Command\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "index",
"name": "Command_sessionId_idx_29f415d4",
"table": "Command"
}
},
"precheck": [
{
"description": "ensure index \"Command_sessionId_idx_29f415d4\" does not exist",
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
"params": [
"\"public\".\"Command_sessionId_idx_29f415d4\""
]
}
],
"execute": [
{
"description": "create index \"Command_sessionId_idx_29f415d4\"",
"sql": "CREATE INDEX \"Command_sessionId_idx_29f415d4\" ON \"public\".\"Command\" (\"sessionId\")",
"params": []
}
],
"postcheck": [
{
"description": "verify index \"Command_sessionId_idx_29f415d4\" exists",
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
"params": [
"\"public\".\"Command_sessionId_idx_29f415d4\""
]
}
]
},
{
"id": "index.LoginAttempt.LoginAttempt_userId_idx_a489d58a",
"label": "Create index \"LoginAttempt_userId_idx_a489d58a\" on \"LoginAttempt\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "index",
"name": "LoginAttempt_userId_idx_a489d58a",
"table": "LoginAttempt"
}
},
"precheck": [
{
"description": "ensure index \"LoginAttempt_userId_idx_a489d58a\" does not exist",
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
"params": [
"\"public\".\"LoginAttempt_userId_idx_a489d58a\""
]
}
],
"execute": [
{
"description": "create index \"LoginAttempt_userId_idx_a489d58a\"",
"sql": "CREATE INDEX \"LoginAttempt_userId_idx_a489d58a\" ON \"public\".\"LoginAttempt\" (\"userId\")",
"params": []
}
],
"postcheck": [
{
"description": "verify index \"LoginAttempt_userId_idx_a489d58a\" exists",
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
"params": [
"\"public\".\"LoginAttempt_userId_idx_a489d58a\""
]
}
]
},
{
"id": "index.Session.Session_userId_idx_a489d58a",
"label": "Create index \"Session_userId_idx_a489d58a\" on \"Session\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "index",
"name": "Session_userId_idx_a489d58a",
"table": "Session"
}
},
"precheck": [
{
"description": "ensure index \"Session_userId_idx_a489d58a\" does not exist",
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
"params": [
"\"public\".\"Session_userId_idx_a489d58a\""
]
}
],
"execute": [
{
"description": "create index \"Session_userId_idx_a489d58a\"",
"sql": "CREATE INDEX \"Session_userId_idx_a489d58a\" ON \"public\".\"Session\" (\"userId\")",
"params": []
}
],
"postcheck": [
{
"description": "verify index \"Session_userId_idx_a489d58a\" exists",
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
"params": [
"\"public\".\"Session_userId_idx_a489d58a\""
]
}
]
},
{
"id": "index.Session.Session_userId_quest_idx_b13626bf",
"label": "Create index \"Session_userId_quest_idx_b13626bf\" on \"Session\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "index",
"name": "Session_userId_quest_idx_b13626bf",
"table": "Session"
}
},
"precheck": [
{
"description": "ensure index \"Session_userId_quest_idx_b13626bf\" does not exist",
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
"params": [
"\"public\".\"Session_userId_quest_idx_b13626bf\""
]
}
],
"execute": [
{
"description": "create index \"Session_userId_quest_idx_b13626bf\"",
"sql": "CREATE INDEX \"Session_userId_quest_idx_b13626bf\" ON \"public\".\"Session\" (\"userId\", \"quest\")",
"params": []
}
],
"postcheck": [
{
"description": "verify index \"Session_userId_quest_idx_b13626bf\" exists",
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
"params": [
"\"public\".\"Session_userId_quest_idx_b13626bf\""
]
}
]
},
{
"id": "index.Session.Session_userId_status_idx_e4a128ba",
"label": "Create index \"Session_userId_status_idx_e4a128ba\" on \"Session\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "index",
"name": "Session_userId_status_idx_e4a128ba",
"table": "Session"
}
},
"precheck": [
{
"description": "ensure index \"Session_userId_status_idx_e4a128ba\" does not exist",
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
"params": [
"\"public\".\"Session_userId_status_idx_e4a128ba\""
]
}
],
"execute": [
{
"description": "create index \"Session_userId_status_idx_e4a128ba\"",
"sql": "CREATE INDEX \"Session_userId_status_idx_e4a128ba\" ON \"public\".\"Session\" (\"userId\", \"status\")",
"params": []
}
],
"postcheck": [
{
"description": "verify index \"Session_userId_status_idx_e4a128ba\" exists",
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
"params": [
"\"public\".\"Session_userId_status_idx_e4a128ba\""
]
}
]
},
{
"id": "index.UserLoginMethod.UserLoginMethod_userId_idx_a489d58a",
"label": "Create index \"UserLoginMethod_userId_idx_a489d58a\" on \"UserLoginMethod\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "index",
"name": "UserLoginMethod_userId_idx_a489d58a",
"table": "UserLoginMethod"
}
},
"precheck": [
{
"description": "ensure index \"UserLoginMethod_userId_idx_a489d58a\" does not exist",
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
"params": [
"\"public\".\"UserLoginMethod_userId_idx_a489d58a\""
]
}
],
"execute": [
{
"description": "create index \"UserLoginMethod_userId_idx_a489d58a\"",
"sql": "CREATE INDEX \"UserLoginMethod_userId_idx_a489d58a\" ON \"public\".\"UserLoginMethod\" (\"userId\")",
"params": []
}
],
"postcheck": [
{
"description": "verify index \"UserLoginMethod_userId_idx_a489d58a\" exists",
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
"params": [
"\"public\".\"UserLoginMethod_userId_idx_a489d58a\""
]
}
]
},
{
"id": "index.UserProgress.UserProgress_userId_idx_a489d58a",
"label": "Create index \"UserProgress_userId_idx_a489d58a\" on \"UserProgress\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "index",
"name": "UserProgress_userId_idx_a489d58a",
"table": "UserProgress"
}
},
"precheck": [
{
"description": "ensure index \"UserProgress_userId_idx_a489d58a\" does not exist",
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
"params": [
"\"public\".\"UserProgress_userId_idx_a489d58a\""
]
}
],
"execute": [
{
"description": "create index \"UserProgress_userId_idx_a489d58a\"",
"sql": "CREATE INDEX \"UserProgress_userId_idx_a489d58a\" ON \"public\".\"UserProgress\" (\"userId\")",
"params": []
}
],
"postcheck": [
{
"description": "verify index \"UserProgress_userId_idx_a489d58a\" exists",
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
"params": [
"\"public\".\"UserProgress_userId_idx_a489d58a\""
]
}
]
},
{
"id": "foreignKey.Command.Command_sessionId_fkey",
"label": "Add foreign key \"Command_sessionId_fkey\" on \"Command\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "foreignKey",
"name": "Command_sessionId_fkey",
"table": "Command"
}
},
"precheck": [
{
"description": "ensure FK \"Command_sessionId_fkey\" does not exist",
"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
"params": [
"Command_sessionId_fkey",
"public",
"\"public\".\"Command\""
]
}
],
"execute": [
{
"description": "add FK \"Command_sessionId_fkey\"",
"sql": "ALTER TABLE \"public\".\"Command\"\nADD CONSTRAINT \"Command_sessionId_fkey\"\nFOREIGN KEY (\"sessionId\")\nREFERENCES \"public\".\"Session\" (\"id\")"
}
],
"postcheck": [
{
"description": "verify FK \"Command_sessionId_fkey\" exists",
"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
"params": [
"Command_sessionId_fkey",
"public",
"\"public\".\"Command\""
]
}
]
},
{
"id": "foreignKey.LoginAttempt.LoginAttempt_userId_fkey",
"label": "Add foreign key \"LoginAttempt_userId_fkey\" on \"LoginAttempt\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "foreignKey",
"name": "LoginAttempt_userId_fkey",
"table": "LoginAttempt"
}
},
"precheck": [
{
"description": "ensure FK \"LoginAttempt_userId_fkey\" does not exist",
"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
"params": [
"LoginAttempt_userId_fkey",
"public",
"\"public\".\"LoginAttempt\""
]
}
],
"execute": [
{
"description": "add FK \"LoginAttempt_userId_fkey\"",
"sql": "ALTER TABLE \"public\".\"LoginAttempt\"\nADD CONSTRAINT \"LoginAttempt_userId_fkey\"\nFOREIGN KEY (\"userId\")\nREFERENCES \"public\".\"User\" (\"id\")"
}
],
"postcheck": [
{
"description": "verify FK \"LoginAttempt_userId_fkey\" exists",
"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
"params": [
"LoginAttempt_userId_fkey",
"public",
"\"public\".\"LoginAttempt\""
]
}
]
},
{
"id": "foreignKey.Session.Session_userId_fkey",
"label": "Add foreign key \"Session_userId_fkey\" on \"Session\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "foreignKey",
"name": "Session_userId_fkey",
"table": "Session"
}
},
"precheck": [
{
"description": "ensure FK \"Session_userId_fkey\" does not exist",
"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
"params": [
"Session_userId_fkey",
"public",
"\"public\".\"Session\""
]
}
],
"execute": [
{
"description": "add FK \"Session_userId_fkey\"",
"sql": "ALTER TABLE \"public\".\"Session\"\nADD CONSTRAINT \"Session_userId_fkey\"\nFOREIGN KEY (\"userId\")\nREFERENCES \"public\".\"User\" (\"id\")"
}
],
"postcheck": [
{
"description": "verify FK \"Session_userId_fkey\" exists",
"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
"params": [
"Session_userId_fkey",
"public",
"\"public\".\"Session\""
]
}
]
},
{
"id": "foreignKey.UserLoginMethod.UserLoginMethod_userId_fkey",
"label": "Add foreign key \"UserLoginMethod_userId_fkey\" on \"UserLoginMethod\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "foreignKey",
"name": "UserLoginMethod_userId_fkey",
"table": "UserLoginMethod"
}
},
"precheck": [
{
"description": "ensure FK \"UserLoginMethod_userId_fkey\" does not exist",
"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
"params": [
"UserLoginMethod_userId_fkey",
"public",
"\"public\".\"UserLoginMethod\""
]
}
],
"execute": [
{
"description": "add FK \"UserLoginMethod_userId_fkey\"",
"sql": "ALTER TABLE \"public\".\"UserLoginMethod\"\nADD CONSTRAINT \"UserLoginMethod_userId_fkey\"\nFOREIGN KEY (\"userId\")\nREFERENCES \"public\".\"User\" (\"id\")"
}
],
"postcheck": [
{
"description": "verify FK \"UserLoginMethod_userId_fkey\" exists",
"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
"params": [
"UserLoginMethod_userId_fkey",
"public",
"\"public\".\"UserLoginMethod\""
]
}
]
},
{
"id": "foreignKey.UserProgress.UserProgress_userId_fkey",
"label": "Add foreign key \"UserProgress_userId_fkey\" on \"UserProgress\"",
"operationClass": "additive",
"target": {
"id": "postgres",
"details": {
"schema": "public",
"objectType": "foreignKey",
"name": "UserProgress_userId_fkey",
"table": "UserProgress"
}
},
"precheck": [
{
"description": "ensure FK \"UserProgress_userId_fkey\" does not exist",
"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
"params": [
"UserProgress_userId_fkey",
"public",
"\"public\".\"UserProgress\""
]
}
],
"execute": [
{
"description": "add FK \"UserProgress_userId_fkey\"",
"sql": "ALTER TABLE \"public\".\"UserProgress\"\nADD CONSTRAINT \"UserProgress_userId_fkey\"\nFOREIGN KEY (\"userId\")\nREFERENCES \"public\".\"User\" (\"id\")"
}
],
"postcheck": [
{
"description": "verify FK \"UserProgress_userId_fkey\" exists",
"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
"params": [
"UserProgress_userId_fkey",
"public",
"\"public\".\"UserProgress\""
]
}
]
}
]
@@ -0,0 +1 @@
{"_generated":{"message":"This file is automatically generated by \"prisma contract emit\".","regenerate":"To regenerate, run: prisma contract emit","warning":"⚠️ GENERATED FILE - DO NOT EDIT"},"capabilities":{"postgres":{"distinctOn":true,"forKeyShare":true,"forNoKeyUpdate":true,"jsonAgg":true,"lateral":true,"limit":true,"orderBy":true,"returning":true},"sql":{"checkConstraint":true,"defaultInInsert":true,"enums":true,"forShare":true,"forUpdate":true,"insertOnConflictSkip":true,"insertOnConflictWithoutTarget":true,"lateral":true,"lockNowait":true,"lockOf":true,"lockSkipLocked":true,"returning":true,"scalarList":true}},"domain":{"namespaces":{"public":{"enum":{"LoginMethod":{"codecId":"pg/text@1","members":[{"name":"Password","value":"password"},{"name":"Google","value":"google"},{"name":"GitHub","value":"github"}]},"SessionStatus":{"codecId":"pg/text@1","members":[{"name":"Active","value":"active"},{"name":"Finished","value":"finished"},{"name":"Inactive","value":"inactive"}]}},"models":{"Command":{"fields":{"command":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"error":{"nullable":true,"type":{"codecId":"pg/text@1","kind":"scalar"}},"id":{"nullable":false,"type":{"codecId":"pg/int8@1","kind":"scalar"}},"output":{"nullable":true,"type":{"codecId":"pg/text@1","kind":"scalar"}},"pwd":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"sessionId":{"nullable":false,"type":{"codecId":"pg/int8@1","kind":"scalar"}},"stage":{"nullable":true,"type":{"codecId":"pg/text@1","kind":"scalar"}},"timestamp":{"nullable":false,"type":{"codecId":"pg/timestamptz-temporal@1","kind":"scalar"}}},"relations":{"session":{"cardinality":"N:1","nullable":false,"on":{"localFields":["sessionId"],"targetFields":["id"]},"to":{"model":"Session","namespace":"public"}}},"storage":{"fields":{"command":{"column":"command"},"error":{"column":"error"},"id":{"column":"id"},"output":{"column":"output"},"pwd":{"column":"pwd"},"sessionId":{"column":"sessionId"},"stage":{"column":"stage"},"timestamp":{"column":"timestamp"}},"namespaceId":"public","table":"Command"}},"LoginAttempt":{"fields":{"id":{"nullable":false,"type":{"codecId":"pg/int8@1","kind":"scalar"}},"ip":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"loginMethod":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"},"valueSet":{"entityKind":"enum","entityName":"LoginMethod","namespaceId":"public","plane":"domain"}},"message":{"nullable":true,"type":{"codecId":"pg/text@1","kind":"scalar"}},"success":{"nullable":false,"type":{"codecId":"pg/bool@1","kind":"scalar"}},"time":{"nullable":false,"type":{"codecId":"pg/timestamptz-temporal@1","kind":"scalar"}},"userAgent":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"userId":{"nullable":true,"type":{"codecId":"pg/int8@1","kind":"scalar"}},"username":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}}},"relations":{"user":{"cardinality":"N:1","nullable":true,"on":{"localFields":["userId"],"targetFields":["id"]},"to":{"model":"User","namespace":"public"}}},"storage":{"fields":{"id":{"column":"id"},"ip":{"column":"ip"},"loginMethod":{"column":"loginMethod"},"message":{"column":"message"},"success":{"column":"success"},"time":{"column":"time"},"userAgent":{"column":"userAgent"},"userId":{"column":"userId"},"username":{"column":"username"}},"namespaceId":"public","table":"LoginAttempt"}},"Session":{"fields":{"completedEvents":{"many":{"elementNullable":false},"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"containerId":{"nullable":true,"type":{"codecId":"pg/text@1","kind":"scalar"}},"containerName":{"nullable":true,"type":{"codecId":"pg/text@1","kind":"scalar"}},"createdAt":{"nullable":false,"type":{"codecId":"pg/timestamptz-temporal@1","kind":"scalar"}},"finishedAt":{"nullable":true,"type":{"codecId":"pg/timestamptz-temporal@1","kind":"scalar"}},"graph":{"nullable":false,"type":{"codecId":"pg/jsonb@1","kind":"scalar"}},"id":{"nullable":false,"type":{"codecId":"pg/int8@1","kind":"scalar"}},"lastActivityAt":{"nullable":false,"type":{"codecId":"pg/timestamptz-temporal@1","kind":"scalar"}},"locale":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"quest":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"status":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"},"valueSet":{"entityKind":"enum","entityName":"SessionStatus","namespaceId":"public","plane":"domain"}},"userId":{"nullable":false,"type":{"codecId":"pg/int8@1","kind":"scalar"}}},"relations":{"commands":{"cardinality":"1:N","on":{"localFields":["id"],"targetFields":["sessionId"]},"to":{"model":"Command","namespace":"public"}},"user":{"cardinality":"N:1","nullable":false,"on":{"localFields":["userId"],"targetFields":["id"]},"to":{"model":"User","namespace":"public"}}},"storage":{"fields":{"completedEvents":{"column":"completedEvents"},"containerId":{"column":"containerId"},"containerName":{"column":"containerName"},"createdAt":{Line truncated
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@odyssey/database",
"module": "src/index.ts",
"type": "module",
"devDependencies": {
"@odyssey/models": "workspace:*",
"@prisma/cli-engine": "0.7.0",
"@types/bun": "latest",
"@types/node": "^26.6.5",
"prisma": "^8.0.0-rc.22"
},
"peerDependencies": {
"typescript": "^5"
},
"scripts": {
"contract:emit": "prisma contract emit"
},
"dependencies": {
"@prisma/orm-postgres": "^8.0.0-rc.17",
"dotenv": "^18.0.7",
"temporal-polyfill": "^1.0.5"
}
}
+106
View File
@@ -0,0 +1,106 @@
# Welcome to Prisma ORM!
Prisma ORM lets you query your database in simple, easy-to-read TypeScript. Define what your data looks like, and Prisma ORM gives you a fully typed client — with autocomplete for every table, column, and relation.
This project is set up for PostgreSQL. Prisma ORM also supports other databases.
## Requirements
- **PostgreSQL 15 or newer.** Older servers are not supported. Run `SELECT version()` against your server to verify.
- The CLI never connects to your database without explicit consent. Pass `--probe-db` to `bun prisma orm init` if you want `init` to verify the server version itself.
## Your data contract
Your data contract is the heart of your application. It lives at [`src/prisma/contract.prisma`](src/prisma/contract.prisma) and describes your models:
```prisma
model User {
id Int @id @default(autoincrement())
email String @unique
username String?
name String?
}
```
Every model you define in your contract can be queried from your app. Your editor will autocomplete the query methods and show you what type each model field is:
```typescript
import { db } from './src/prisma/db';
const user = await db.orm.public.User
.where({ email: 'alice@example.com' })
.first();
// Your editor will show the type of user as
// { id: number; email: string; username: string | null; name: string | null; createdAt: Date; posts: Post[] } | null
```
Your contract has two companion files in the same directory:
- **`contract.json`** — this tells your application what models exist, just like `package-lock.json` tells your package manager what dependencies your project has
- **`contract.d.ts`** — this powers autocomplete and type checking in your editor
Commit both files to git. When you change your contract, run `bun prisma contract emit` to update them.
If you use a framework like Next.js or Vite, the Prisma ORM plugin will do this for you automatically.
## Configuration
[`prisma.config.ts`](prisma.config.ts) tells the CLI where your contract lives and how to connect to your database. It loads environment variables from `.env` automatically:
```typescript
import 'dotenv/config';
import { definePrismaConfig } from '@prisma/cli-engine';
import { defineConfig as ormConfig } from '@prisma/orm-postgres/config';
export default definePrismaConfig({
orm: ormConfig({
contract: './src/prisma/contract.prisma',
db: {
connection: process.env['DATABASE_URL']!,
},
}),
});
```
Notice the `DATABASE_URL` above? It's defined in your [`.env`](./.env) file:
```env
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
```
You can customize how your environment variables are loaded by changing or removing the `import 'dotenv/config'` line.
## Quick reference
### Commands
```bash
bun prisma contract emit # Update contract.json and contract.d.ts
bun prisma db init # Create tables in the database
bun prisma migration status # Show migration status
```
### Files
| File | Purpose |
|---|---|
| [`src/prisma/contract.prisma`](src/prisma/contract.prisma) | Your data contract — define your models here |
| [`prisma.config.ts`](prisma.config.ts) | CLI configuration |
| [`src/prisma/db.ts`](src/prisma/db.ts) | Database client — `import { db } from './src/prisma/db'` |
| `src/prisma/contract.json` | Compiled contract (generated) |
| `src/prisma/contract.d.ts` | Contract types (generated) |
### Workflow
1. Edit [`src/prisma/contract.prisma`](src/prisma/contract.prisma) to add or change models.
2. Run `bun prisma contract emit` to regenerate the contract.
3. Query your models — your IDE will autocomplete everything.
## Monorepo notes (pnpm workspaces)
If this project lives inside a pnpm workspace, a few things are worth knowing:
- **Catalogs.** When the workspace's `pnpm-workspace.yaml` defines a `catalogs` entry for `prisma` or `@prisma/orm-postgres`, pnpm uses the catalog version everywhere — `init` does too. If you wanted the published `latest` instead, update or remove the catalog entry, then re-run `pnpm install`.
- **`pnpm dlx`.** `pnpm dlx prisma@latest orm init …` works in any directory. Inside a workspace, pnpm still resolves dependencies through the workspace's catalog/overrides rather than the registry; expect the installed Prisma ORM packages to reflect the workspace's catalog rather than `latest`.
- **`pnpm` → `npm` fallback.** If `pnpm` ever fails to install Prisma ORM with a `workspace:*` or `catalog:` resolution error (a leak in a published artefact), `init` falls back to `npm install` and surfaces a warning. Once the offending package republishes a clean version you can switch back with `pnpm install`.
+12
View File
@@ -0,0 +1,12 @@
import 'dotenv/config';
import { definePrismaConfig } from '@prisma/cli-engine';
import { defineConfig as ormConfig } from '@prisma/orm-postgres/config';
export default definePrismaConfig({
orm: ormConfig({
contract: "./src/prisma/contract.prisma",
db: {
connection: process.env['DATABASE_URL']!,
},
}),
});
@@ -0,0 +1,403 @@
/**
* One-shot migration of the legacy Mongo data into Postgres.
*
* MONGO_URL=... DATABASE_URL=... bun run scripts/migrate-from-mongo.ts [--dry-run] [--force]
*
* - Stop the server before running; this is a snapshot copy, not a sync.
* - Everything runs in ONE Postgres transaction, so a failure leaves Postgres untouched
* and the script can simply be re-run.
* - Refuses to run if any target table is non-empty, unless --force is given
* (--force does not delete anything; it only skips the check).
* - New ids are assigned here (sequential), so relations can be rewired from ObjectIds.
* The id sequences are advanced afterwards.
* - Rows that cannot be migrated (orphans, unknown providers) are written to
* migration-skipped.json and counted in the summary.
*/
import 'dotenv/config'
import { writeFileSync } from 'node:fs'
import { Temporal } from 'temporal-polyfill'
import {
connectDB,
User,
UserProfile,
Session,
Command,
LoginAttempt,
} from '@odyssey/models'
import { db } from '../src/prisma/db'
const DRY_RUN = process.argv.includes('--dry-run')
const FORCE = process.argv.includes('--force')
const BATCH_SIZE = 1000
const LOGIN_METHODS = ['password', 'google', 'github'] as const
type LoginMethod = (typeof LOGIN_METHODS)[number]
const isLoginMethod = (v: string): v is LoginMethod =>
(LOGIN_METHODS as readonly string[]).includes(v)
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
const skipped: {
table: string
reason: string
id?: string
detail?: unknown
}[] = []
const stats: Record<
string,
{ source: number; inserted: number; skipped: number }
> = {}
function track(table: string) {
return (stats[table] ??= { source: 0, inserted: 0, skipped: 0 })
}
function skip(table: string, reason: string, id?: unknown, detail?: unknown) {
track(table).skipped++
skipped.push({
table,
reason,
id: id === undefined ? undefined : String(id),
detail,
})
}
const instant = (d: Date) => Temporal.Instant.fromEpochMilliseconds(d.getTime())
const instantOrNull = (d?: Date | null) => (d ? instant(d) : null)
// Postgres text cannot hold NUL bytes, which some captured terminal output contains.
let nulStripped = 0
function text(v: string): string
function text(v: string | null | undefined): string | null
function text(v: string | null | undefined) {
if (v == null) return null
if (!v.includes('\0')) return v
nulStripped++
return v.replaceAll('\0', '')
}
// Mongoose subdocuments carry _id; the Postgres graph column stores the bare tree.
interface GraphNode {
path: string
type: string
discovered: boolean
children?: GraphNode[]
}
function cleanGraph(node: any): GraphNode {
const out: GraphNode = {
path: node?.path ?? '/',
// legacy sessions used 'folder' for directories
type: node?.type === 'file' ? 'file' : 'directory',
discovered: node?.discovered ?? false,
}
if (node?.children?.length) out.children = node.children.map(cleanGraph)
return out
}
// A lean Mongoose Map comes back as a plain object, a hydrated one as a Map.
const entries = <T>(m: Map<string, T> | Record<string, T> | undefined) =>
m instanceof Map ? [...m.entries()] : Object.entries(m ?? {})
async function insert(tx: Tx, table: string, plan: unknown, count: number) {
if (DRY_RUN || count === 0) {
track(table).inserted += count
return
}
const { affectedRows } = await tx.execute(plan as never)
track(table).inserted += Number(affectedRows)
}
async function* batches<T>(cursor: AsyncIterable<T>, size: number) {
let buf: T[] = []
for await (const item of cursor) {
buf.push(item)
if (buf.length >= size) {
yield buf
buf = []
}
}
if (buf.length) yield buf
}
async function migrate(tx: Tx) {
const t = tx.sql.public
const userIds = new Map<string, bigint>()
const userIdsByUsername = new Map<string, bigint>()
const sessionIds = new Map<string, bigint>()
const seenEmails = new Set<string>()
const seenOAuth = new Set<string>()
let nextUserId = 1n
let nextSessionId = 1n
let nextCommandId = 1n
let nextAttemptId = 1n
// --- User + UserLoginMethod
for await (const users of batches(
User.find().sort({ _id: 1 }).lean().cursor(),
BATCH_SIZE
)) {
const userRows: any[] = []
const methodRows: any[] = []
for (const u of users) {
track('User').source++
const email = u.email.trim().toLowerCase()
if (seenEmails.has(email)) {
throw new Error(
`Duplicate email after lowercasing: ${email} (user ${u._id}). Resolve it in Mongo first.`
)
}
seenEmails.add(email)
const id = nextUserId++
userIds.set(String(u._id), id)
userIdsByUsername.set(u.username, id)
userRows.push({
id,
email,
username: u.username,
name: null,
createdAt: instant(u.createdAt),
updatedAt: instant(u.updatedAt),
isGuest: u.isGuest ?? false,
})
if (u.hashedPassword) {
methodRows.push({
userId: id,
type: 'password',
hashedPassword: u.hashedPassword,
oauthId: null,
displayName: null,
createdAt: instant(u.createdAt),
updatedAt: instant(u.updatedAt),
})
}
for (const [key, login] of entries(u.socialLogins)) {
const provider = (login.provider ?? key).toLowerCase()
if (!isLoginMethod(provider) || provider === 'password') {
skip('UserLoginMethod', 'unknown provider', u._id, { provider })
continue
}
const dedupe = `${provider}:${login.id}`
if (seenOAuth.has(dedupe)) {
skip('UserLoginMethod', 'duplicate (type, oauthId)', u._id, {
provider,
oauthId: login.id,
})
continue
}
seenOAuth.add(dedupe)
methodRows.push({
userId: id,
type: provider,
hashedPassword: null,
oauthId: login.id,
displayName: login.displayName ?? null,
createdAt: instant(u.createdAt),
updatedAt: instant(u.updatedAt),
})
}
}
await insert(tx, 'User', t.User.insert(userRows).build(), userRows.length)
track('UserLoginMethod').source += methodRows.length
await insert(
tx,
'UserLoginMethod',
t.UserLoginMethod.insert(methodRows).build(),
methodRows.length
)
}
// --- UserProgress (UserProfile.progress map, flattened)
for await (const profiles of batches(
UserProfile.find().lean().cursor(),
BATCH_SIZE
)) {
const rows: any[] = []
for (const p of profiles) {
const userId = userIds.get(String(p.user))
const items = entries(p.progress)
track('UserProgress').source += items.length
if (userId === undefined) {
for (const [quest] of items)
skip('UserProgress', 'user not found', p.user, { quest })
continue
}
for (const [key, prog] of items) {
rows.push({
userId,
quest: prog.quest ?? key,
startedAt: instant(prog.startedAt),
// `completed` is no longer stored: finishedAt IS NOT NULL means completed.
finishedAt: prog.completed
? instantOrNull(prog.finishedAt ?? (p as any).updatedAt)
: null,
})
}
}
await insert(
tx,
'UserProgress',
t.UserProgress.insert(rows).build(),
rows.length
)
}
// --- Session
for await (const sessions of batches(
Session.find().sort({ _id: 1 }).lean().cursor(),
BATCH_SIZE
)) {
const rows: any[] = []
for (const s of sessions) {
track('Session').source++
const userId = userIds.get(String(s.user))
if (userId === undefined) {
skip('Session', 'user not found', s._id, { user: String(s.user) })
continue
}
const id = nextSessionId++
sessionIds.set(String(s._id), id)
rows.push({
id,
userId,
quest: s.quest,
// sessions created before localization have no locale; they were zh-TW
locale: s.locale ?? 'zh-TW',
containerId: s.containerId ?? null,
containerName: s.containerName ?? null,
status: s.status,
createdAt: instant(s.createdAt),
lastActivityAt: instant(s.lastActivityAt ?? s.updatedAt),
finishedAt: instantOrNull(s.finishedAt),
completedEvents: s.completedEvents ?? [],
graph: cleanGraph(s.graph),
})
}
await insert(tx, 'Session', t.Session.insert(rows).build(), rows.length)
}
// --- Command (largest collection)
for await (const commands of batches(
Command.find().sort({ _id: 1 }).lean().cursor(),
BATCH_SIZE
)) {
const rows: any[] = []
for (const c of commands) {
track('Command').source++
const sessionId = sessionIds.get(String(c.session))
if (sessionId === undefined) {
skip('Command', 'session not found', c._id, {
session: String(c.session),
})
continue
}
rows.push({
id: nextCommandId++,
sessionId,
timestamp: instant(c.createdAt),
pwd: text(c.pwd),
command: text(c.command),
output: text(c.output),
error: text(c.error),
stage: text(c.stage),
})
}
await insert(tx, 'Command', t.Command.insert(rows).build(), rows.length)
}
// --- LoginAttempt (userId stays null when the username no longer exists)
for await (const attempts of batches(
LoginAttempt.find().sort({ _id: 1 }).lean().cursor(),
BATCH_SIZE
)) {
const rows = attempts.map((a) => {
track('LoginAttempt').source++
return {
id: nextAttemptId++,
username: a.username,
userId: userIdsByUsername.get(a.username) ?? null,
ip: a.ip,
loginMethod: 'password' as const, // legacy attempts were password-only
userAgent: a.userAgent,
time: instant(a.time ?? (a as any).createdAt),
success: a.success,
message: a.message ?? null,
}
})
await insert(
tx,
'LoginAttempt',
t.LoginAttempt.insert(rows).build(),
rows.length
)
}
// --- Advance the autoincrement sequences past the ids we assigned explicitly.
// Identifiers can't be bind parameters, so each statement is spelled out.
if (!DRY_RUN) {
const { raw } = db
const plans = [
raw.sql`SELECT setval(pg_get_serial_sequence('"User"', 'id'), COALESCE((SELECT MAX(id) FROM "User"), 0) + 1, false)`,
raw.sql`SELECT setval(pg_get_serial_sequence('"Session"', 'id'), COALESCE((SELECT MAX(id) FROM "Session"), 0) + 1, false)`,
raw.sql`SELECT setval(pg_get_serial_sequence('"Command"', 'id'), COALESCE((SELECT MAX(id) FROM "Command"), 0) + 1, false)`,
raw.sql`SELECT setval(pg_get_serial_sequence('"LoginAttempt"', 'id'), COALESCE((SELECT MAX(id) FROM "LoginAttempt"), 0) + 1, false)`,
]
for (const plan of plans)
await tx.execute(plan.affectedCount().build() as never)
}
}
async function assertTargetEmpty() {
const { orm } = db
const found = await Promise.all([
orm.public.User.first(),
orm.public.UserLoginMethod.first(),
orm.public.UserProgress.first(),
orm.public.Session.first(),
orm.public.Command.first(),
orm.public.LoginAttempt.first(),
])
if (found.some(Boolean)) {
throw new Error(
'Target tables are not empty. Truncate them or pass --force.'
)
}
}
async function main() {
const mongoUrl = process.env['MONGO_URL']
if (!mongoUrl) throw new Error('MONGO_URL is not set')
const mongo = await connectDB(mongoUrl)
console.log(
DRY_RUN ? 'DRY RUN: nothing will be written' : 'Migrating Mongo -> Postgres'
)
try {
if (!FORCE && !DRY_RUN) await assertTargetEmpty()
await db.transaction((tx) => migrate(tx as Tx))
} finally {
await mongo.close()
await db.close()
}
console.table(stats)
if (nulStripped)
console.log(`Stripped NUL bytes from ${nulStripped} command text fields`)
writeFileSync('migration-skipped.json', JSON.stringify(skipped, null, 2))
console.log(
`Skipped rows written to migration-skipped.json (${skipped.length})`
)
for (const [table, s] of Object.entries(stats)) {
if (!DRY_RUN && s.source !== s.inserted + s.skipped) {
throw new Error(
`Count mismatch for ${table}: ${s.source} != ${s.inserted} + ${s.skipped}`
)
}
}
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
+3
View File
@@ -0,0 +1,3 @@
export { db } from './prisma/db'
console.log('Hello via Bun!')
+1609
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,102 @@
// use prisma-8
model User {
id BigInt @id @default(autoincrement())
email String @unique
username String @unique
name String?
createdAt DateTime
updatedAt DateTime
isGuest Boolean
loginMethods UserLoginMethod[]
progress UserProgress[]
sessions Session[]
loginAttempts LoginAttempt[]
}
enum LoginMethod {
@@type("pg/text@1")
Password = "password"
Google = "google"
GitHub = "github"
}
// User login using one of the login method
model UserLoginMethod {
type LoginMethod
userId BigInt
createdAt DateTime
updatedAt DateTime
hashedPassword String?
oauthId String?
displayName String?
@@id([userId, type])
@@unique([type, oauthId])
user User @relation(fields: [userId], references: [id])
}
// Store what quest a user has completed
model UserProgress {
userId BigInt
quest String
startedAt DateTime
finishedAt DateTime?
user User @relation(fields: [userId], references: [id])
@@id([userId, quest])
}
enum SessionStatus {
@@type("pg/text@1")
Active = "active"
Finished = "finished"
Inactive = "inactive"
}
model Session {
id BigInt @id @default(autoincrement())
userId BigInt
quest String
locale String
containerId String?
containerName String?
status SessionStatus
createdAt DateTime
lastActivityAt DateTime
finishedAt DateTime?
completedEvents String[]
graph Jsonb
user User @relation(fields: [userId], references: [id])
commands Command[]
@@index([userId, quest])
@@index([userId, status])
}
model Command {
id BigInt @id @default(autoincrement())
sessionId BigInt
timestamp DateTime
pwd String
command String
output String?
error String?
stage String?
session Session @relation(fields: [sessionId], references: [id])
@@index([sessionId])
}
model LoginAttempt {
id BigInt @id @default(autoincrement())
username String
userId BigInt?
ip String
loginMethod LoginMethod
userAgent String
time DateTime
success Boolean
message String?
user User? @relation(fields: [userId], references: [id])
}
+9
View File
@@ -0,0 +1,9 @@
import 'dotenv/config'
import postgres from '@prisma/orm-postgres/runtime'
import type { Contract } from './contract.d'
import contractJson from './contract.json' with { type: 'json' }
export const db = postgres<Contract>({
contractJson,
url: process.env['DATABASE_URL']!,
})
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"types": [
"bun",
"node"
],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"resolveJsonModule": true
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@odyssey/file-graph",
"version": "0.0.0",
"private": true,
"main": "index.ts",
"types": "index.ts",
"exports": {
".": "./index.ts"
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, test, expect } from '@jest/globals'
import { describe, test, expect, beforeEach } from 'bun:test'
import File from '../file.js'
describe('File', () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, test, expect } from '@jest/globals'
import { describe, test, expect, beforeEach } from 'bun:test'
import FileGraph from '../fileGraph.js'
import FileNode from '../fileNode.js'
import { FileObject } from '../file.js'
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, test, expect } from '@jest/globals'
import { describe, test, expect, beforeEach } from 'bun:test'
import { ParentNotExistsError } from '../errors.js'
import FileNode, { IFileNode } from '../fileNode.js'
import { FileObject } from '../file.js'
+1 -1
View File
@@ -1,4 +1,4 @@
import { FileGraph, IFileNode } from '../file-graph'
import { FileGraph, IFileNode } from '@odyssey/file-graph'
import { Quest } from './Quest'
import {
IQuest,
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@odyssey/game",
"version": "0.0.0",
"private": true,
"main": "index.ts",
"types": "index.ts",
"exports": {
".": "./index.ts"
},
"dependencies": {
"@odyssey/file-graph": "workspace:*",
"zod": "^3.24.2",
"zod-to-json-schema": "^3.24.5"
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { IFileNode } from '../../file-graph'
import { IFileNode } from '@odyssey/file-graph'
export * from './condition.js'
export * from './stage.js'
+2 -23
View File
@@ -1,28 +1,7 @@
import { test, expect } from '@jest/globals'
import { IFileExistenceChecker, IFileInput } from '../schema'
import { test, expect } from 'bun:test'
import { checkFiles } from '../condition/FileMatcher.js'
import { Condition } from '../condition/Condition.js'
export class MockFileChecker implements IFileExistenceChecker {
private files: IFileInput[] = [
{
path: '/home/user/hello.txt',
type: 'file',
},
{
path: '/home/user/world.txt',
type: 'file',
},
{
path: '/home/user/Downloads',
type: 'directory',
},
]
async exists(file: IFileInput): Promise<boolean> {
return this.files.some((f) => f.path === file.path && f.type === file.type)
}
}
import { MockFileChecker } from './mockFileChecker'
test('file matcher', async () => {
const checker = new MockFileChecker()
+2 -2
View File
@@ -1,11 +1,11 @@
import { test, expect } from '@jest/globals'
import { it, test, expect } from 'bun:test'
import { Condition } from '../condition/Condition'
import { OutputMatcher } from '../condition/OutputMatcher'
import { PwdMatcher } from '../condition/PwdMatcher'
import { CommandMatcher } from '../condition/CommandMatcher'
import { ErrorMatcher } from '../condition/ErrorMatcher'
import { conditionSchema } from '../schema'
import { MockFileChecker } from './asyncCondition.test'
import { MockFileChecker } from './mockFileChecker'
test('command match', () => {
const matcher = new CommandMatcher('^echo start$')
+2 -2
View File
@@ -1,7 +1,7 @@
import { expect, it, describe } from '@jest/globals'
import { describe, it, expect } from 'bun:test'
import { IQuest } from '../schema'
import { GameSession } from '../Session'
import { MockFileChecker } from './asyncCondition.test'
import { MockFileChecker } from './mockFileChecker'
const quest: IQuest = {
id: 'quest1',
+22
View File
@@ -0,0 +1,22 @@
import type { IFileExistenceChecker, IFileInput } from '../schema'
export class MockFileChecker implements IFileExistenceChecker {
private files: IFileInput[] = [
{
path: '/home/user/hello.txt',
type: 'file',
},
{
path: '/home/user/world.txt',
type: 'file',
},
{
path: '/home/user/Downloads',
type: 'directory',
},
]
async exists(file: IFileInput): Promise<boolean> {
return this.files.some((f) => f.path === file.path && f.type === file.type)
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { expect, describe, it } from '@jest/globals'
import { describe, it, expect } from 'bun:test'
import { IQuest } from '../schema'
import { GameSession } from '../Session'
import { MockFileChecker } from './asyncCondition.test'
import { MockFileChecker } from './mockFileChecker'
const quest: IQuest = {
id: 'quest1',
+2 -2
View File
@@ -1,6 +1,6 @@
import { test, expect } from '@jest/globals'
import { test, expect } from 'bun:test'
import { Stage } from '../Stage'
import { MockFileChecker } from './asyncCondition.test'
import { MockFileChecker } from './mockFileChecker'
const checker = new MockFileChecker()
+9 -5
View File
@@ -1,10 +1,14 @@
import mongoose from 'mongoose'
export { User, IUser } from './user.js'
export { Session, ISession } from './session.js'
export { Command, ICommand } from './command.js'
export { UserProfile, IUserProfile, IProgress } from './userProfile.js'
export { ILoginAttempt, LoginAttempt } from './loginAttempt.js'
export { User, type IUser } from './user.js'
export { Session, type ISession } from './session.js'
export { Command, type ICommand } from './command.js'
export {
UserProfile,
type IUserProfile,
type IProgress,
} from './userProfile.js'
export { type ILoginAttempt, LoginAttempt } from './loginAttempt.js'
export async function connectDB(uri: string) {
const db = await mongoose.connect(uri)
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@odyssey/models",
"version": "0.0.0",
"private": true,
"main": "index.ts",
"types": "index.ts",
"exports": {
".": "./index.ts"
},
"dependencies": {
"@odyssey/constants": "workspace:*",
"mongoose": "^8.24.1"
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { model, Schema, Types } from 'mongoose'
import type { QuestLocale } from '../constants'
import { SUPPORTED_LOCALES } from '../constants'
import type { QuestLocale } from '@odyssey/constants'
import { SUPPORTED_LOCALES } from '@odyssey/constants'
export interface INode {
path: string
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@odyssey/utils",
"version": "0.0.0",
"private": true,
"main": "index.ts",
"types": "index.ts",
"exports": {
".": "./index.ts"
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from '@jest/globals'
import { test, expect } from 'bun:test'
import { isValidEmail, isValidUsername } from '../auth'
test('check valid username', () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from '@jest/globals'
import { describe, it, expect } from 'bun:test'
import { buildFileCheckCmd, File } from '../commands'
describe('buildFileCheckCmd function', () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from '@jest/globals'
import { describe, it, expect } from 'bun:test'
import DAG from '../dag'
describe('dag layers', () => {
+16 -15
View File
@@ -1,27 +1,28 @@
FROM node:20-alpine AS build
FROM oven/bun:1.3.14-alpine AS prod
WORKDIR /app
COPY ./package.json yarn.lock ./
COPY package.json bun.lock ./
COPY app/package.json ./app/package.json
COPY server/package.json ./server/package.json
RUN yarn install
COPY services/analytics/package.json ./services/analytics/package.json
COPY tests/package.json ./tests/package.json
COPY packages/constants/package.json ./packages/constants/package.json
COPY packages/file-graph/package.json ./packages/file-graph/package.json
COPY packages/game/package.json ./packages/game/package.json
COPY packages/models/package.json ./packages/models/package.json
COPY packages/utils/package.json ./packages/utils/package.json
RUN bun install --frozen-lockfile --production --filter server
# Install docker cli
COPY server ./server
COPY packages ./packages
COPY tsconfig.json ./tsconfig.json
RUN yarn workspace server build
# Production
FROM node:20-alpine AS prod
WORKDIR /app
COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/server ./server
COPY tsconfig.json ./tsconfig.json
COPY packages ./packages
COPY server ./server
COPY ./quests ./quests
ENV NODE_ENV=production
WORKDIR /app/server
CMD [ "yarn", "start" ]
CMD [ "bun", "src/index.ts" ]
ENV HOST=0.0.0.0
ENV PORT=3000
+1 -1
View File
@@ -1,4 +1,4 @@
import { connectDB, User } from '../../packages/models'
import { connectDB, User } from '@odyssey/models'
async function migrate() {
await connectDB(
+11 -16
View File
@@ -3,15 +3,19 @@
"version": "0.0.0",
"description": "Linux Odyssey Server",
"scripts": {
"start": "node dist/server/src/index.js",
"setup": "tsx src/cli/setup.ts",
"clean": "tsx src/cli/clean.ts",
"build": "tsc",
"start": "bun src/index.ts",
"setup": "bun src/cli/setup.ts",
"clean": "bun src/cli/clean.ts",
"type-check": "tsc --noEmit",
"dev": "tsx watch --include \"../quests/**/*.yml\" src/index.ts",
"build:quests": "tsx src/cli/build.ts"
"dev": "bun scripts/dev.ts",
"build:quests": "bun src/cli/build.ts"
},
"dependencies": {
"@odyssey/constants": "workspace:*",
"@odyssey/game": "workspace:*",
"@odyssey/models": "workspace:*",
"@odyssey/utils": "workspace:*",
"@odyssey/database": "workspace:*",
"@trpc/server": "^11.8.0",
"bcrypt": "^5.1.1",
"commander": "^11.0.0",
@@ -36,20 +40,17 @@
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"socket.io": "^4.7.1",
"ssh2": "^1.16.0",
"swagger-ui-express": "^5.0.0",
"tsx": "^4.19.3",
"validator": "^13.15.22",
"winston": "^3.11.0",
"yaml": "^2.8.3",
"zod": "^3.24.2"
},
"devDependencies": {
"@swc/core": "^1.4.2",
"@swc/helpers": "^0.5.6",
"@types/bcrypt": "^5.0.2",
"@types/dockerode": "^3.3.23",
"@types/express": "^4.17.21",
"@types/express-serve-static-core": "^5.1.3",
"@types/express-session": "^1.17.10",
"@types/jsonwebtoken": "^9.0.5",
"@types/minimist": "^1.2.5",
@@ -59,14 +60,8 @@
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/passport-oauth2": "^1.4.15",
"@types/source-map-support": "^0",
"@types/ssh2": "^1.15.1",
"@types/swagger-ui-express": "^4.1.6",
"@types/validator": "^13.11.9",
"concurrently": "^8.2.2",
"jest": "^29.6.1",
"regenerator-runtime": "^0.14.1",
"source-map-support": "^0.5.21",
"typescript": "^5.3.3"
}
}
+27
View File
@@ -0,0 +1,27 @@
// Dev server with auto-restart. `bun --watch` only tracks imported modules,
// but quests are read from YAML at startup, so rewrite the entry file (same
// contents) whenever a quest file changes to trigger a restart. Bun ignores
// timestamp-only changes, so `touch` would not work.
import { watch, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
const entry = path.join(import.meta.dir, '../src/index.ts')
const questsDir = path.join(import.meta.dir, '../../quests')
const server = Bun.spawn(['bun', '--watch', entry], {
stdio: ['inherit', 'inherit', 'inherit'],
})
let timer: ReturnType<typeof setTimeout> | undefined
watch(questsDir, { recursive: true }, (_event, filename) => {
if (!filename?.endsWith('.yml')) return
clearTimeout(timer)
timer = setTimeout(() => {
writeFileSync(entry, readFileSync(entry))
}, 100)
})
const stop = () => server.kill()
process.on('SIGINT', stop)
process.on('SIGTERM', stop)
process.exit(await server.exited)
@@ -1,6 +1,6 @@
import type { Request, Response } from 'express'
import { GameSession, commandSchema } from '../../../../packages/game'
import { Command, Session } from '../../../../packages/models'
import { GameSession, commandSchema } from '@odyssey/game'
import { Command, Session } from '@odyssey/models'
import { pushToSession } from '../socket'
import { finishSession } from '../../models/sessionManager'
import { asyncHandler } from '../../middleware/error'
+1 -1
View File
@@ -1,7 +1,7 @@
import { Router, Request, Response, Express } from 'express'
import passport from 'passport'
import { NextFunction } from 'express-serve-static-core'
import { LoginAttempt } from '../../../../packages/models'
import { LoginAttempt } from '@odyssey/models'
import enabledMethods from '../../auth/passport.js'
import {
-2
View File
@@ -1,8 +1,6 @@
import { Router } from 'express'
import passport from 'passport'
import commands from './commandRouter.js'
import authRouter from './authRouter.js'
import authRequired from '../../middleware/authRequired.js'
import config from '../../config.js'
import leaderboardController from '../controllers/leaderboardController.js'
+1 -1
View File
@@ -2,7 +2,7 @@ import { Server } from 'socket.io'
import type { Socket } from 'socket.io'
import validator from 'validator'
import type { Server as HttpServer } from 'http'
import { Session } from '../../../packages/models'
import { Session } from '@odyssey/models'
import SessionMiddleware from '../middleware/session.js'
import logger from '../utils/logger.js'
+3 -3
View File
@@ -1,8 +1,8 @@
import { check } from 'express-validator'
import { isValidUsername, isValidEmail } from '../../../../packages/utils'
import { User } from '../../../../packages/models'
import { passwordPolicy } from '../../../../packages/constants'
import { isValidUsername, isValidEmail } from '@odyssey/utils'
import { User } from '@odyssey/models'
import { passwordPolicy } from '@odyssey/constants'
import { noError } from '../../middleware/validator.js'
const checkUsername = () =>
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Profile } from 'passport'
import type { VerifyCallback } from 'passport-oauth2'
import { User } from '../../../packages/models'
import { User } from '@odyssey/models'
import logger from '../utils/logger.js'
export default async function oauthVerify(
+1 -1
View File
@@ -1,6 +1,6 @@
import passport from 'passport'
import type { Express } from 'express'
import { UserProfile } from '../../../packages/models'
import { UserProfile } from '@odyssey/models'
import passwordStrategy from './passwordStrategy.js'
import jwtStrategy from './jwtStrategy.js'
import googleStrategy from './googleStrategy.js'
+1 -1
View File
@@ -3,7 +3,7 @@ import bcrypt from 'bcrypt'
import { Strategy as LocalStrategy } from 'passport-local'
import type { IVerifyOptions } from 'passport-local'
import type { Express } from 'express'
import { User } from '../../../packages/models'
import { User } from '@odyssey/models'
async function verifyPassword(
username: string,
+1 -1
View File
@@ -1,4 +1,4 @@
import { connectDB } from '../../../packages/models'
import { connectDB } from '@odyssey/models'
import { removeExpired } from '../containers/expiryChecker'
import config from '../config'
import logger from '../utils/logger'
+1 -1
View File
@@ -94,7 +94,7 @@ function createConfig() {
function getProjectRoot(): string {
// Start from the current file's directory
let currentDir = __dirname
let currentDir = import.meta.dir
// Keep going up until we find the root package.json with workspaces
while (currentDir !== path.parse(currentDir).root) {
+2 -2
View File
@@ -1,7 +1,7 @@
import { execFile } from 'child_process'
import { buildFileCheckCmd } from '../../../packages/utils'
import type { IFileExistenceChecker, IFileInput } from '../../../packages/game'
import { buildFileCheckCmd } from '@odyssey/utils'
import type { IFileExistenceChecker, IFileInput } from '@odyssey/game'
function sanitizeId(id: string) {
return id.replace(/[^a-zA-Z0-9]/g, '')
+1 -1
View File
@@ -1,6 +1,6 @@
import fs from 'fs/promises'
import Docker from 'dockerode'
import type { QuestLocale } from '../../../packages/constants'
import type { QuestLocale } from '@odyssey/constants'
import config, { getQuestImage } from '../config.js'
import logger from '../utils/logger.js'
+2 -2
View File
@@ -1,7 +1,7 @@
import schedule from 'node-schedule'
import type { HydratedDocument } from 'mongoose'
import { Session } from '../../../packages/models'
import type { ISession } from '../../../packages/models'
import { Session } from '@odyssey/models'
import type { ISession } from '@odyssey/models'
import config from '../config.js'
import { deleteContainer } from './docker.js'
import logger from '../utils/logger.js'
+4 -2
View File
@@ -5,7 +5,8 @@ import YAML from 'yaml'
import swaggerUI from 'swagger-ui-express'
import passport from 'passport'
import * as trpcExpress from '@trpc/server/adapters/express'
import { connectDB } from '../../packages/models'
import { connectDB } from '@odyssey/models'
import { db } from '@odyssey/database'
import './auth/passport.js'
import socketServer from './api/socket.js'
@@ -22,9 +23,10 @@ import { appRouter } from './routers/index.js'
import { createContext } from './trpc.js'
async function main() {
console.log(db)
if (!config.secret) {
logger.error(
'No SECRET_KEY found in .env! To set up a persistent key, please run `yarn setup`:'
'No SECRET_KEY found in .env! To set up a persistent key, please run `bun run setup`:'
)
process.exit(1)
}
+1 -1
View File
@@ -1,8 +1,8 @@
import rateLimit from 'express-rate-limit'
import type { Options } from 'express-rate-limit'
import type { Request, Response, NextFunction } from 'express'
import { LoginAttempt } from '@odyssey/models'
import config from '../config.js'
import { LoginAttempt } from '../../../packages/models'
import logger from '../utils/logger.js'
function createRateLimiter(options: Partial<Options>) {
+3 -7
View File
@@ -3,13 +3,9 @@ import fs from 'fs/promises'
import path from 'path'
import yaml from 'yaml'
import type { QuestLocale } from '../../../packages/constants'
import { SUPPORTED_LOCALES } from '../../../packages/constants'
import {
questSchema,
globalExceptionSchema,
IQuest,
} from '../../../packages/game'
import type { QuestLocale } from '@odyssey/constants'
import { SUPPORTED_LOCALES } from '@odyssey/constants'
import { questSchema, globalExceptionSchema, IQuest } from '@odyssey/game'
import logger from '../utils/logger.js'
import config from '../config.js'
+4 -4
View File
@@ -1,8 +1,8 @@
import { HydratedDocument } from 'mongoose'
import type { QuestLocale } from '../../../packages/constants'
import { LOCALE_NAME_MAP } from '../../../packages/constants'
import { Session, UserProfile } from '../../../packages/models'
import type { ISession, IUser } from '../../../packages/models'
import type { QuestLocale } from '@odyssey/constants'
import { LOCALE_NAME_MAP } from '@odyssey/constants'
import { Session, UserProfile } from '@odyssey/models'
import type { ISession, IUser } from '@odyssey/models'
import {
createContainer,
deleteContainer,
+1 -1
View File
@@ -1,6 +1,6 @@
import { Express } from 'express'
import { format } from 'date-fns'
import { User, UserProfile } from '../../../packages/models'
import { User, UserProfile } from '@odyssey/models'
import { hashPassword } from '../utils/auth.js'
export async function createUser(
+1 -1
View File
@@ -1,8 +1,8 @@
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
import { SUPPORTED_LOCALES } from '@odyssey/constants'
import { router, publicProcedure } from '../trpc.js'
import { questManager } from '../models/quest.js'
import { SUPPORTED_LOCALES } from '../../../packages/constants'
export const questRouter = router({
getQuests: publicProcedure
+3 -3
View File
@@ -1,8 +1,8 @@
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
import { SUPPORTED_LOCALES } from '../../../packages/constants'
import { ISession, Session } from '../../../packages/models'
import { GameSession, VoidFileExistenceChecker } from '../../../packages/game'
import { SUPPORTED_LOCALES } from '@odyssey/constants'
import { ISession, Session } from '@odyssey/models'
import { GameSession, VoidFileExistenceChecker } from '@odyssey/game'
import { createNewSession, isQuestUnlocked } from '../models/sessionManager.js'
import config from '../config.js'
import { protectedProcedure, router } from '../trpc.js'
+1 -1
View File
@@ -1,5 +1,5 @@
import { TRPCError } from '@trpc/server'
import { UserProfile } from '../../../packages/models'
import { UserProfile } from '@odyssey/models'
import { protectedProcedure, router } from '../trpc.js'
export const userRouter = router({
+1 -1
View File
@@ -1,4 +1,4 @@
import { User } from '../../../packages/models'
import { User } from '@odyssey/models'
import config from '../config.js'
import { createUser } from '../models/userManager.js'
import logger from './logger.js'
+7 -9
View File
@@ -1,14 +1,12 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"outDir": "./dist"
"module": "Preserve",
"moduleResolution": "bundler",
"target": "ESNext",
"types": ["bun", "node"],
"noEmit": true
},
"include": ["src"],
"exclude": ["node_modules", "src/*.test.js", "dist"],
"ts-node": {
"swc": true
}
"include": ["src", "scripts", "migrations"],
"exclude": ["node_modules", "src/*.test.js"]
}
+12 -13
View File
@@ -1,27 +1,26 @@
FROM node:20-alpine AS build
FROM oven/bun:1.3.14-alpine AS prod
WORKDIR /app
COPY ./package.json yarn.lock ./
COPY package.json bun.lock ./
COPY app/package.json ./app/package.json
COPY server/package.json ./server/package.json
COPY services/analytics/package.json ./services/analytics/package.json
RUN yarn install --frozen-lockfile
COPY tests/package.json ./tests/package.json
COPY packages/constants/package.json ./packages/constants/package.json
COPY packages/file-graph/package.json ./packages/file-graph/package.json
COPY packages/game/package.json ./packages/game/package.json
COPY packages/models/package.json ./packages/models/package.json
COPY packages/utils/package.json ./packages/utils/package.json
RUN bun install --frozen-lockfile --production --filter analytics
COPY tsconfig.json ./tsconfig.json
COPY packages ./packages
COPY services/analytics ./services/analytics
RUN yarn workspace analytics build
# Production stage
FROM node:20-alpine AS prod
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/services/analytics ./services/analytics
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3001
EXPOSE 3001
WORKDIR /app/services/analytics
CMD [ "yarn", "start" ]
CMD [ "bun", "src/index.ts" ]
+4 -9
View File
@@ -1,16 +1,16 @@
{
"name": "analytics",
"version": "1.0.0",
"main": "src/index.js",
"main": "src/index.ts",
"private": true,
"license": "GPL",
"scripts": {
"start": "node dist/services/analytics/src/index.js",
"build": "tsc",
"start": "bun src/index.ts",
"type-check": "tsc --noEmit",
"dev": "tsx watch src/index.ts"
"dev": "bun --watch src/index.ts"
},
"dependencies": {
"@odyssey/models": "workspace:*",
"dotenv": "^16.4.5",
"express": "^4.20.0",
"express-basic-auth": "^1.2.1",
@@ -19,12 +19,7 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@swc/core": "^1.4.2",
"@swc/helpers": "^0.5.6",
"@types/express": "^4.17.21",
"@types/node": "^20.11.20",
"regenerator-runtime": "^0.14.1",
"tsx": "^4.19.3",
"typescript": "^5.3.3"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import express from 'express'
import { engine } from 'express-handlebars'
import basicAuth from 'express-basic-auth'
import { connectDB } from '../../../packages/models'
import { connectDB } from '@odyssey/models'
import config from './config'
import router from './router'
+1 -1
View File
@@ -1,4 +1,4 @@
import { Command } from '../../../../packages/models'
import { Command } from '@odyssey/models'
import Pagination from './pagination'
// eslint-disable-next-line import/prefer-default-export
+1 -1
View File
@@ -1,4 +1,4 @@
import { UserProfile } from '../../../../packages/models'
import { UserProfile } from '@odyssey/models'
export interface QuestProgress {
_id: string
+1 -1
View File
@@ -1,4 +1,4 @@
import { Command, Session } from '../../../../packages/models'
import { Command, Session } from '@odyssey/models'
import Pagination from './pagination'
import {
CommandObject,
+1 -1
View File
@@ -1,5 +1,5 @@
import { HydratedDocument, Types } from 'mongoose'
import { Session, User, IUser, ISession } from '../../../../packages/models'
import { Session, User, IUser, ISession } from '@odyssey/models'
import Pagination from './pagination'
import { UserSessionDetail } from '../interface'
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more