mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-25 07:27:04 +00:00
Support PostgreSQL database backend (#3167)
* feat(database): add postgres database client Adds the PostgreSQL database client, native bootstrap migration, SQL preparation helpers, and database config/factory wiring.\n\nRefs #3165. * feat(database): make backend queries postgres-aware Updates runtime SQL call sites for database-specific booleans, identifiers, insert-ignore, upserts, JSON extraction, intervals, and Postgres insert ids.\n\nRefs #3165. * test(database): cover postgres client behavior Adds unit coverage for SQL preparation, factory selection, write-result mapping, and transaction rollback/commit ordering, plus an env-gated PostgreSQL integration flow.\n\nRefs #3165. * docs(self-hosting): document postgres database setup Adds PostgreSQL configuration examples and migration path guidance for self-hosted deployments.\n\nRefs #3165. * fix: harden postgres oidc tests * fix(postgres): normalize query results and SQL prep * fix(user): preserve normalized cache booleans * test(postgres): run integration coverage with pgmock * tests: add way to run all tests with postgres though slow Also adding note that postgres is not in active use so might not work out the box --------- Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
This commit is contained in:
co-authored by
Daniel Salazar
parent
8ac4bad196
commit
d2fee51844
+18
-4
@@ -159,17 +159,30 @@
|
||||
|
||||
// ── Database ────────────────────────────────────────────────────────
|
||||
"database": {
|
||||
// `sqlite` for single-node / dev; `mysql` for self-host with MariaDB.
|
||||
// `sqlite` for single-node/dev, `mysql` for MariaDB/MySQL,
|
||||
// or `postgres` for PostgreSQL.
|
||||
//
|
||||
// NOTE: `postgres` support is a community contribution and is not
|
||||
// exercised by Puter.com production. It boots, passes its own
|
||||
// integration tests against pgmock, and runs the common user/app/
|
||||
// fsentry/session/permission/OIDC flows — but less-traveled code
|
||||
// paths may surface MySQL/SQLite-isms that haven't been ported yet.
|
||||
// Expect rough edges and please file issues if you hit one. For
|
||||
// production self-hosting today, `mysql` (MariaDB) and `sqlite` are
|
||||
// the supported defaults.
|
||||
"engine": "sqlite",
|
||||
// sqlite — file path on disk
|
||||
"path": "volatile/runtime/puter-database.sqlite",
|
||||
"targetVersion": 0,
|
||||
// mysql — connection details
|
||||
// mysql/postgres — connection details. PostgreSQL defaults to port
|
||||
// 5432 when `engine` is `postgres`; MySQL/MariaDB normally use 3306.
|
||||
"host": "",
|
||||
"port": 3306,
|
||||
"user": "",
|
||||
"password": "",
|
||||
"database": "",
|
||||
// postgres may also use a URL instead of host/user/password fields:
|
||||
// "connectionString": "postgres://puter:secret@localhost:5432/puter",
|
||||
// Optional read-replica pool. Reads route here when populated.
|
||||
"replica": {
|
||||
"host": "",
|
||||
@@ -178,9 +191,10 @@
|
||||
"password": "",
|
||||
"database": ""
|
||||
}
|
||||
// mysql self-host bootstrap: set `migrationPaths` to apply the bundled
|
||||
// schema on first boot. Idempotent — safe to leave on.
|
||||
// mysql/postgres self-host bootstrap: set `migrationPaths` to apply the
|
||||
// bundled schema on first boot. Idempotent — safe to leave on.
|
||||
// "migrationPaths": ["./src/backend/clients/database/migrations/mysql"]
|
||||
// "migrationPaths": ["./src/backend/clients/database/migrations/postgres"]
|
||||
},
|
||||
|
||||
// ── DynamoDB (KV store) ─────────────────────────────────────────────
|
||||
|
||||
+21
-1
@@ -136,7 +136,7 @@ Why these knobs:
|
||||
|
||||
- `jwt_secret` + `jwt_secret_v2` — Puter signs new auth tokens with `jwt_secret_v2` (v2 token format, `kid: 'v2'` JWT header). `jwt_secret` is verify-only and lets tokens minted before this version (cookies still in users' browsers, stored API tokens) keep working. `allow_v1_tokens: true` keeps that fallback enabled. Fresh installs can leave it as-is; future versions will flip the flag to `false` and retire `jwt_secret` entirely once legacy tokens have drained.
|
||||
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start=gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
|
||||
- `database.migrationPaths` — Puter applies the bundled MySQL schema on boot. `mysql_mig_1.sql` (tables) and `mysql_mig_2.sql` (default apps: editor, viewer, pdf, camera, player, recorder, git, dev-center, puter-linux). Idempotent — safe to re-run.
|
||||
- `database.migrationPaths` — Puter applies the bundled MySQL/MariaDB schema on boot. The migration files are idempotent, so it is safe to leave this configured across restarts.
|
||||
- `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.
|
||||
- `dynamo.aws` keys are dummies; DynamoDB-local doesn't validate them but the AWS SDK requires _something_. **Note:** DynamoDB uses `access_key` / `secret_key` (snake_case); S3 below uses `accessKeyId` / `secretAccessKey` (camelCase). Not interchangeable.
|
||||
- `providers.ollama.enabled: false` — Puter auto-probes a local Ollama at `127.0.0.1:11434` by default; without one running you'd see `ECONNREFUSED` on every boot. To run a bundled Ollama, see [Optional: local LLM (Ollama)](#optional-local-llm-ollama) below.
|
||||
@@ -232,6 +232,26 @@ Change it in Settings after first login.
|
||||
|
||||
All optional. Drop any of the blocks below into `puter/config/config.json` and `docker compose restart puter`. See [config.template.jsonc](../config.template.jsonc) for the full list. Per-key documentation lives in [src/backend/types.ts](../src/backend/types.ts).
|
||||
|
||||
### PostgreSQL database
|
||||
|
||||
> **Community contribution — expect rough edges.** PostgreSQL support was contributed by the community and is not run by Puter.com production. It boots, applies the bundled schema, and exercises the common user/app/fsentry/session/permission/OIDC flows in the integration tests, but less-traveled SQL call sites may still need porting. If you hit a query that doesn't work on Postgres, please open an issue. For production self-hosting today, MariaDB/MySQL and SQLite are the supported defaults.
|
||||
|
||||
The bundled Docker Compose stack still defaults to MariaDB. To use PostgreSQL instead, run a PostgreSQL service yourself, point Puter at it, and use the PostgreSQL migration path:
|
||||
|
||||
```json
|
||||
"database": {
|
||||
"engine": "postgres",
|
||||
"host": "postgres",
|
||||
"port": 5432,
|
||||
"user": "puter",
|
||||
"password": "...",
|
||||
"database": "puter",
|
||||
"migrationPaths": ["/opt/puter/dist/src/backend/clients/database/migrations/postgres"]
|
||||
}
|
||||
```
|
||||
|
||||
You may use `"connectionString": "postgres://puter:...@postgres:5432/puter"` instead of the host/user/password fields. Puter only needs normal PostgreSQL connection details and the migration path; CloudNativePG is one possible Kubernetes operator, but no operator-specific manifests are required by Puter.
|
||||
|
||||
### Email (SMTP)
|
||||
|
||||
Used for password resets, email confirmation, and notifications. Without it those flows silently fail.
|
||||
|
||||
@@ -84,7 +84,7 @@ export const handleAppTelemetryUsers = async (
|
||||
|
||||
const users = await clients.db.read(
|
||||
`SELECT u.username, u.uuid FROM user_to_app_permissions p
|
||||
INNER JOIN user u ON p.user_id = u.id
|
||||
INNER JOIN ${clients.db.quoteIdentifier('user')} u ON p.user_id = u.id
|
||||
WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ?
|
||||
ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id
|
||||
LIMIT ? OFFSET ?`,
|
||||
|
||||
Generated
+71
-3
@@ -14932,6 +14932,46 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
|
||||
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.13.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.14.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
|
||||
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
@@ -14941,10 +14981,19 @@
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
||||
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
|
||||
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
@@ -14963,6 +15012,22 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgmock": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pgmock/-/pgmock-1.0.3.tgz",
|
||||
"integrity": "sha512-5Lo17esUvwOq9TvAZMeFZRB69+QiSCpLrdwbsxDqGoj6yVZi+6umMiJvFiWwgo+YlNfWe1kY9Djw360T/J0AWg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -18339,6 +18404,7 @@
|
||||
"openai": "^6.34.0",
|
||||
"otpauth": "^9.2.4",
|
||||
"parse-domain": "^8.2.2",
|
||||
"pg": "^8.21.0",
|
||||
"prompt-sync": "^4.2.0",
|
||||
"replicate": "^1.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
@@ -18355,9 +18421,11 @@
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pg": "^8.6.1",
|
||||
"@types/validator": "^13.15.10",
|
||||
"chai": "^4.3.7",
|
||||
"nodemon": "^3.1.0",
|
||||
"pgmock": "^1.0.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.0.14"
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test:backend": "vitest run --config src/backend/vitest.config.ts ",
|
||||
"test:backend:postgres": "PUTER_TEST_DB_ENGINE=postgres vitest run --config src/backend/vitest.config.ts ",
|
||||
"start=gui": "nodemon --exec \"node dev-server.js\" ",
|
||||
"start": "node --enable-source-maps -r ./dist/src/backend/telemetry.js ./dist/src/backend/index.js",
|
||||
"prestart": "npm run build:ts",
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface BatchEntry {
|
||||
values: unknown[];
|
||||
}
|
||||
|
||||
type SqlJsonPath = readonly [string, ...string[]];
|
||||
|
||||
/**
|
||||
* Base database client. Subclasses must override every method that throws here.
|
||||
*
|
||||
@@ -101,9 +103,10 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
const cols = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const sql =
|
||||
`INSERT INTO \`${tableName}\` ` +
|
||||
`(${cols.map((c) => `\`${c}\``).join(', ')}) ` +
|
||||
`VALUES (${cols.map(() => '?').join(', ')})`;
|
||||
`INSERT INTO ${this.quoteIdentifier(tableName)} ` +
|
||||
`(${cols.map((c) => this.quoteIdentifier(c)).join(', ')}) ` +
|
||||
`VALUES (${cols.map(() => '?').join(', ')})` +
|
||||
this.returningIdClause();
|
||||
return this.write(sql, values);
|
||||
}
|
||||
|
||||
@@ -161,4 +164,88 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
}
|
||||
return choices.otherwise as T;
|
||||
}
|
||||
|
||||
quoteIdentifier(identifier: string): string {
|
||||
return identifier
|
||||
.split('.')
|
||||
.map((part) => {
|
||||
if (part === '*') return part;
|
||||
return `\`${part.replaceAll('`', '``')}\``;
|
||||
})
|
||||
.join('.');
|
||||
}
|
||||
|
||||
booleanLiteral(value: boolean): string {
|
||||
return value ? '1' : '0';
|
||||
}
|
||||
|
||||
booleanValue(value: boolean): boolean | 0 | 1 {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
|
||||
insertIgnoreInto(tableName: string): string {
|
||||
const table = this.quoteIdentifier(tableName);
|
||||
return this.case({
|
||||
sqlite: `INSERT OR IGNORE INTO ${table}`,
|
||||
postgres: `INSERT INTO ${table}`,
|
||||
otherwise: `INSERT IGNORE INTO ${table}`,
|
||||
});
|
||||
}
|
||||
|
||||
insertIgnoreSuffix(): string {
|
||||
return this.case({
|
||||
postgres: ' ON CONFLICT DO NOTHING',
|
||||
otherwise: '',
|
||||
});
|
||||
}
|
||||
|
||||
upsertClause(
|
||||
conflictColumns: readonly string[],
|
||||
updateColumns: readonly string[],
|
||||
): string {
|
||||
if (updateColumns.length === 0) {
|
||||
throw new Error('upsertClause requires at least one update column');
|
||||
}
|
||||
|
||||
const updateList = updateColumns
|
||||
.map((column) => `${this.quoteIdentifier(column)} = ?`)
|
||||
.join(', ');
|
||||
|
||||
return this.case({
|
||||
mysql: `ON DUPLICATE KEY UPDATE ${updateList}`,
|
||||
otherwise: `ON CONFLICT(${conflictColumns
|
||||
.map((column) => this.quoteIdentifier(column))
|
||||
.join(', ')}) DO UPDATE SET ${updateList}`,
|
||||
});
|
||||
}
|
||||
|
||||
jsonTextExtract(jsonExpression: string, path: SqlJsonPath): string {
|
||||
const sqlitePath = `$${path.map((part) => `.${part}`).join('')}`;
|
||||
return this.case({
|
||||
sqlite: `json_extract(${jsonExpression}, ${this.sqlStringLiteral(sqlitePath)})`,
|
||||
mysql: `JSON_UNQUOTE(JSON_EXTRACT(${jsonExpression}, ${this.sqlStringLiteral(sqlitePath)}))`,
|
||||
postgres: `${jsonExpression} #>> ARRAY[${path
|
||||
.map((part) => this.sqlStringLiteral(part))
|
||||
.join(', ')}]`,
|
||||
otherwise: `JSON_UNQUOTE(JSON_EXTRACT(${jsonExpression}, ${this.sqlStringLiteral(sqlitePath)}))`,
|
||||
});
|
||||
}
|
||||
|
||||
nullCoalesce(...expressions: readonly string[]): string {
|
||||
if (expressions.length === 0) {
|
||||
throw new Error('nullCoalesce requires at least one expression');
|
||||
}
|
||||
return `COALESCE(${expressions.join(', ')})`;
|
||||
}
|
||||
|
||||
returningIdClause(): string {
|
||||
return this.case({
|
||||
postgres: ' RETURNING id',
|
||||
otherwise: '',
|
||||
});
|
||||
}
|
||||
|
||||
protected sqlStringLiteral(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { createPool, type Pool } from 'mysql2';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
import { SQLBatcher } from './SQLBatcher.js';
|
||||
import { splitMysqlStatements } from './splitMysqlStatements.js';
|
||||
import { compareMigrationFilenames } from './migrationFilenames.js';
|
||||
import type { IConfig } from '../../types';
|
||||
|
||||
const RETRIABLE_ERROR_CODES = new Set([
|
||||
@@ -44,36 +45,7 @@ const RETRIABLE_ERROR_MESSAGES = [
|
||||
'ETIMEDOUT',
|
||||
];
|
||||
|
||||
/**
|
||||
* Comparator for MySQL migration filenames.
|
||||
*
|
||||
* Existing files are named `mysql_mig_<N>.sql` with unpadded N, so a
|
||||
* plain lexical sort puts `mysql_mig_10.sql` BEFORE `mysql_mig_2.sql`.
|
||||
* Pull the trailing integer out and sort numerically. Anything that
|
||||
* doesn't match the `_<digits>.sql` shape (one-off, vendor dump) falls
|
||||
* back to lexical comparison so the order stays deterministic, and
|
||||
* unmatched names sort *after* numbered files so future numbered
|
||||
* migrations don't get interleaved into a one-off's namespace.
|
||||
*
|
||||
* Exported only for the unit test — the production caller is the
|
||||
* `runMigrations()` loop inside this file.
|
||||
*/
|
||||
export const compareMigrationFilenames = (a: string, b: string): number => {
|
||||
const numericIndex = (name: string): number => {
|
||||
const m = /_(\d+)\.sql$/.exec(name);
|
||||
return m ? Number.parseInt(m[1], 10) : Number.NaN;
|
||||
};
|
||||
const na = numericIndex(a);
|
||||
const nb = numericIndex(b);
|
||||
if (Number.isFinite(na) && Number.isFinite(nb)) {
|
||||
if (na !== nb) return na - nb;
|
||||
} else if (Number.isFinite(na)) {
|
||||
return -1;
|
||||
} else if (Number.isFinite(nb)) {
|
||||
return 1;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
};
|
||||
export { compareMigrationFilenames };
|
||||
|
||||
type PoolConfig = Parameters<typeof createPool>[0];
|
||||
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { IConfig } from '../../types';
|
||||
import type { PuterServer } from '../../server';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import {
|
||||
createPgMockPostgresDatabaseClient,
|
||||
POSTGRES_TEST_MIGRATIONS_PATH,
|
||||
setupTestServer,
|
||||
} from '../../testUtil.js';
|
||||
import { PostgresDatabaseClient } from './PostgresDatabaseClient.js';
|
||||
|
||||
const postgresUrl = process.env.PUTER_TEST_POSTGRES_URL;
|
||||
const postgresMigrationsPath = POSTGRES_TEST_MIGRATIONS_PATH;
|
||||
const postgresTestSchemaPattern = /^puter_test_[a-f0-9]{32}$/u;
|
||||
const postgresIntegrationTimeoutMs = 180_000;
|
||||
|
||||
let postgresTestSchema: string | undefined;
|
||||
let postgresTestUrl: string | undefined;
|
||||
|
||||
const postgresConfig = (overrides: Partial<IConfig> = {}): IConfig => {
|
||||
if (postgresUrl && !postgresTestUrl) {
|
||||
throw new Error('Postgres test schema was not initialized');
|
||||
}
|
||||
|
||||
const { database: databaseOverrides, ...rootOverrides } = overrides;
|
||||
const database = postgresUrl
|
||||
? {
|
||||
engine: 'postgres' as const,
|
||||
inMemory: false,
|
||||
connectionString: postgresTestUrl,
|
||||
migrationPaths: [postgresMigrationsPath],
|
||||
}
|
||||
: {
|
||||
engine: 'postgres' as const,
|
||||
inMemory: true,
|
||||
migrationPaths: [postgresMigrationsPath],
|
||||
};
|
||||
|
||||
return {
|
||||
port: 0,
|
||||
extensions: [],
|
||||
database: { ...database, ...(databaseOverrides ?? {}) },
|
||||
...rootOverrides,
|
||||
};
|
||||
};
|
||||
|
||||
const quoteTestSchemaIdentifier = (schema: string): string => {
|
||||
if (!postgresTestSchemaPattern.test(schema)) {
|
||||
throw new Error(`Unsafe Postgres test schema name: ${schema}`);
|
||||
}
|
||||
return `"${schema}"`;
|
||||
};
|
||||
|
||||
const postgresConnectionStringForSchema = (
|
||||
connectionString: string,
|
||||
schema: string,
|
||||
): string => {
|
||||
if (!postgresTestSchemaPattern.test(schema)) {
|
||||
throw new Error(`Unsafe Postgres test schema name: ${schema}`);
|
||||
}
|
||||
|
||||
const url = new URL(connectionString);
|
||||
url.searchParams.set('options', `-c search_path=${schema}`);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const createPostgresTestSchema = async (): Promise<void> => {
|
||||
if (!postgresUrl) return;
|
||||
|
||||
const schema = `puter_test_${uuidv4().replaceAll('-', '')}`;
|
||||
const pool = new Pool({ connectionString: postgresUrl });
|
||||
try {
|
||||
await pool.query(`CREATE SCHEMA ${quoteTestSchemaIdentifier(schema)}`);
|
||||
postgresTestSchema = schema;
|
||||
postgresTestUrl = postgresConnectionStringForSchema(
|
||||
postgresUrl,
|
||||
schema,
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
};
|
||||
|
||||
const dropPostgresTestSchema = async (): Promise<void> => {
|
||||
if (!postgresUrl || !postgresTestSchema) return;
|
||||
|
||||
const schema = postgresTestSchema;
|
||||
postgresTestSchema = undefined;
|
||||
postgresTestUrl = undefined;
|
||||
|
||||
const pool = new Pool({ connectionString: postgresUrl });
|
||||
try {
|
||||
await pool.query(
|
||||
`DROP SCHEMA IF EXISTS ${quoteTestSchemaIdentifier(schema)} CASCADE`,
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
};
|
||||
|
||||
describe('PostgresDatabaseClient integration', () => {
|
||||
let server: PuterServer | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
await createPostgresTestSchema();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await server?.shutdown();
|
||||
} finally {
|
||||
server = undefined;
|
||||
await dropPostgresTestSchema();
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
'applies the native migrations idempotently to an empty database',
|
||||
async () => {
|
||||
const config = postgresConfig();
|
||||
const pgMockClient = postgresUrl
|
||||
? undefined
|
||||
: await createPgMockPostgresDatabaseClient(config);
|
||||
try {
|
||||
const firstClient =
|
||||
pgMockClient?.client ?? new PostgresDatabaseClient(config);
|
||||
try {
|
||||
await firstClient.onServerStart();
|
||||
} finally {
|
||||
await firstClient.onServerShutdown();
|
||||
}
|
||||
|
||||
const secondClient =
|
||||
pgMockClient?.createClient() ??
|
||||
new PostgresDatabaseClient(config);
|
||||
try {
|
||||
await secondClient.onServerStart();
|
||||
const [systemUser] = await secondClient.read(
|
||||
'SELECT `id`, `username` FROM `user` WHERE `username` = ?',
|
||||
['system'],
|
||||
);
|
||||
const [devCenter] = await secondClient.read(
|
||||
'SELECT `name`, `index_url` FROM `apps` WHERE `name` = ?',
|
||||
['dev-center'],
|
||||
);
|
||||
|
||||
expect(systemUser).toMatchObject({
|
||||
id: 1,
|
||||
username: 'system',
|
||||
});
|
||||
expect(devCenter).toMatchObject({
|
||||
name: 'dev-center',
|
||||
index_url:
|
||||
'https://builtins.namespaces.puter.com/dev-center',
|
||||
});
|
||||
} finally {
|
||||
await secondClient.onServerShutdown();
|
||||
}
|
||||
} finally {
|
||||
pgMockClient?.destroy();
|
||||
}
|
||||
},
|
||||
postgresIntegrationTimeoutMs,
|
||||
);
|
||||
|
||||
it('starts the server and exercises user, app, fsentry, permission, and session flows', async () => {
|
||||
const userStorageAllowance = 123_456_789;
|
||||
server = await setupTestServer(
|
||||
postgresConfig({
|
||||
no_default_user: false,
|
||||
is_storage_limited: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const admin = await server.stores.user.getByUsername('admin');
|
||||
expect(admin?.username).toBe('admin');
|
||||
|
||||
const username = `pg-${uuidv4().slice(0, 8)}`;
|
||||
const createdUser = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
free_storage: userStorageAllowance,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
await generateDefaultFsentries(
|
||||
server.clients.db,
|
||||
server.stores.user,
|
||||
createdUser,
|
||||
);
|
||||
const user = await server.stores.user.getById(createdUser.id);
|
||||
if (!user) throw new Error('created user was not readable');
|
||||
expect(user.username).toBe(username);
|
||||
await expect(
|
||||
server.stores.fsEntry.getUserStorageAllowance(user.id),
|
||||
).resolves.toMatchObject({
|
||||
max: userStorageAllowance,
|
||||
});
|
||||
|
||||
const otherUsername = `pg-other-${uuidv4().slice(0, 8)}`;
|
||||
const otherUser = await server.stores.user.create({
|
||||
username: otherUsername,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${otherUsername}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
|
||||
const authResult = await server.services.auth.createSessionToken(
|
||||
user,
|
||||
{
|
||||
ip: '127.0.0.1',
|
||||
user_agent: 'postgres-integration-test',
|
||||
},
|
||||
);
|
||||
const authenticated =
|
||||
await server.services.auth.authenticateFromToken(authResult.token);
|
||||
expect(authenticated?.user?.id).toBe(user?.id);
|
||||
|
||||
const devCenter = await server.stores.app.getByName('dev-center');
|
||||
if (!devCenter) throw new Error('dev-center app was not seeded');
|
||||
expect(devCenter.name).toBe('dev-center');
|
||||
|
||||
const documents = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
);
|
||||
if (!documents) throw new Error('Documents directory was not created');
|
||||
expect(documents.isDir).toBe(true);
|
||||
const folder = await server.stores.fsEntry.createNonFileEntry({
|
||||
userId: user.id,
|
||||
parent: documents,
|
||||
name: 'postgres-folder',
|
||||
kind: 'directory',
|
||||
});
|
||||
const renamedFolder = await server.stores.fsEntry.updateEntry(
|
||||
folder.uuid,
|
||||
{
|
||||
name: 'postgres-folder-renamed',
|
||||
path: `/${username}/Documents/postgres-folder-renamed`,
|
||||
},
|
||||
);
|
||||
expect(renamedFolder.name).toBe('postgres-folder-renamed');
|
||||
|
||||
await server.stores.permission.upsertUserAppPerm(
|
||||
user.id,
|
||||
Number(devCenter.id),
|
||||
'driver:postgres-integration',
|
||||
{ ok: true },
|
||||
);
|
||||
await expect(
|
||||
server.stores.permission.hasUserAppPerm(
|
||||
user.id,
|
||||
Number(devCenter.id),
|
||||
'driver:postgres-integration',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
await server.stores.oidc.link(
|
||||
user.id,
|
||||
'postgres-integration-test',
|
||||
'subject-1',
|
||||
null,
|
||||
);
|
||||
await expect(
|
||||
server.stores.oidc.link(
|
||||
user.id,
|
||||
'postgres-integration-test',
|
||||
'subject-1',
|
||||
null,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
server.stores.oidc.link(
|
||||
otherUser.id,
|
||||
'postgres-integration-test',
|
||||
'subject-1',
|
||||
null,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
legacyCode: 'conflict',
|
||||
});
|
||||
|
||||
const session = await server.stores.session.create(user.id, {
|
||||
meta: { source: 'postgres-integration-test' },
|
||||
});
|
||||
const activeSession = await server.stores.session.getByUuid(
|
||||
session.uuid,
|
||||
);
|
||||
expect(activeSession?.uuid).toBe(session.uuid);
|
||||
|
||||
const workerName = `worker-${uuidv4().slice(0, 8)}`;
|
||||
const workerSession = await server.stores.session.getOrCreateWorker(
|
||||
user.id,
|
||||
{ workerName },
|
||||
);
|
||||
expect(workerSession?.kind).toBe('worker');
|
||||
expect(workerSession?.meta?.worker_name).toBe(workerName);
|
||||
|
||||
await server.stores.session.removeByUuid(session.uuid);
|
||||
await expect(
|
||||
server.stores.session.getByUuid(session.uuid),
|
||||
).resolves.toBeNull();
|
||||
}, postgresIntegrationTimeoutMs);
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { FieldDef, QueryResult } from 'pg';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import { DatabaseClientFactory } from './index.js';
|
||||
import {
|
||||
mapPostgresWriteResult,
|
||||
PostgresDatabaseClient,
|
||||
type PostgresPool,
|
||||
type PostgresPoolClient,
|
||||
} from './PostgresDatabaseClient.js';
|
||||
|
||||
type QueryCall = {
|
||||
text: string;
|
||||
values?: unknown[];
|
||||
};
|
||||
|
||||
const postgresConfig = (): IConfig => ({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
database: {
|
||||
engine: 'postgres',
|
||||
migrationPaths: [],
|
||||
},
|
||||
});
|
||||
|
||||
const postgresReplicaConfig = (): IConfig => ({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
database: {
|
||||
engine: 'postgres',
|
||||
migrationPaths: [],
|
||||
replica: {},
|
||||
},
|
||||
});
|
||||
|
||||
const field = (name: string, dataTypeID: number): FieldDef => ({
|
||||
name,
|
||||
tableID: 0,
|
||||
columnID: 0,
|
||||
dataTypeID,
|
||||
dataTypeSize: -1,
|
||||
dataTypeModifier: -1,
|
||||
format: 'text',
|
||||
});
|
||||
|
||||
const int8Field = (name: string): FieldDef => field(name, 20);
|
||||
const textField = (name: string): FieldDef => field(name, 25);
|
||||
|
||||
const queryResult = (
|
||||
rows: Record<string, unknown>[] = [],
|
||||
rowCount = rows.length,
|
||||
fields: FieldDef[] = [],
|
||||
): QueryResult<Record<string, unknown>> => ({
|
||||
command: '',
|
||||
fields,
|
||||
oid: 0,
|
||||
rowCount,
|
||||
rows,
|
||||
});
|
||||
|
||||
class RecordingPoolClient implements PostgresPoolClient {
|
||||
readonly calls: QueryCall[] = [];
|
||||
released = false;
|
||||
|
||||
constructor(private readonly failOnText?: string) {}
|
||||
|
||||
async query(
|
||||
text: string,
|
||||
values?: unknown[],
|
||||
): Promise<QueryResult<Record<string, unknown>>> {
|
||||
this.calls.push({ text, values });
|
||||
if (this.failOnText && text.includes(this.failOnText)) {
|
||||
throw new Error(`forced query failure: ${text}`);
|
||||
}
|
||||
return queryResult([], text === 'ROLLBACK' ? 0 : 1);
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.released = true;
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingPool implements PostgresPool {
|
||||
readonly calls: QueryCall[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly client: RecordingPoolClient =
|
||||
new RecordingPoolClient(),
|
||||
private readonly nextResult: QueryResult<Record<string, unknown>> =
|
||||
queryResult(),
|
||||
) {}
|
||||
|
||||
async query(
|
||||
text: string,
|
||||
values?: unknown[],
|
||||
): Promise<QueryResult<Record<string, unknown>>> {
|
||||
this.calls.push({ text, values });
|
||||
if (text === 'SELECT 1') return queryResult([{ ok: 1 }], 1);
|
||||
return this.nextResult;
|
||||
}
|
||||
|
||||
async connect(): Promise<PostgresPoolClient> {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async end(): Promise<void> {}
|
||||
}
|
||||
|
||||
describe('PostgresDatabaseClient', () => {
|
||||
it('is selected by the database factory', () => {
|
||||
const client = new DatabaseClientFactory(postgresConfig());
|
||||
expect(client).toBeInstanceOf(PostgresDatabaseClient);
|
||||
});
|
||||
|
||||
it('maps pg write results to the shared WriteResult shape', () => {
|
||||
expect(
|
||||
mapPostgresWriteResult(queryResult([{ id: '42' }], 1)),
|
||||
).toEqual({
|
||||
insertId: 42,
|
||||
affectedRows: 1,
|
||||
anyRowsAffected: true,
|
||||
});
|
||||
|
||||
expect(mapPostgresWriteResult(queryResult([], 0))).toEqual({
|
||||
insertId: 0,
|
||||
affectedRows: 0,
|
||||
anyRowsAffected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('prepares write SQL at the pg boundary', async () => {
|
||||
const pool = new RecordingPool(
|
||||
new RecordingPoolClient(),
|
||||
queryResult([{ id: '7' }], 1),
|
||||
);
|
||||
const client = new PostgresDatabaseClient(postgresConfig(), () => pool);
|
||||
await client.onServerStart();
|
||||
|
||||
const result = await client.write(
|
||||
'INSERT INTO `apps` (`name`) VALUES (?) RETURNING id',
|
||||
['editor'],
|
||||
);
|
||||
|
||||
expect(result.insertId).toBe(7);
|
||||
expect(pool.calls.at(-1)).toEqual({
|
||||
text: 'INSERT INTO "apps" ("name") VALUES ($1) RETURNING id',
|
||||
values: ['editor'],
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes int8 fields on read and primary read rows', async () => {
|
||||
const pool = new RecordingPool(
|
||||
new RecordingPoolClient(),
|
||||
queryResult(
|
||||
[
|
||||
{
|
||||
uuid: 'session-1',
|
||||
created_at: '1710000000',
|
||||
last_activity: '1710000001',
|
||||
expires_at: '1710000002',
|
||||
revoked_at: null,
|
||||
},
|
||||
],
|
||||
1,
|
||||
[
|
||||
textField('uuid'),
|
||||
int8Field('created_at'),
|
||||
int8Field('last_activity'),
|
||||
int8Field('expires_at'),
|
||||
int8Field('revoked_at'),
|
||||
],
|
||||
),
|
||||
);
|
||||
const client = new PostgresDatabaseClient(postgresConfig(), () => pool);
|
||||
await client.onServerStart();
|
||||
|
||||
await expect(client.read('SELECT * FROM `sessions`')).resolves.toEqual(
|
||||
[
|
||||
{
|
||||
uuid: 'session-1',
|
||||
created_at: 1710000000,
|
||||
last_activity: 1710000001,
|
||||
expires_at: 1710000002,
|
||||
revoked_at: null,
|
||||
},
|
||||
],
|
||||
);
|
||||
await expect(client.pread('SELECT * FROM `sessions`')).resolves.toEqual(
|
||||
[
|
||||
{
|
||||
uuid: 'session-1',
|
||||
created_at: 1710000000,
|
||||
last_activity: 1710000001,
|
||||
expires_at: 1710000002,
|
||||
revoked_at: null,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unsafe int8 values instead of losing precision', async () => {
|
||||
const pool = new RecordingPool(
|
||||
new RecordingPoolClient(),
|
||||
queryResult(
|
||||
[{ id: '9007199254740992' }],
|
||||
1,
|
||||
[int8Field('id')],
|
||||
),
|
||||
);
|
||||
const client = new PostgresDatabaseClient(postgresConfig(), () => pool);
|
||||
await client.onServerStart();
|
||||
|
||||
await expect(client.read('SELECT `id` FROM `sessions`')).rejects.toThrow(
|
||||
'safe integer',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes tryHardRead rows returned by a replica', async () => {
|
||||
const primaryPool = new RecordingPool(
|
||||
new RecordingPoolClient(),
|
||||
queryResult([{ created_at: '1' }], 1, [
|
||||
int8Field('created_at'),
|
||||
]),
|
||||
);
|
||||
const replicaPool = new RecordingPool(
|
||||
new RecordingPoolClient(),
|
||||
queryResult([{ created_at: '2' }], 1, [
|
||||
int8Field('created_at'),
|
||||
]),
|
||||
);
|
||||
const pools = [primaryPool, replicaPool];
|
||||
let nextPoolIndex = 0;
|
||||
const client = new PostgresDatabaseClient(
|
||||
postgresReplicaConfig(),
|
||||
() => {
|
||||
const pool = pools[nextPoolIndex];
|
||||
nextPoolIndex += 1;
|
||||
if (!pool) throw new Error('unexpected pool factory call');
|
||||
return pool;
|
||||
},
|
||||
);
|
||||
await client.onServerStart();
|
||||
|
||||
await expect(
|
||||
client.tryHardRead('SELECT `created_at` FROM `sessions`'),
|
||||
).resolves.toEqual([{ created_at: 2 }]);
|
||||
});
|
||||
|
||||
it('normalizes tryHardRead rows returned by primary fallback', async () => {
|
||||
const primaryPool = new RecordingPool(
|
||||
new RecordingPoolClient(),
|
||||
queryResult([{ created_at: '3' }], 1, [
|
||||
int8Field('created_at'),
|
||||
]),
|
||||
);
|
||||
const replicaPool = new RecordingPool(
|
||||
new RecordingPoolClient(),
|
||||
queryResult([], 0, [int8Field('created_at')]),
|
||||
);
|
||||
const pools = [primaryPool, replicaPool];
|
||||
let nextPoolIndex = 0;
|
||||
const client = new PostgresDatabaseClient(
|
||||
postgresReplicaConfig(),
|
||||
() => {
|
||||
const pool = pools[nextPoolIndex];
|
||||
nextPoolIndex += 1;
|
||||
if (!pool) throw new Error('unexpected pool factory call');
|
||||
return pool;
|
||||
},
|
||||
);
|
||||
await client.onServerStart();
|
||||
|
||||
await expect(
|
||||
client.tryHardRead('SELECT `created_at` FROM `sessions`'),
|
||||
).resolves.toEqual([{ created_at: 3 }]);
|
||||
});
|
||||
|
||||
it('runs batch writes in order and commits', async () => {
|
||||
const conn = new RecordingPoolClient();
|
||||
const client = new PostgresDatabaseClient(
|
||||
postgresConfig(),
|
||||
() => new RecordingPool(conn),
|
||||
);
|
||||
await client.onServerStart();
|
||||
|
||||
await client.batchWrite([
|
||||
{
|
||||
statement:
|
||||
'UPDATE `user` SET `username` = ? WHERE `id` = ?',
|
||||
values: ['ada', 1],
|
||||
},
|
||||
{
|
||||
statement: 'DELETE FROM `sessions` WHERE `uuid` = ?',
|
||||
values: ['session-1'],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(conn.calls).toEqual([
|
||||
{ text: 'BEGIN', values: undefined },
|
||||
{
|
||||
text: 'UPDATE "user" SET "username" = $1 WHERE "id" = $2',
|
||||
values: ['ada', 1],
|
||||
},
|
||||
{
|
||||
text: 'DELETE FROM "sessions" WHERE "uuid" = $1',
|
||||
values: ['session-1'],
|
||||
},
|
||||
{ text: 'COMMIT', values: undefined },
|
||||
]);
|
||||
expect(conn.released).toBe(true);
|
||||
});
|
||||
|
||||
it('rolls back and releases the pg connection when a batch write fails', async () => {
|
||||
const conn = new RecordingPoolClient('UPDATE "user"');
|
||||
const client = new PostgresDatabaseClient(
|
||||
postgresConfig(),
|
||||
() => new RecordingPool(conn),
|
||||
);
|
||||
await client.onServerStart();
|
||||
|
||||
await expect(
|
||||
client.batchWrite([
|
||||
{
|
||||
statement:
|
||||
'UPDATE `user` SET `username` = ? WHERE `id` = ?',
|
||||
values: ['ada', 1],
|
||||
},
|
||||
]),
|
||||
).rejects.toThrow('forced query failure');
|
||||
|
||||
expect(conn.calls).toEqual([
|
||||
{ text: 'BEGIN', values: undefined },
|
||||
{
|
||||
text: 'UPDATE "user" SET "username" = $1 WHERE "id" = $2',
|
||||
values: ['ada', 1],
|
||||
},
|
||||
{ text: 'ROLLBACK', values: undefined },
|
||||
]);
|
||||
expect(conn.released).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { Pool, type PoolConfig, type QueryResult } from 'pg';
|
||||
import {
|
||||
AbstractDatabaseClient,
|
||||
type BatchEntry,
|
||||
type WriteResult,
|
||||
} from './DatabaseClient';
|
||||
import { compareMigrationFilenames } from './migrationFilenames.js';
|
||||
import { preparePostgresSql } from './preparePostgresSql.js';
|
||||
import { splitPostgresStatements } from './splitPostgresStatements.js';
|
||||
import type { IConfig } from '../../types';
|
||||
|
||||
type PostgresEndpointConfig = {
|
||||
host?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
database?: string;
|
||||
connectionString?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export interface PostgresQueryable {
|
||||
query(query: string, values?: unknown[]): Promise<QueryResult>;
|
||||
}
|
||||
|
||||
export interface PostgresPoolClient extends PostgresQueryable {
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export interface PostgresPool extends PostgresQueryable {
|
||||
connect(): Promise<PostgresPoolClient>;
|
||||
end(): Promise<void>;
|
||||
}
|
||||
|
||||
type PostgresPoolFactory = (poolConfig: PoolConfig) => PostgresPool;
|
||||
|
||||
enum Configuration {
|
||||
SINGLE,
|
||||
REPLICA,
|
||||
}
|
||||
|
||||
const POSTGRES_INT8_OID = 20;
|
||||
const INTEGER_TEXT_PATTERN = /^-?\d+$/u;
|
||||
|
||||
const normalizePostgresInt8 = (
|
||||
value: unknown,
|
||||
columnName: string,
|
||||
): number | null | undefined => {
|
||||
if (value === null || value === undefined) return value;
|
||||
|
||||
const parsed =
|
||||
typeof value === 'bigint'
|
||||
? Number(value)
|
||||
: typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string' && INTEGER_TEXT_PATTERN.test(value)
|
||||
? Number(value)
|
||||
: Number.NaN;
|
||||
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new Error(
|
||||
`[postgres] int8 column ${columnName} is outside JavaScript's safe integer range`,
|
||||
);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const normalizePostgresRows = (
|
||||
result: QueryResult,
|
||||
): Record<string, unknown>[] => {
|
||||
const int8Fields = result.fields.filter(
|
||||
(field) => field.dataTypeID === POSTGRES_INT8_OID,
|
||||
);
|
||||
if (int8Fields.length === 0) {
|
||||
return result.rows as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
return result.rows.map((row) => {
|
||||
const normalized: Record<string, unknown> = { ...row };
|
||||
for (const field of int8Fields) {
|
||||
normalized[field.name] = normalizePostgresInt8(
|
||||
normalized[field.name],
|
||||
field.name,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
};
|
||||
|
||||
export const mapPostgresWriteResult = (result: QueryResult): WriteResult => {
|
||||
const affectedRows = result.rowCount ?? 0;
|
||||
const rowId = result.rows[0]?.id;
|
||||
const insertId =
|
||||
typeof rowId === 'bigint'
|
||||
? rowId
|
||||
: typeof rowId === 'number'
|
||||
? rowId
|
||||
: typeof rowId === 'string' && rowId !== ''
|
||||
? Number(rowId)
|
||||
: 0;
|
||||
const normalizedInsertId =
|
||||
typeof insertId === 'number' && Number.isNaN(insertId) ? 0 : insertId;
|
||||
|
||||
return {
|
||||
insertId: normalizedInsertId,
|
||||
affectedRows,
|
||||
anyRowsAffected: affectedRows > 0,
|
||||
};
|
||||
};
|
||||
|
||||
export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
override readonly engineName = 'postgres';
|
||||
|
||||
private primaryPool!: PostgresPool;
|
||||
private replicaPool!: PostgresPool;
|
||||
private configuration = Configuration.SINGLE;
|
||||
private shutdownStarted = false;
|
||||
private shutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
config: IConfig,
|
||||
private readonly poolFactory: PostgresPoolFactory = (poolConfig) =>
|
||||
new Pool(poolConfig) as unknown as PostgresPool,
|
||||
) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
override async onServerStart(): Promise<void> {
|
||||
const dbConf = this.config.database!;
|
||||
|
||||
this.primaryPool = this.createPool(dbConf);
|
||||
await this.primaryPool.query('SELECT 1');
|
||||
console.log('[postgres] connected to primary');
|
||||
|
||||
if (dbConf.replica) {
|
||||
this.replicaPool = this.createPool(dbConf.replica);
|
||||
await this.replicaPool.query('SELECT 1');
|
||||
this.configuration = Configuration.REPLICA;
|
||||
console.log('[postgres] connected to read-replica');
|
||||
} else {
|
||||
this.replicaPool = this.primaryPool;
|
||||
this.configuration = Configuration.SINGLE;
|
||||
}
|
||||
|
||||
await this.runMigrations();
|
||||
}
|
||||
|
||||
override async onServerPrepareShutdown(): Promise<void> {
|
||||
if (this.shutdownStarted) return;
|
||||
this.shutdownStarted = true;
|
||||
|
||||
const drainMs = 60_000;
|
||||
console.log(
|
||||
`[postgres] draining in-flight queries (${drainMs}ms) before closing pools`,
|
||||
);
|
||||
|
||||
this.shutdownTimer = setTimeout(() => {
|
||||
this.shutdownTimer = null;
|
||||
this.closeCurrentPools().catch((e) =>
|
||||
console.error('[postgres] error closing pools after drain', e),
|
||||
);
|
||||
}, drainMs);
|
||||
|
||||
if (typeof this.shutdownTimer.unref === 'function') {
|
||||
this.shutdownTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
override async onServerShutdown(): Promise<void> {
|
||||
if (this.shutdownTimer) {
|
||||
clearTimeout(this.shutdownTimer);
|
||||
this.shutdownTimer = null;
|
||||
}
|
||||
await this.closeCurrentPools();
|
||||
}
|
||||
|
||||
override quoteIdentifier(identifier: string): string {
|
||||
return identifier
|
||||
.split('.')
|
||||
.map((part) => {
|
||||
if (part === '*') return part;
|
||||
return `"${part.replaceAll('"', '""')}"`;
|
||||
})
|
||||
.join('.');
|
||||
}
|
||||
|
||||
override booleanLiteral(value: boolean): string {
|
||||
return value ? 'TRUE' : 'FALSE';
|
||||
}
|
||||
|
||||
override booleanValue(value: boolean): boolean {
|
||||
return value;
|
||||
}
|
||||
|
||||
override async read(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const result = await this.query(this.replicaPool, query, params);
|
||||
return normalizePostgresRows(result);
|
||||
}
|
||||
|
||||
override async pread(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const result = await this.query(this.primaryPool, query, params);
|
||||
return normalizePostgresRows(result);
|
||||
}
|
||||
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<WriteResult> {
|
||||
const result = await this.query(this.primaryPool, query, params);
|
||||
return mapPostgresWriteResult(result);
|
||||
}
|
||||
|
||||
override async batchWrite(entries: BatchEntry[]): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
const conn = await this.primaryPool.connect();
|
||||
try {
|
||||
await conn.query('BEGIN');
|
||||
try {
|
||||
for (const { statement, values } of entries) {
|
||||
await this.query(conn, statement, values);
|
||||
}
|
||||
await conn.query('COMMIT');
|
||||
} catch (err) {
|
||||
await conn.query('ROLLBACK').catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
override async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
if (this.configuration === Configuration.SINGLE) {
|
||||
return this.read(query, params);
|
||||
}
|
||||
|
||||
const primaryPromise = this.query(this.primaryPool, query, params);
|
||||
try {
|
||||
const replicaResult = await this.query(
|
||||
this.replicaPool,
|
||||
query,
|
||||
params,
|
||||
);
|
||||
if (replicaResult.rows.length > 0) {
|
||||
primaryPromise.catch(() => {});
|
||||
return normalizePostgresRows(replicaResult);
|
||||
}
|
||||
} catch {
|
||||
// fall through to primary
|
||||
}
|
||||
|
||||
const primaryResult = await primaryPromise;
|
||||
return normalizePostgresRows(primaryResult);
|
||||
}
|
||||
|
||||
private async runMigrations(): Promise<void> {
|
||||
const paths = this.config.database?.migrationPaths;
|
||||
if (!paths || paths.length === 0) return;
|
||||
|
||||
const conn = await this.primaryPool.connect();
|
||||
try {
|
||||
for (const rawPath of paths) {
|
||||
const dir = isAbsolute(rawPath)
|
||||
? rawPath
|
||||
: resolvePath(process.cwd(), rawPath);
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(dir)
|
||||
.filter(
|
||||
(f) =>
|
||||
f.endsWith('.sql') && f.startsWith('postgres'),
|
||||
)
|
||||
.sort(compareMigrationFilenames);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[postgres] migration path is unreadable: ${dir}`,
|
||||
{ cause: e },
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log(`[postgres] no migrations in ${dir}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[postgres] running migrations from ${dir}: ${files.length} file(s)`,
|
||||
);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = resolvePath(dir, file);
|
||||
const contents = readFileSync(filePath, 'utf8');
|
||||
const statements = splitPostgresStatements(contents);
|
||||
await conn.query('BEGIN');
|
||||
try {
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
try {
|
||||
await conn.query(statements[i]);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[postgres] failed to apply ${file} at statement ${i}`,
|
||||
{ cause: e },
|
||||
);
|
||||
}
|
||||
}
|
||||
await conn.query('COMMIT');
|
||||
} catch (e) {
|
||||
await conn.query('ROLLBACK').catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
console.log(
|
||||
`[postgres] applied ${file} (${statements.length} statements)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
private createPool(dbConf: PostgresEndpointConfig): PostgresPool {
|
||||
const connectionString = dbConf.connectionString ?? dbConf.url;
|
||||
if (connectionString) {
|
||||
return this.poolFactory({
|
||||
connectionString,
|
||||
max: 30,
|
||||
});
|
||||
}
|
||||
|
||||
return this.poolFactory({
|
||||
host: dbConf.host ?? '127.0.0.1',
|
||||
port: dbConf.port ?? 5432,
|
||||
user: dbConf.user ?? 'postgres',
|
||||
password: dbConf.password ?? '',
|
||||
database: dbConf.database ?? 'puter',
|
||||
max: 30,
|
||||
});
|
||||
}
|
||||
|
||||
private async query(
|
||||
target: PostgresQueryable,
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<QueryResult> {
|
||||
const prepared = preparePostgresSql(query);
|
||||
return target.query(prepared.text, params);
|
||||
}
|
||||
|
||||
private async closeCurrentPools(): Promise<void> {
|
||||
const tasks: Promise<void>[] = [];
|
||||
if (this.primaryPool) tasks.push(this.primaryPool.end());
|
||||
if (this.replicaPool && this.replicaPool !== this.primaryPool) {
|
||||
tasks.push(this.replicaPool.end());
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,12 @@ export {
|
||||
} from './DatabaseClient';
|
||||
export { SqliteDatabaseClient } from './SqliteDatabaseClient';
|
||||
export { MySQLDatabaseClient } from './MySQLDatabaseClient';
|
||||
export { PostgresDatabaseClient } from './PostgresDatabaseClient';
|
||||
|
||||
import type { IConfig } from '../../types';
|
||||
import { AbstractDatabaseClient } from './DatabaseClient';
|
||||
import { MySQLDatabaseClient } from './MySQLDatabaseClient';
|
||||
import { PostgresDatabaseClient } from './PostgresDatabaseClient';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient';
|
||||
|
||||
/**
|
||||
@@ -41,6 +43,8 @@ export const DatabaseClientFactory = class DatabaseClientFactory {
|
||||
switch (engine) {
|
||||
case 'mysql':
|
||||
return new MySQLDatabaseClient(config);
|
||||
case 'postgres':
|
||||
return new PostgresDatabaseClient(config);
|
||||
case 'sqlite':
|
||||
return new SqliteDatabaseClient(config);
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Comparator for migration filenames named `<engine>_mig_<N>.sql`.
|
||||
*
|
||||
* Existing MySQL files use unpadded integers, so lexical sorting places
|
||||
* `*_mig_10.sql` before `*_mig_2.sql`. Pull the trailing integer out and sort
|
||||
* numerically. Anything that does not match the `_<digits>.sql` shape falls
|
||||
* back to lexical comparison and sorts after numbered files.
|
||||
*/
|
||||
export const compareMigrationFilenames = (a: string, b: string): number => {
|
||||
const numericIndex = (name: string): number => {
|
||||
const m = /_(\d+)\.sql$/.exec(name);
|
||||
return m ? Number.parseInt(m[1], 10) : Number.NaN;
|
||||
};
|
||||
const na = numericIndex(a);
|
||||
const nb = numericIndex(b);
|
||||
if (Number.isFinite(na) && Number.isFinite(nb)) {
|
||||
if (na !== nb) return na - nb;
|
||||
} else if (Number.isFinite(na)) {
|
||||
return -1;
|
||||
} else if (Number.isFinite(nb)) {
|
||||
return 1;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
};
|
||||
@@ -0,0 +1,724 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
uuid varchar(36) NOT NULL UNIQUE,
|
||||
username varchar(50) UNIQUE,
|
||||
email varchar(256),
|
||||
password varchar(225),
|
||||
free_storage bigint,
|
||||
max_subdomains integer,
|
||||
taskbar_items text,
|
||||
desktop_uuid varchar(36),
|
||||
appdata_uuid varchar(36),
|
||||
documents_uuid varchar(36),
|
||||
pictures_uuid varchar(36),
|
||||
videos_uuid varchar(36),
|
||||
trash_uuid varchar(36),
|
||||
trash_id integer,
|
||||
appdata_id integer,
|
||||
desktop_id integer,
|
||||
documents_id integer,
|
||||
pictures_id integer,
|
||||
videos_id integer,
|
||||
referrer varchar(64),
|
||||
desktop_bg_url text,
|
||||
desktop_bg_color varchar(20),
|
||||
desktop_bg_fit varchar(16),
|
||||
pass_recovery_token varchar(36),
|
||||
requires_email_confirmation boolean NOT NULL DEFAULT FALSE,
|
||||
email_confirm_code varchar(8),
|
||||
email_confirm_token varchar(36),
|
||||
email_confirmed boolean NOT NULL DEFAULT FALSE,
|
||||
dev_first_name varchar(100),
|
||||
dev_last_name varchar(100),
|
||||
dev_paypal varchar(100),
|
||||
dev_approved_for_incentive_program boolean DEFAULT FALSE,
|
||||
dev_joined_incentive_program boolean DEFAULT FALSE,
|
||||
suspended boolean,
|
||||
unsubscribed boolean NOT NULL DEFAULT FALSE,
|
||||
"timestamp" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_activity_ts timestamp,
|
||||
referral_code varchar(16) UNIQUE,
|
||||
referred_by integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
unconfirmed_change_email varchar(256),
|
||||
change_email_confirm_token varchar(256),
|
||||
otp_secret text,
|
||||
otp_enabled boolean DEFAULT FALSE,
|
||||
otp_recovery_codes text,
|
||||
stripe_customer_id varchar(40),
|
||||
public_uuid varchar(36),
|
||||
public_id integer,
|
||||
clean_email varchar(256),
|
||||
audit_metadata jsonb,
|
||||
signup_ip varchar(45),
|
||||
signup_ip_forwarded varchar(45),
|
||||
signup_user_agent varchar(512),
|
||||
signup_origin varchar(255),
|
||||
signup_server varchar(255),
|
||||
metadata jsonb DEFAULT '{}'::jsonb,
|
||||
reputation smallint DEFAULT 100
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_email ON "user" (email);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_pass_recovery_token ON "user" (pass_recovery_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_referrer ON "user" (referrer);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_email_confirm_token ON "user" (email_confirm_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_last_activity_ts ON "user" (last_activity_ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_referred_by ON "user" (referred_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_stripe_customer_id ON "user" (stripe_customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_clean_email ON "user" (clean_email);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_signup_ip ON "user" (signup_ip);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_signup_ip_forwarded ON "user" (signup_ip_forwarded);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_signup_user_agent ON "user" (signup_user_agent);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_signup_origin ON "user" (signup_origin);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_signup_server ON "user" (signup_server);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS apps (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
uid varchar(40) NOT NULL UNIQUE,
|
||||
owner_user_id integer REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
icon text,
|
||||
name varchar(100) NOT NULL UNIQUE,
|
||||
title varchar(100) NOT NULL,
|
||||
description text,
|
||||
godmode boolean DEFAULT FALSE,
|
||||
maximize_on_start boolean DEFAULT FALSE,
|
||||
index_url text NOT NULL,
|
||||
approved_for_listing boolean DEFAULT FALSE,
|
||||
approved_for_opening_items boolean DEFAULT FALSE,
|
||||
approved_for_incentive_program boolean DEFAULT FALSE,
|
||||
"timestamp" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_review timestamp,
|
||||
tags varchar(255),
|
||||
app_owner integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
background boolean DEFAULT FALSE,
|
||||
metadata jsonb,
|
||||
protected boolean DEFAULT FALSE,
|
||||
is_private boolean DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_owner_user_id ON apps (owner_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_app_owner ON apps (app_owner);
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_owner_timestamp ON apps (owner_user_id, "timestamp" DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_listing_timestamp ON apps (approved_for_listing, "timestamp" DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "group" (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
uid varchar(40) UNIQUE,
|
||||
owner_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
extra jsonb,
|
||||
metadata jsonb,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_owner_user_id ON "group" (owner_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_filetype_association (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
app_id integer NOT NULL REFERENCES apps (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
type varchar(60) NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_filetype_association_app_id ON app_filetype_association (app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_filetype_association_type ON app_filetype_association (type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_opens (
|
||||
_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
app_uid varchar(40) NOT NULL REFERENCES apps (uid) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
ts integer NOT NULL,
|
||||
human_ts timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_opens_user_id ON app_opens (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_opens_app_uid ON app_opens (app_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_opens_uid_ts ON app_opens (app_uid, ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_opens_app_user ON app_opens (app_uid, user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fsentries (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
uuid varchar(36) NOT NULL UNIQUE,
|
||||
bucket varchar(50),
|
||||
bucket_region varchar(30),
|
||||
public_token varchar(36) UNIQUE,
|
||||
file_request_token varchar(36) UNIQUE,
|
||||
is_shortcut boolean DEFAULT FALSE,
|
||||
shortcut_to integer REFERENCES fsentries (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
parent_id integer REFERENCES fsentries (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
associated_app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
is_dir boolean DEFAULT FALSE,
|
||||
layout varchar(30),
|
||||
sort_by varchar(20),
|
||||
sort_order varchar(10),
|
||||
is_public boolean,
|
||||
thumbnail text,
|
||||
immutable boolean NOT NULL DEFAULT FALSE,
|
||||
name varchar(767) NOT NULL,
|
||||
metadata text,
|
||||
modified integer NOT NULL,
|
||||
created integer,
|
||||
accessed integer,
|
||||
size bigint,
|
||||
symlink_path varchar(260),
|
||||
is_symlink boolean DEFAULT FALSE,
|
||||
parent_uid varchar(36),
|
||||
path varchar(4096),
|
||||
CONSTRAINT fsentries_parent_name_unique UNIQUE (parent_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_name ON fsentries (name);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_modified ON fsentries (modified);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_parent_id ON fsentries (parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_is_dir ON fsentries (is_dir);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_user_id ON fsentries (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_shortcut_to ON fsentries (shortcut_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_associated_app_id ON fsentries (associated_app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_bucket ON fsentries (bucket);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_bucket_region ON fsentries (bucket_region);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_parent_uid ON fsentries (parent_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_path ON fsentries (path);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_accessed ON fsentries (accessed);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_user_parent_name ON fsentries (user_id, parent_uid, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_parent_uid_name ON fsentries (parent_uid, name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fsentry_versions (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
fsentry_id integer NOT NULL REFERENCES fsentries (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
fsentry_uuid varchar(36) NOT NULL,
|
||||
version_id varchar(60) NOT NULL,
|
||||
user_id integer REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
message text,
|
||||
ts_epoch integer
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentry_versions_fsentry_id ON fsentry_versions (fsentry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentry_versions_fsentry_uuid ON fsentry_versions (fsentry_uuid);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentry_versions_user_id ON fsentry_versions (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subdomains (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
uuid varchar(40) UNIQUE,
|
||||
subdomain varchar(64) NOT NULL UNIQUE,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
root_dir_id integer REFERENCES fsentries (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
associated_app_id integer REFERENCES apps (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
ts timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||
app_owner integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
protected boolean DEFAULT FALSE,
|
||||
domain varchar(265),
|
||||
database_id varchar(40),
|
||||
preamble_version varchar(64)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_subdomains_user_id ON subdomains (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subdomains_root_dir_id ON subdomains (root_dir_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subdomains_associated_app_id ON subdomains (associated_app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subdomains_app_owner ON subdomains (app_owner);
|
||||
CREATE INDEX IF NOT EXISTS idx_subdomains_domain ON subdomains (domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_subdomains_root_user ON subdomains (root_dir_id, user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subdomains_app_user ON subdomains (associated_app_id, user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kv (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
app varchar(40),
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
kkey_hash bigint NOT NULL,
|
||||
kkey text NOT NULL,
|
||||
value text,
|
||||
migrated boolean DEFAULT FALSE,
|
||||
"expireAt" timestamp,
|
||||
CONSTRAINT kv_app_user_kkey_hash_unique UNIQUE (app, user_id, kkey_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_app ON kv (app);
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_user_id ON kv (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_kkey_hash ON kv (kkey_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
uuid varchar(40) NOT NULL,
|
||||
meta jsonb,
|
||||
created_at bigint DEFAULT 0,
|
||||
last_activity bigint DEFAULT 0,
|
||||
kind varchar(32) NOT NULL DEFAULT 'web'
|
||||
CHECK (kind IN ('web', 'app', 'access_token', 'asset', 'worker')),
|
||||
label varchar(255),
|
||||
parent_session_id varchar(64),
|
||||
last_ip varchar(64),
|
||||
last_user_agent varchar(512),
|
||||
revoked_at bigint,
|
||||
expires_at bigint,
|
||||
app_uid varchar(64),
|
||||
legacy_token_uid varchar(64),
|
||||
created_via varchar(32),
|
||||
auth_id varchar(64),
|
||||
access_token_uid varchar(64)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_uuid ON sessions (uuid);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_revoked ON sessions (user_id, revoked_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions (parent_session_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_user_app_active
|
||||
ON sessions (user_id, app_uid)
|
||||
WHERE kind = 'app' AND revoked_at IS NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_legacy_token_active
|
||||
ON sessions (legacy_token_uid)
|
||||
WHERE legacy_token_uid IS NOT NULL AND revoked_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_kind_user ON sessions (kind, user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_access_token_uid
|
||||
ON sessions (access_token_uid)
|
||||
WHERE access_token_uid IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_user_worker_active
|
||||
ON sessions (user_id, COALESCE(app_uid, ''), (meta #>> ARRAY['worker_name']))
|
||||
WHERE kind = 'worker' AND revoked_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_to_app_permissions (
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
app_id integer NOT NULL REFERENCES apps (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
dt timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, app_id, permission)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_utap_user_permission ON user_to_app_permissions (user_id, permission);
|
||||
CREATE INDEX IF NOT EXISTS idx_utap_app_permission ON user_to_app_permissions (app_id, permission);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_to_group_permissions (
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
group_id integer NOT NULL REFERENCES "group" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
PRIMARY KEY (user_id, group_id, permission)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_to_group_permissions_group_id ON user_to_group_permissions (group_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_to_user_permissions (
|
||||
issuer_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
holder_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
PRIMARY KEY (issuer_user_id, holder_user_id, permission)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_to_user_permissions_holder_user_id ON user_to_user_permissions (holder_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dev_to_app_permissions (
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
app_id integer NOT NULL REFERENCES apps (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
PRIMARY KEY (user_id, app_id, permission)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_to_app_permissions_app_id ON dev_to_app_permissions (app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_to_app_permissions_permission ON dev_to_app_permissions (permission);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS access_token_permissions (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
token_uid varchar(40) NOT NULL,
|
||||
authorizer_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
authorizer_app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_access_token_permissions_token_uid ON access_token_permissions (token_uid);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS old_app_names (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
app_uid varchar(40) NOT NULL REFERENCES apps (uid) ON DELETE CASCADE,
|
||||
name varchar(255) NOT NULL,
|
||||
"timestamp" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_old_app_names_app_uid_name UNIQUE (app_uid, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_old_app_names_app_uid ON old_app_names (app_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_old_app_names_name ON old_app_names (name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jct_user_group (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
group_id integer NOT NULL REFERENCES "group" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
extra jsonb,
|
||||
metadata jsonb,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jct_user_group_user_id ON jct_user_group (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_jct_user_group_group_id ON jct_user_group (group_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
uid varchar(40) UNIQUE,
|
||||
value jsonb NOT NULL,
|
||||
acknowledged bigint,
|
||||
shown bigint,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_user_id ON notification (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS share (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
uid varchar(40) UNIQUE,
|
||||
issuer_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
recipient_email varchar(255) NOT NULL,
|
||||
data jsonb,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_share_issuer_user_id ON share (issuer_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_share_recipient_email ON share (recipient_email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
message text,
|
||||
ts timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_user_id ON feedback (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS thread (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
uid varchar(40) NOT NULL UNIQUE,
|
||||
parent_uid varchar(40) REFERENCES thread (uid) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
owner_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
"schema" text,
|
||||
text text NOT NULL,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_thread_parent_uid ON thread (parent_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_thread_owner_user_id ON thread (owner_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_comments (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
uid varchar(40) NOT NULL UNIQUE,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
metadata jsonb,
|
||||
text text NOT NULL,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_comments_user_id ON user_comments (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_comments_uid ON user_comments (uid);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_fsentry_comments (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_comment_id integer NOT NULL REFERENCES user_comments (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
fsentry_id integer NOT NULL REFERENCES fsentries (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_fsentry_comments_user_comment_id ON user_fsentry_comments (user_comment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_fsentry_comments_fsentry_id ON user_fsentry_comments (fsentry_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_fsentry_version_comments (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_comment_id integer NOT NULL REFERENCES user_comments (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
fsentry_version_id integer NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_fsentry_version_comments_user_comment_id ON user_fsentry_version_comments (user_comment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_fsentry_version_comments_version_id ON user_fsentry_version_comments (fsentry_version_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_group_comments (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_comment_id integer NOT NULL REFERENCES user_comments (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
group_id integer NOT NULL REFERENCES "group" (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_group_comments_user_comment_id ON user_group_comments (user_comment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_group_comments_group_id ON user_group_comments (group_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_user_comments (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_comment_id integer NOT NULL REFERENCES user_comments (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
commented_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_user_comments_user_comment_id ON user_user_comments (user_comment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_user_comments_commented_user_id ON user_user_comments (commented_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_oidc_providers (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
provider varchar(64) NOT NULL,
|
||||
provider_sub varchar(255) NOT NULL,
|
||||
refresh_token text,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_oidc_providers_user_id ON user_oidc_providers (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_oidc_providers_provider ON user_oidc_providers (provider);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_oidc_providers_provider_sub_unique ON user_oidc_providers (provider, provider_sub);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_update_audit (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
app_id_keep integer NOT NULL,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
old_name varchar(50),
|
||||
new_name varchar(50),
|
||||
reason varchar(255)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_update_audit_app_id ON app_update_audit (app_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_update_audit (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
user_id_keep integer NOT NULL,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
old_email varchar(256),
|
||||
new_email varchar(256),
|
||||
old_username varchar(50),
|
||||
new_username varchar(50),
|
||||
reason varchar(255)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_update_audit_user_id ON user_update_audit (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS storage_audit (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
user_id_keep integer NOT NULL,
|
||||
is_subtract boolean NOT NULL DEFAULT FALSE,
|
||||
amount bigint NOT NULL,
|
||||
field_a varchar(16),
|
||||
field_b varchar(16),
|
||||
reason varchar(255),
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_storage_audit_user_id ON storage_audit (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_user_to_app_permissions (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
user_id_keep integer NOT NULL,
|
||||
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
app_id_keep integer NOT NULL,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
action varchar(16),
|
||||
reason varchar(255),
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user_to_app_permissions_user_id ON audit_user_to_app_permissions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user_to_app_permissions_app_id ON audit_user_to_app_permissions (app_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_dev_to_app_permissions (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
user_id_keep integer NOT NULL,
|
||||
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
app_id_keep integer NOT NULL,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
action varchar(16),
|
||||
reason varchar(255),
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_dev_to_app_permissions_user_id ON audit_dev_to_app_permissions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_dev_to_app_permissions_app_id ON audit_dev_to_app_permissions (app_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_user_to_group_permissions (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
user_id_keep integer NOT NULL,
|
||||
group_id integer REFERENCES "group" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
group_id_keep integer NOT NULL,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
action varchar(255),
|
||||
reason varchar(255),
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user_to_group_permissions_user_id ON audit_user_to_group_permissions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user_to_group_permissions_group_id ON audit_user_to_group_permissions (group_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_user_to_user_permissions (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
issuer_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
issuer_user_id_keep integer NOT NULL,
|
||||
holder_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
holder_user_id_keep integer NOT NULL,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
action varchar(16),
|
||||
reason varchar(255),
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user_to_user_permissions_issuer_user_id ON audit_user_to_user_permissions (issuer_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user_to_user_permissions_holder_user_id ON audit_user_to_user_permissions (holder_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_usage (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
service_name varchar(64),
|
||||
model_name varchar(128),
|
||||
price_modifier varchar(40),
|
||||
cost integer,
|
||||
value_uint_1 integer,
|
||||
value_uint_2 integer,
|
||||
value_uint_3 integer,
|
||||
value_uint_4 integer,
|
||||
value_uint_5 integer,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_app_id ON ai_usage (app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_service_name ON ai_usage (service_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_model_name ON ai_usage (model_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_price_modifier ON ai_usage (price_modifier);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_created_at ON ai_usage (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_user_timestamp ON ai_usage (user_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS general_analytics (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
uid varchar(40) NOT NULL,
|
||||
trace_id varchar(40),
|
||||
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
user_id_keep integer,
|
||||
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
app_id_keep integer,
|
||||
server_id varchar(40),
|
||||
actor_type varchar(40),
|
||||
tags jsonb,
|
||||
fields jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_general_analytics_user_id ON general_analytics (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_general_analytics_app_id ON general_analytics (app_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS monthly_usage_counts (
|
||||
year integer NOT NULL,
|
||||
month integer NOT NULL,
|
||||
service_type varchar(40) NOT NULL,
|
||||
service_name varchar(40) NOT NULL,
|
||||
actor_key varchar(255) NOT NULL,
|
||||
pricing_category jsonb NOT NULL,
|
||||
pricing_category_hash bytea NOT NULL,
|
||||
"count" integer DEFAULT 0,
|
||||
value_uint_1 integer,
|
||||
value_uint_2 integer,
|
||||
value_uint_3 integer,
|
||||
PRIMARY KEY (year, month, service_type, service_name, actor_key, pricing_category_hash)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS service_usage_monthly (
|
||||
"key" varchar(255) NOT NULL,
|
||||
year integer NOT NULL,
|
||||
month integer NOT NULL,
|
||||
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
"count" integer NOT NULL,
|
||||
extra jsonb,
|
||||
PRIMARY KEY ("key", year, month)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_usage_monthly_user_id ON service_usage_monthly (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_usage_monthly_app_id ON service_usage_monthly (app_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS per_user_credit (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL UNIQUE REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
amount bigint NOT NULL,
|
||||
last_updated_at bigint NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO "user" (id, uuid, username, metadata)
|
||||
VALUES (1, '5d4adce0-a381-4982-9c02-6e2540026238', 'system', '{}'::jsonb)
|
||||
ON CONFLICT (uuid) DO UPDATE SET username = EXCLUDED.username;
|
||||
|
||||
SELECT setval(pg_get_serial_sequence('"user"', 'id'), COALESCE((SELECT MAX(id) FROM "user"), 1), true);
|
||||
|
||||
INSERT INTO "group" (uid, owner_user_id, extra, metadata)
|
||||
VALUES
|
||||
('26bfb1fb-421f-45bc-9aa4-d81ea569e7a5', 1, '{"critical": true, "type": "default", "name": "system"}'::jsonb, '{"title": "System", "color": "#000000"}'::jsonb),
|
||||
('ca342a5e-b13d-4dee-9048-58b11a57cc55', 1, '{"critical": true, "type": "default", "name": "admin"}'::jsonb, '{"title": "Admin", "color": "#a83232"}'::jsonb),
|
||||
('78b1b1dd-c959-44d2-b02c-8735671f9997', 1, '{"critical": true, "type": "default", "name": "user"}'::jsonb, '{"title": "User", "color": "#3254a8"}'::jsonb),
|
||||
('b7220104-7905-4985-b996-649fdcdb3c8f', 1, '{"critical": true, "type": "default", "name": "temp"}'::jsonb, '{"title": "Temp", "color": "#888888"}'::jsonb),
|
||||
('3c2dfff7-d22a-41aa-a193-59a61dac4b64', 1, '{"type": "default", "name": "moderator"}'::jsonb, '{"title": "Moderator", "color": "#a432a8"}'::jsonb),
|
||||
('5e8f251d-3382-4b0d-932c-7bb82f48652f', 1, '{"type": "default", "name": "developer"}'::jsonb, '{"title": "Developer", "color": "#32a852"}'::jsonb)
|
||||
ON CONFLICT (uid) DO UPDATE SET
|
||||
owner_user_id = EXCLUDED.owner_user_id,
|
||||
extra = EXCLUDED.extra,
|
||||
metadata = EXCLUDED.metadata;
|
||||
|
||||
INSERT INTO apps (
|
||||
uid, owner_user_id, icon, name, title, description, index_url,
|
||||
godmode, maximize_on_start, background,
|
||||
approved_for_listing, approved_for_opening_items,
|
||||
approved_for_incentive_program, tags, "timestamp"
|
||||
)
|
||||
VALUES
|
||||
('app-3920851d-bda8-479b-9407-8517293c7d44', 1, NULL, 'pdf', 'PDF', '', 'https://pdf.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-5584fbf7-ed69-41fc-99cd-85da21b1ef51', 1, NULL, 'camera', 'Camera', 'Camera in the browser.', 'https://online-camera.com', FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, NULL, '2020-01-01 00:00:00'),
|
||||
('app-11edfba2-1ed3-4e22-8573-47e88fb87d70', 1, NULL, 'player', 'Player', 'A free video player app in the browser.', 'https://simple-player.puter.com', FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, NULL, '2020-01-01 00:00:00'),
|
||||
('app-7bdca1a4-6373-4c98-ad97-03ff2d608ca1', 1, NULL, 'recorder', 'Recorder', 'Online voice recorder in the browser with cloud storage.', 'https://voice-recorder.com', FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, NULL, '2020-01-01 00:00:00'),
|
||||
('app-d7e9471f-e441-4d72-a5ab-75e96573b76b', 1, NULL, 'music-player', 'Music Player', 'A free music player app in the browser.', 'https://player.puter.com', FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, NULL, '2026-05-10 00:00:00'),
|
||||
('app-e3ac5486-da8c-42ad-8377-8728086e0980', 1, NULL, 'git', 'Git', 'Puter Git client', 'https://builtins.namespaces.puter.com/git', FALSE, FALSE, TRUE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-0b37f054-07d4-4627-8765-11bd23e889d4', 1, NULL, 'dev-center', 'Dev Center', 'This is the app that makes apps', 'https://builtins.namespaces.puter.com/dev-center', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, NULL, '2020-01-01 00:00:00'),
|
||||
('app-fbbdb72b-ad08-4cb4-86a1-de0f27cf2e1e', 1, NULL, 'puter-linux', 'Puter Linux', 'Linux emulator for Puter', 'https://builtins.namespaces.puter.com/emulator', TRUE, FALSE, FALSE, TRUE, TRUE, FALSE, NULL, '2020-01-01 00:00:00'),
|
||||
('app-838dfbc4-bf8b-48c2-b47b-c4adc77fab58', 1, NULL, 'editor', 'Editor', 'Text editor', 'https://online-notepad.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-58282b08-990a-4906-95f7-fa37ff92452b', 1, NULL, 'draw', 'Draw', 'Image editor', 'https://draw.puter.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, 'graphics', '2020-01-01 00:00:00'),
|
||||
('app-0bef044f-918f-4cbf-a0c0-b4a17ee81085', 1, NULL, 'about', 'About', 'About Puter', 'https://about.puter.com', TRUE, FALSE, FALSE, FALSE, TRUE, FALSE, NULL, '2020-01-01 00:00:00'),
|
||||
('app-a2ae72a4-1ba3-4a29-b5c0-6de1be5cf178', 1, NULL, 'app-center', 'App Center', 'Discover apps for Puter', 'https://app-center.puter.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, NULL, '2020-01-01 00:00:00'),
|
||||
('app-93005ce0-80d1-50d9-9b1e-9c453c375d56', 1, NULL, 'markus', 'Markus', 'Markdown editor', 'https://markus.puter.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-6f79ef7b-52b7-4b31-91c6-fc07b62e9396', 1, NULL, 'code', 'Code', 'Code editor', 'https://code.puter.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-862fc09e-c7b8-4c30-b5b4-47b3cc5f5232', 1, NULL, 'memos', 'Memos', 'Notes and memos', 'https://memos.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-aeb8c03e-1144-4c57-bff5-3a1a7c17f9f0', 1, NULL, 'word-processor', 'Word Processor', 'Write documents', 'https://word-processor.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-c10a1999-22f5-4644-9960-9aaac0d4934e', 1, NULL, 'spreadsheet', 'Spreadsheet', 'Work with spreadsheets', 'https://spreadsheet.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-e9c1d58d-3d8d-4f0a-a85c-e383ef63bc29', 1, NULL, 'presentation', 'Presentation', 'Create presentations', 'https://presentation.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-fb7d9e42-8207-4fa0-b680-f0239327097f', 1, NULL, 'pdf-editor', 'PDF Editor', 'Edit PDF documents', 'https://pdf-editor.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00')
|
||||
ON CONFLICT (uid) DO UPDATE SET
|
||||
owner_user_id = EXCLUDED.owner_user_id,
|
||||
name = EXCLUDED.name,
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
index_url = EXCLUDED.index_url,
|
||||
godmode = EXCLUDED.godmode,
|
||||
maximize_on_start = EXCLUDED.maximize_on_start,
|
||||
background = EXCLUDED.background,
|
||||
approved_for_listing = EXCLUDED.approved_for_listing,
|
||||
approved_for_opening_items = EXCLUDED.approved_for_opening_items,
|
||||
approved_for_incentive_program = EXCLUDED.approved_for_incentive_program,
|
||||
tags = EXCLUDED.tags;
|
||||
|
||||
INSERT INTO user_to_group_permissions (user_id, group_id, permission, extra)
|
||||
SELECT u.id, g.id, 'driver', '{}'::jsonb
|
||||
FROM "user" u, "group" g
|
||||
WHERE u.username = 'system'
|
||||
AND g.uid = 'ca342a5e-b13d-4dee-9048-58b11a57cc55'
|
||||
ON CONFLICT (user_id, group_id, permission) DO UPDATE SET extra = EXCLUDED.extra;
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { preparePostgresSql } from './preparePostgresSql.js';
|
||||
|
||||
describe('preparePostgresSql', () => {
|
||||
it('converts placeholders and backtick identifiers outside SQL literals', () => {
|
||||
const prepared = preparePostgresSql(
|
||||
'SELECT `user`.`id` FROM `user` WHERE `email` = ? AND `username` = ?',
|
||||
);
|
||||
|
||||
expect(prepared).toEqual({
|
||||
text: 'SELECT "user"."id" FROM "user" WHERE "email" = $1 AND "username" = $2',
|
||||
parameterCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves question marks inside strings and comments untouched', () => {
|
||||
const prepared = preparePostgresSql(`
|
||||
SELECT '?' AS literal, ?
|
||||
-- ? in a comment
|
||||
FROM \`apps\`
|
||||
WHERE \`description\` = 'is this ok?'
|
||||
/* and ? in a block comment */
|
||||
AND \`name\` = ?
|
||||
`);
|
||||
|
||||
expect(prepared.text).toContain("SELECT '?' AS literal, $1");
|
||||
expect(prepared.text).toContain('-- ? in a comment');
|
||||
expect(prepared.text).toContain('"description" = \'is this ok?\'');
|
||||
expect(prepared.text).toContain('/* and ? in a block comment */');
|
||||
expect(prepared.text).toContain('"name" = $2');
|
||||
expect(prepared.parameterCount).toBe(2);
|
||||
});
|
||||
|
||||
it('escapes double quotes inside converted identifiers', () => {
|
||||
const prepared = preparePostgresSql(
|
||||
'SELECT `odd"name` FROM `odd``table` WHERE `id` = ?',
|
||||
);
|
||||
|
||||
expect(prepared).toEqual({
|
||||
text: 'SELECT "odd""name" FROM "odd`table" WHERE "id" = $1',
|
||||
parameterCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not scan placeholders inside dollar-quoted strings', () => {
|
||||
const prepared = preparePostgresSql(
|
||||
"SELECT $$?$$, $tag$`not_ident` ?$tag$, `id` FROM `user` WHERE `id` = ?",
|
||||
);
|
||||
|
||||
expect(prepared).toEqual({
|
||||
text: 'SELECT $$?$$, $tag$`not_ident` ?$tag$, "id" FROM "user" WHERE "id" = $1',
|
||||
parameterCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat Postgres JSON operators as comments', () => {
|
||||
const prepared = preparePostgresSql(
|
||||
"SELECT * FROM `sessions` WHERE `user_id` = ? AND `meta` #>> ARRAY['worker_name'] = ? AND `expires_at` > ?",
|
||||
);
|
||||
|
||||
expect(prepared).toEqual({
|
||||
text: 'SELECT * FROM "sessions" WHERE "user_id" = $1 AND "meta" #>> ARRAY[\'worker_name\'] = $2 AND "expires_at" > $3',
|
||||
parameterCount: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface PreparedPostgresSql {
|
||||
text: string;
|
||||
parameterCount: number;
|
||||
}
|
||||
|
||||
type ScannerState =
|
||||
| { kind: 'normal' }
|
||||
| { kind: 'singleQuote' }
|
||||
| { kind: 'doubleQuote' }
|
||||
| { kind: 'backtickIdentifier' }
|
||||
| { kind: 'lineComment' }
|
||||
| { kind: 'blockComment' }
|
||||
| { kind: 'dollarQuote'; tag: string };
|
||||
|
||||
const isLineCommentStart = (sql: string, index: number): boolean => {
|
||||
if (sql[index] !== '-' || sql[index + 1] !== '-') return false;
|
||||
const after = sql[index + 2];
|
||||
return after === undefined || /\s/u.test(after);
|
||||
};
|
||||
|
||||
const readDollarQuoteTag = (sql: string, index: number): string | null => {
|
||||
const match = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/u.exec(sql.slice(index));
|
||||
return match?.[0] ?? null;
|
||||
};
|
||||
|
||||
export const preparePostgresSql = (sql: string): PreparedPostgresSql => {
|
||||
let state: ScannerState = { kind: 'normal' };
|
||||
let parameterCount = 0;
|
||||
let out = '';
|
||||
|
||||
for (let i = 0; i < sql.length; i++) {
|
||||
const char = sql[i]!;
|
||||
const next = sql[i + 1];
|
||||
|
||||
switch (state.kind) {
|
||||
case 'normal': {
|
||||
const dollarTag =
|
||||
char === '$' ? readDollarQuoteTag(sql, i) : null;
|
||||
if (dollarTag) {
|
||||
out += dollarTag;
|
||||
i += dollarTag.length - 1;
|
||||
state = { kind: 'dollarQuote', tag: dollarTag };
|
||||
break;
|
||||
}
|
||||
if (char === "'") {
|
||||
out += char;
|
||||
state = { kind: 'singleQuote' };
|
||||
break;
|
||||
}
|
||||
if (char === '"') {
|
||||
out += char;
|
||||
state = { kind: 'doubleQuote' };
|
||||
break;
|
||||
}
|
||||
if (char === '`') {
|
||||
out += '"';
|
||||
state = { kind: 'backtickIdentifier' };
|
||||
break;
|
||||
}
|
||||
if (isLineCommentStart(sql, i)) {
|
||||
out += char;
|
||||
if (char === '-') {
|
||||
out += next ?? '';
|
||||
i += 1;
|
||||
}
|
||||
state = { kind: 'lineComment' };
|
||||
break;
|
||||
}
|
||||
if (char === '/' && next === '*') {
|
||||
out += '/*';
|
||||
i += 1;
|
||||
state = { kind: 'blockComment' };
|
||||
break;
|
||||
}
|
||||
if (char === '?') {
|
||||
parameterCount += 1;
|
||||
out += `$${parameterCount}`;
|
||||
break;
|
||||
}
|
||||
out += char;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'singleQuote':
|
||||
out += char;
|
||||
if (char === "'" && next === "'") {
|
||||
out += next;
|
||||
i += 1;
|
||||
} else if (char === '\\' && next !== undefined) {
|
||||
out += next;
|
||||
i += 1;
|
||||
} else if (char === "'") {
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'doubleQuote':
|
||||
out += char;
|
||||
if (char === '"' && next === '"') {
|
||||
out += next;
|
||||
i += 1;
|
||||
} else if (char === '"') {
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'backtickIdentifier':
|
||||
if (char === '`' && next === '`') {
|
||||
out += '`';
|
||||
i += 1;
|
||||
} else if (char === '`') {
|
||||
out += '"';
|
||||
state = { kind: 'normal' };
|
||||
} else if (char === '"') {
|
||||
out += '""';
|
||||
} else {
|
||||
out += char;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'lineComment':
|
||||
out += char;
|
||||
if (char === '\n') {
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'blockComment':
|
||||
out += char;
|
||||
if (char === '*' && next === '/') {
|
||||
out += '/';
|
||||
i += 1;
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'dollarQuote':
|
||||
if (sql.startsWith(state.tag, i)) {
|
||||
out += state.tag;
|
||||
i += state.tag.length - 1;
|
||||
state = { kind: 'normal' };
|
||||
} else {
|
||||
out += char;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { text: out, parameterCount };
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
type ScannerState =
|
||||
| { kind: 'normal' }
|
||||
| { kind: 'singleQuote' }
|
||||
| { kind: 'doubleQuote' }
|
||||
| { kind: 'lineComment' }
|
||||
| { kind: 'blockComment' }
|
||||
| { kind: 'dollarQuote'; tag: string };
|
||||
|
||||
const readDollarQuoteTag = (sql: string, index: number): string | null => {
|
||||
const match = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/u.exec(sql.slice(index));
|
||||
return match?.[0] ?? null;
|
||||
};
|
||||
|
||||
export const splitPostgresStatements = (sql: string): string[] => {
|
||||
const statements: string[] = [];
|
||||
let state: ScannerState = { kind: 'normal' };
|
||||
let start = 0;
|
||||
|
||||
for (let i = 0; i < sql.length; i++) {
|
||||
const char = sql[i]!;
|
||||
const next = sql[i + 1];
|
||||
|
||||
switch (state.kind) {
|
||||
case 'normal': {
|
||||
const dollarTag =
|
||||
char === '$' ? readDollarQuoteTag(sql, i) : null;
|
||||
if (dollarTag) {
|
||||
i += dollarTag.length - 1;
|
||||
state = { kind: 'dollarQuote', tag: dollarTag };
|
||||
break;
|
||||
}
|
||||
if (char === "'") {
|
||||
state = { kind: 'singleQuote' };
|
||||
break;
|
||||
}
|
||||
if (char === '"') {
|
||||
state = { kind: 'doubleQuote' };
|
||||
break;
|
||||
}
|
||||
if (char === '-' && next === '-') {
|
||||
i += 1;
|
||||
state = { kind: 'lineComment' };
|
||||
break;
|
||||
}
|
||||
if (char === '/' && next === '*') {
|
||||
i += 1;
|
||||
state = { kind: 'blockComment' };
|
||||
break;
|
||||
}
|
||||
if (char === ';') {
|
||||
const statement = sql.slice(start, i).trim();
|
||||
if (statement !== '') statements.push(statement);
|
||||
start = i + 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'singleQuote':
|
||||
if (char === "'" && next === "'") {
|
||||
i += 1;
|
||||
} else if (char === '\\' && next !== undefined) {
|
||||
i += 1;
|
||||
} else if (char === "'") {
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'doubleQuote':
|
||||
if (char === '"' && next === '"') {
|
||||
i += 1;
|
||||
} else if (char === '"') {
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'lineComment':
|
||||
if (char === '\n') {
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'blockComment':
|
||||
if (char === '*' && next === '/') {
|
||||
i += 1;
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'dollarQuote':
|
||||
if (sql.startsWith(state.tag, i)) {
|
||||
i += state.tag.length - 1;
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const finalStatement = sql.slice(start).trim();
|
||||
if (finalStatement !== '') statements.push(finalStatement);
|
||||
return statements;
|
||||
};
|
||||
@@ -2335,8 +2335,8 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
await this.clients.db.write(
|
||||
'UPDATE `user` SET `otp_enabled` = 1 WHERE `uuid` = ?',
|
||||
[user.uuid],
|
||||
'UPDATE `user` SET `otp_enabled` = ? WHERE `uuid` = ?',
|
||||
[this.clients.db.booleanValue(true), user.uuid],
|
||||
);
|
||||
await this.stores.user.invalidateById(user.id);
|
||||
|
||||
@@ -2371,8 +2371,8 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
|
||||
await this.clients.db.write(
|
||||
'UPDATE `user` SET `otp_enabled` = 0, `otp_recovery_codes` = NULL, `otp_secret` = NULL WHERE `uuid` = ?',
|
||||
[user.uuid],
|
||||
'UPDATE `user` SET `otp_enabled` = ?, `otp_recovery_codes` = NULL, `otp_secret` = NULL WHERE `uuid` = ?',
|
||||
[this.clients.db.booleanValue(false), user.uuid],
|
||||
);
|
||||
await this.stores.user.invalidateById(user.id);
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ export class StaticPagesController extends PuterController {
|
||||
const domain = this.config.domain ?? req.hostname;
|
||||
const origin = `${req.protocol}://${domain}`;
|
||||
const apps = (await this.clients.db.read(
|
||||
'SELECT `name` FROM `apps` WHERE `approved_for_listing` = 1',
|
||||
`SELECT \`name\` FROM \`apps\` WHERE \`approved_for_listing\` = ${this.clients.db.booleanLiteral(true)}`,
|
||||
)) as Array<{ name: string }>;
|
||||
const urls = [
|
||||
`<url><loc>${req.protocol}://docs.${domain}/</loc></url>`,
|
||||
@@ -252,7 +252,7 @@ export class StaticPagesController extends PuterController {
|
||||
const [dupe] = (await this.clients.db.read(
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM \`user\` WHERE (\`email\` = ? OR \`clean_email\` = ?)
|
||||
AND \`email_confirmed\` = 1
|
||||
AND \`email_confirmed\` = ${this.clients.db.booleanLiteral(true)}
|
||||
AND \`password\` IS NOT NULL
|
||||
) AS email_exists`,
|
||||
[user.email, cleanEmail],
|
||||
|
||||
@@ -243,7 +243,9 @@ export async function resolveOwnedAppForHostedSite(opts: {
|
||||
const uniqueCandidates = [...new Set(urlCandidates)];
|
||||
if (uniqueCandidates.length === 0) return null;
|
||||
|
||||
const privateFilter = opts.requirePrivate ? 'AND `is_private` = 1 ' : '';
|
||||
const privateFilter = opts.requirePrivate
|
||||
? `AND \`is_private\` = ${opts.db.booleanLiteral(true)} `
|
||||
: '';
|
||||
const placeholders = uniqueCandidates.map(() => '?').join(', ');
|
||||
const rows = await opts.db.read(
|
||||
`SELECT * FROM apps WHERE owner_user_id = ? ${privateFilter}AND index_url IN (${placeholders}) LIMIT 2`,
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"openai": "^6.34.0",
|
||||
"otpauth": "^9.2.4",
|
||||
"parse-domain": "^8.2.2",
|
||||
"pg": "^8.21.0",
|
||||
"prompt-sync": "^4.2.0",
|
||||
"replicate": "^1.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
@@ -77,9 +78,11 @@
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pg": "^8.6.1",
|
||||
"@types/validator": "^13.15.10",
|
||||
"chai": "^4.3.7",
|
||||
"nodemon": "^3.1.0",
|
||||
"pgmock": "^1.0.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.0.14"
|
||||
|
||||
@@ -3125,11 +3125,12 @@ export class FSService extends PuterService {
|
||||
*/
|
||||
async removeAllForUser(userId: number): Promise<void> {
|
||||
const pageSize = 5000;
|
||||
const falseLiteral = this.clients.db.booleanLiteral(false);
|
||||
// Files-first loop: delete backing S3 objects in batches, then DB rows.
|
||||
for (;;) {
|
||||
const files = (await this.clients.db.read(
|
||||
`SELECT uuid, bucket, bucket_region FROM fsentries
|
||||
WHERE user_id = ? AND is_dir = 0 AND (is_shortcut = 0 OR is_shortcut IS NULL) AND (is_symlink = 0 OR is_symlink IS NULL)
|
||||
WHERE user_id = ? AND is_dir = ${falseLiteral} AND (is_shortcut = ${falseLiteral} OR is_shortcut IS NULL) AND (is_symlink = ${falseLiteral} OR is_symlink IS NULL)
|
||||
LIMIT ${pageSize}`,
|
||||
[userId],
|
||||
)) as Array<{
|
||||
|
||||
@@ -106,6 +106,16 @@ const READ_ONLY_COLUMNS = new Set([
|
||||
'protected',
|
||||
'is_private',
|
||||
]);
|
||||
const APP_BOOLEAN_COLUMNS = new Set([
|
||||
'godmode',
|
||||
'maximize_on_start',
|
||||
'approved_for_listing',
|
||||
'approved_for_opening_items',
|
||||
'approved_for_incentive_program',
|
||||
'background',
|
||||
'protected',
|
||||
'is_private',
|
||||
]);
|
||||
|
||||
export class AppStore extends PuterStore {
|
||||
#appStatsInterval;
|
||||
@@ -341,7 +351,7 @@ export class AppStore extends PuterStore {
|
||||
const colList = columns.map((c) => `\`${c}\``).join(', ');
|
||||
|
||||
const result = await this.clients.db.write(
|
||||
`INSERT INTO \`apps\` (${colList}) VALUES (${placeholders})`,
|
||||
`INSERT INTO \`apps\` (${colList}) VALUES (${placeholders})${this.clients.db.returningIdClause()}`,
|
||||
values,
|
||||
);
|
||||
const insertId = result?.insertId;
|
||||
@@ -380,7 +390,7 @@ export class AppStore extends PuterStore {
|
||||
const colList = columns.map((c) => `\`${c}\``).join(', ');
|
||||
|
||||
const result = await this.clients.db.write(
|
||||
`INSERT INTO \`apps\` (${colList}) VALUES (${placeholders})`,
|
||||
`INSERT INTO \`apps\` (${colList}) VALUES (${placeholders})${this.clients.db.returningIdClause()}`,
|
||||
values,
|
||||
);
|
||||
const insertId = result?.insertId;
|
||||
@@ -698,6 +708,7 @@ export class AppStore extends PuterStore {
|
||||
async #resolveByOldName(name) {
|
||||
const cutoffClause = this.clients.db.case({
|
||||
sqlite: `datetime('now', '-${OLD_APP_NAME_TTL_MONTHS} months')`,
|
||||
postgres: `(NOW() - INTERVAL '${OLD_APP_NAME_TTL_MONTHS} months')`,
|
||||
otherwise: `(NOW() - INTERVAL ${OLD_APP_NAME_TTL_MONTHS} MONTH)`,
|
||||
});
|
||||
|
||||
@@ -842,7 +853,10 @@ export class AppStore extends PuterStore {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (READ_ONLY_COLUMNS.has(k)) continue;
|
||||
out[k] = v;
|
||||
out[k] =
|
||||
APP_BOOLEAN_COLUMNS.has(k) && v !== null && v !== undefined
|
||||
? this.clients.db.booleanValue(Boolean(v))
|
||||
: v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1136,6 +1150,7 @@ export class AppStore extends PuterStore {
|
||||
const periodExpr = this.clients.db.case({
|
||||
mysql: `DATE_FORMAT(FROM_UNIXTIME(ts), '${timeFormat}')`,
|
||||
sqlite: `STRFTIME('${timeFormat}', datetime(ts, 'unixepoch'))`,
|
||||
postgres: `TO_CHAR(TO_TIMESTAMP(ts), '${this.#postgresDateFormat(grouping)}')`,
|
||||
otherwise: `DATE_FORMAT(FROM_UNIXTIME(ts), '${timeFormat}')`,
|
||||
});
|
||||
const rows = await this.clients.db.read(
|
||||
@@ -1156,6 +1171,23 @@ export class AppStore extends PuterStore {
|
||||
return this.#assembleGroupedResult(processed, allPeriods, grouping);
|
||||
}
|
||||
|
||||
#postgresDateFormat(grouping) {
|
||||
switch (grouping) {
|
||||
case 'hour':
|
||||
return 'YYYY-MM-DD HH24:00:00';
|
||||
case 'day':
|
||||
return 'YYYY-MM-DD';
|
||||
case 'week':
|
||||
return 'YYYY-WW';
|
||||
case 'month':
|
||||
return 'YYYY-MM';
|
||||
case 'year':
|
||||
return 'YYYY';
|
||||
default:
|
||||
return 'YYYY-MM-DD';
|
||||
}
|
||||
}
|
||||
|
||||
#assembleGroupedResult(rows, allPeriods, grouping) {
|
||||
// Totals come from raw rows so they survive even if a row's
|
||||
// period key fails to match an `allPeriods` entry (e.g. week
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import { FSEntryStore } from './FSEntryStore.js';
|
||||
|
||||
describe('FSEntryStore', () => {
|
||||
it('quotes camelCase storage allowance aliases for Postgres', async () => {
|
||||
const queries: string[] = [];
|
||||
const config: IConfig = {
|
||||
port: 0,
|
||||
extensions: [],
|
||||
is_storage_limited: true,
|
||||
storage_capacity: 1000,
|
||||
};
|
||||
const clients = {
|
||||
db: {
|
||||
quoteIdentifier: (identifier: string) => `"${identifier}"`,
|
||||
read: async (query: string) => {
|
||||
queries.push(query);
|
||||
if (query.includes('SUM(size)')) {
|
||||
return [{ totalUsage: 321 }];
|
||||
}
|
||||
return [{ freeStorage: 654 }];
|
||||
},
|
||||
},
|
||||
event: {
|
||||
emitAndWait: async () => undefined,
|
||||
},
|
||||
};
|
||||
const store = new FSEntryStore(
|
||||
config,
|
||||
clients as ConstructorParameters<typeof FSEntryStore>[1],
|
||||
{} as ConstructorParameters<typeof FSEntryStore>[2],
|
||||
);
|
||||
|
||||
await expect(store.getUserStorageAllowance(42)).resolves.toEqual({
|
||||
curr: 321,
|
||||
max: 654,
|
||||
});
|
||||
expect(queries).toEqual([
|
||||
'SELECT COALESCE(SUM(size), 0) AS "totalUsage" FROM fsentries WHERE user_id = ?',
|
||||
'SELECT free_storage AS "freeStorage" FROM "user" WHERE id = ? LIMIT 1',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -60,10 +60,7 @@ export class FSEntryStore extends PuterStore {
|
||||
declare protected stores: LayerInstances<typeof puterStores>;
|
||||
|
||||
#insertIgnoreIntoFsentriesSql(): string {
|
||||
return this.clients.db.case({
|
||||
sqlite: 'INSERT OR IGNORE INTO fsentries',
|
||||
otherwise: 'INSERT IGNORE INTO fsentries',
|
||||
});
|
||||
return this.clients.db.insertIgnoreInto('fsentries');
|
||||
}
|
||||
|
||||
// JSON aggregation of associated subdomain rows, keyed on fsentries.id.
|
||||
@@ -86,6 +83,16 @@ export class FSEntryStore extends PuterStore {
|
||||
FROM subdomains sd
|
||||
WHERE sd.root_dir_id = fsentries.id
|
||||
)`,
|
||||
postgres: `(
|
||||
SELECT COALESCE(
|
||||
json_agg(
|
||||
json_build_object('uuid', sd.uuid, 'subdomain', sd.subdomain)
|
||||
),
|
||||
'[]'::json
|
||||
)
|
||||
FROM subdomains sd
|
||||
WHERE sd.root_dir_id = fsentries.id
|
||||
)`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -659,6 +666,8 @@ export class FSEntryStore extends PuterStore {
|
||||
const insertRows: unknown[] = [];
|
||||
const valuePlaceholders: string[] = [];
|
||||
const expectedUuidByPath = new Map<string, string>();
|
||||
const trueLiteral = this.clients.db.booleanLiteral(true);
|
||||
const falseLiteral = this.clients.db.booleanLiteral(false);
|
||||
for (const dirPath of pathsAtDepth) {
|
||||
const parentPath = pathPosix.dirname(dirPath);
|
||||
const parentEntry =
|
||||
@@ -679,7 +688,7 @@ export class FSEntryStore extends PuterStore {
|
||||
const expectedUuid = uuidv4();
|
||||
expectedUuidByPath.set(dirPath, expectedUuid);
|
||||
valuePlaceholders.push(
|
||||
'(?, ?, ?, ?, ?, ?, 1, ?, ?, ?, 0, 0)',
|
||||
`(?, ?, ?, ?, ?, ?, ${trueLiteral}, ?, ?, ?, ${falseLiteral}, 0)`,
|
||||
);
|
||||
insertRows.push(
|
||||
expectedUuid,
|
||||
@@ -709,7 +718,7 @@ export class FSEntryStore extends PuterStore {
|
||||
accessed,
|
||||
immutable,
|
||||
size
|
||||
) VALUES ${valuePlaceholders.join(', ')}`,
|
||||
) VALUES ${valuePlaceholders.join(', ')}${this.clients.db.insertIgnoreSuffix()}`,
|
||||
insertRows,
|
||||
);
|
||||
} catch {
|
||||
@@ -812,6 +821,8 @@ export class FSEntryStore extends PuterStore {
|
||||
: await this.#ensureDirectoryPath(parentPath, userId, true);
|
||||
const dirName = pathPosix.basename(normalizedPath);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const trueLiteral = this.clients.db.booleanLiteral(true);
|
||||
const falseLiteral = this.clients.db.booleanLiteral(false);
|
||||
|
||||
let insertError: unknown = null;
|
||||
try {
|
||||
@@ -829,7 +840,7 @@ export class FSEntryStore extends PuterStore {
|
||||
accessed,
|
||||
immutable,
|
||||
size
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, 0, 0)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ${trueLiteral}, ?, ?, ?, ${falseLiteral}, 0)${this.clients.db.insertIgnoreSuffix()}`,
|
||||
[
|
||||
uuidv4(),
|
||||
userId,
|
||||
@@ -1765,11 +1776,13 @@ export class FSEntryStore extends PuterStore {
|
||||
entry.input.associatedAppId ?? null,
|
||||
entry.input.isPublic === undefined
|
||||
? null
|
||||
: entry.input.isPublic
|
||||
? 1
|
||||
: 0,
|
||||
: this.clients.db.booleanValue(
|
||||
entry.input.isPublic,
|
||||
),
|
||||
entry.input.thumbnail ?? null,
|
||||
entry.input.immutable ? 1 : 0,
|
||||
this.clients.db.booleanValue(
|
||||
Boolean(entry.input.immutable),
|
||||
),
|
||||
entry.fileName,
|
||||
entry.targetPath,
|
||||
entry.metadataJson,
|
||||
@@ -1850,7 +1863,7 @@ export class FSEntryStore extends PuterStore {
|
||||
}
|
||||
|
||||
valuePlaceholders.push(
|
||||
'(?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
`(?, ?, ?, ?, ?, ?, ?, ${this.clients.db.booleanLiteral(false)}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
values.push(
|
||||
entry.input.uuid,
|
||||
@@ -1862,11 +1875,13 @@ export class FSEntryStore extends PuterStore {
|
||||
entry.input.associatedAppId ?? null,
|
||||
entry.input.isPublic === undefined
|
||||
? null
|
||||
: entry.input.isPublic
|
||||
? 1
|
||||
: 0,
|
||||
: this.clients.db.booleanValue(
|
||||
entry.input.isPublic,
|
||||
),
|
||||
entry.input.thumbnail ?? null,
|
||||
entry.input.immutable ? 1 : 0,
|
||||
this.clients.db.booleanValue(
|
||||
Boolean(entry.input.immutable),
|
||||
),
|
||||
entry.fileName,
|
||||
entry.targetPath,
|
||||
entry.metadataJson,
|
||||
@@ -1903,7 +1918,7 @@ export class FSEntryStore extends PuterStore {
|
||||
created,
|
||||
accessed,
|
||||
size
|
||||
) VALUES ${valuePlaceholders.join(', ')}`,
|
||||
) VALUES ${valuePlaceholders.join(', ')}${this.clients.db.insertIgnoreSuffix()}`,
|
||||
values,
|
||||
);
|
||||
},
|
||||
@@ -2055,11 +2070,13 @@ export class FSEntryStore extends PuterStore {
|
||||
entry.input.associatedAppId ?? null,
|
||||
entry.input.isPublic === undefined
|
||||
? null
|
||||
: entry.input.isPublic
|
||||
? 1
|
||||
: 0,
|
||||
: this.clients.db.booleanValue(
|
||||
entry.input.isPublic,
|
||||
),
|
||||
entry.input.thumbnail ?? null,
|
||||
entry.input.immutable ? 1 : 0,
|
||||
this.clients.db.booleanValue(
|
||||
Boolean(entry.input.immutable),
|
||||
),
|
||||
entry.fileName,
|
||||
entry.targetPath,
|
||||
entry.metadataJson,
|
||||
@@ -2273,9 +2290,13 @@ export class FSEntryStore extends PuterStore {
|
||||
? `/${input.name}`
|
||||
: `${parentPath}/${input.name}`;
|
||||
|
||||
const isDir = input.kind === 'directory' ? 1 : 0;
|
||||
const isShortcut = input.kind === 'shortcut' ? 1 : 0;
|
||||
const isSymlink = input.kind === 'symlink' ? 1 : 0;
|
||||
const isDir = this.clients.db.booleanValue(input.kind === 'directory');
|
||||
const isShortcut = this.clients.db.booleanValue(
|
||||
input.kind === 'shortcut',
|
||||
);
|
||||
const isSymlink = this.clients.db.booleanValue(
|
||||
input.kind === 'symlink',
|
||||
);
|
||||
|
||||
await this.clients.db.write(
|
||||
`INSERT INTO fsentries (
|
||||
@@ -2315,12 +2336,10 @@ export class FSEntryStore extends PuterStore {
|
||||
input.associatedAppId ?? null,
|
||||
input.metadata ?? null,
|
||||
input.thumbnail ?? null,
|
||||
input.immutable ? 1 : 0,
|
||||
this.clients.db.booleanValue(Boolean(input.immutable)),
|
||||
input.isPublic === undefined || input.isPublic === null
|
||||
? null
|
||||
: input.isPublic
|
||||
? 1
|
||||
: 0,
|
||||
: this.clients.db.booleanValue(input.isPublic),
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
@@ -2578,10 +2597,12 @@ export class FSEntryStore extends PuterStore {
|
||||
if (patch.isPublic !== undefined)
|
||||
push(
|
||||
'is_public',
|
||||
patch.isPublic === null ? null : patch.isPublic ? 1 : 0,
|
||||
patch.isPublic === null
|
||||
? null
|
||||
: this.clients.db.booleanValue(patch.isPublic),
|
||||
);
|
||||
if (patch.immutable !== undefined)
|
||||
push('immutable', patch.immutable ? 1 : 0);
|
||||
push('immutable', this.clients.db.booleanValue(patch.immutable));
|
||||
if (patch.associatedAppId !== undefined)
|
||||
push('associated_app_id', patch.associatedAppId);
|
||||
if (patch.layout !== undefined) push('layout', patch.layout);
|
||||
@@ -2629,7 +2650,7 @@ export class FSEntryStore extends PuterStore {
|
||||
async getRootEntryForUser(userId: number): Promise<FSEntry | null> {
|
||||
const rows = (await this.clients.db.read(
|
||||
`SELECT ${this.#selectFsentriesColumns()} FROM fsentries
|
||||
WHERE user_id = ? AND parent_uid IS NULL AND is_dir = 1
|
||||
WHERE user_id = ? AND parent_uid IS NULL AND is_dir = ${this.clients.db.booleanLiteral(true)}
|
||||
ORDER BY id ASC LIMIT 1`,
|
||||
[userId],
|
||||
)) as unknown as FSEntryRow[];
|
||||
@@ -2669,9 +2690,13 @@ export class FSEntryStore extends PuterStore {
|
||||
if (oldPath && oldPath !== '/' && oldPath !== newPath) {
|
||||
const likePattern = `${this.#escapeLikePattern(oldPath)}/%`;
|
||||
const oldLen = oldPath.length;
|
||||
const rewrittenPath = this.clients.db.case({
|
||||
postgres: '? || SUBSTR(path, ?)',
|
||||
otherwise: 'CONCAT(?, SUBSTR(path, ?))',
|
||||
});
|
||||
await this.clients.db.write(
|
||||
`UPDATE fsentries
|
||||
SET path = CONCAT(?, SUBSTR(path, ?)),
|
||||
SET path = ${rewrittenPath},
|
||||
modified = ?
|
||||
WHERE user_id = ? AND path LIKE ? ESCAPE '!'`,
|
||||
[newPath, oldLen + 1, now, userId, likePattern],
|
||||
@@ -2720,9 +2745,13 @@ export class FSEntryStore extends PuterStore {
|
||||
|
||||
// CONCAT(?, SUBSTR(path, ? + 1)) to rewrite just the prefix portion.
|
||||
const oldPrefixLen = normalizedOld.length;
|
||||
const rewrittenPath = this.clients.db.case({
|
||||
postgres: '? || SUBSTR(path, ?)',
|
||||
otherwise: 'CONCAT(?, SUBSTR(path, ?))',
|
||||
});
|
||||
const result = await this.clients.db.write(
|
||||
`UPDATE fsentries
|
||||
SET path = CONCAT(?, SUBSTR(path, ?)),
|
||||
SET path = ${rewrittenPath},
|
||||
modified = ?
|
||||
WHERE user_id = ? AND path LIKE ? ESCAPE '!'`,
|
||||
[normalizedNew, oldPrefixLen + 1, now, userId, likePattern],
|
||||
@@ -2771,11 +2800,11 @@ export class FSEntryStore extends PuterStore {
|
||||
): Promise<{ curr: number; max: number }> {
|
||||
const [usageRows, userRows] = await Promise.all([
|
||||
this.clients.db.read(
|
||||
'SELECT COALESCE(SUM(size), 0) AS totalUsage FROM fsentries WHERE user_id = ?',
|
||||
`SELECT COALESCE(SUM(size), 0) AS ${this.clients.db.quoteIdentifier('totalUsage')} FROM fsentries WHERE user_id = ?`,
|
||||
[userId],
|
||||
) as Promise<{ totalUsage: number }[]>,
|
||||
this.clients.db.read(
|
||||
'SELECT free_storage AS freeStorage FROM user WHERE id = ? LIMIT 1',
|
||||
`SELECT free_storage AS ${this.clients.db.quoteIdentifier('freeStorage')} FROM ${this.clients.db.quoteIdentifier('user')} WHERE id = ? LIMIT 1`,
|
||||
[userId],
|
||||
) as Promise<{ freeStorage: number | null }[]>,
|
||||
]);
|
||||
|
||||
@@ -159,6 +159,7 @@ export class GroupStore extends PuterStore {
|
||||
}): Promise<string> {
|
||||
const windowClause = this.clients.db.case<string>({
|
||||
sqlite: "datetime('now', '-1 hour')",
|
||||
postgres: "NOW() - INTERVAL '1 hour'",
|
||||
otherwise: 'NOW() - INTERVAL 1 HOUR',
|
||||
});
|
||||
const [countRow] = await this.clients.db.read(
|
||||
|
||||
@@ -20,6 +20,15 @@
|
||||
import { PuterStore } from '../types';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
|
||||
const isUniqueConstraintError = (e) => {
|
||||
return (
|
||||
e?.message?.includes('UNIQUE') ||
|
||||
e?.code === 'SQLITE_CONSTRAINT' ||
|
||||
e?.code === 'ER_DUP_ENTRY' ||
|
||||
e?.code === '23505'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* CRUD over the `user_oidc_providers` table.
|
||||
*
|
||||
@@ -54,11 +63,7 @@ export class OIDCStore extends PuterStore {
|
||||
);
|
||||
return;
|
||||
} catch (e) {
|
||||
const isUnique =
|
||||
e.message?.includes('UNIQUE') ||
|
||||
e.code === 'SQLITE_CONSTRAINT' ||
|
||||
e.code === 'ER_DUP_ENTRY';
|
||||
if (!isUnique) throw e;
|
||||
if (!isUniqueConstraintError(e)) throw e;
|
||||
}
|
||||
|
||||
// UNIQUE(provider, provider_sub) collision — either we're re-linking
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { OIDCStore } from './OIDCStore.js';
|
||||
|
||||
type OidcRow = {
|
||||
user_id: number;
|
||||
provider: string;
|
||||
provider_sub: string;
|
||||
};
|
||||
|
||||
const createPostgresUniqueError = (): Error & { code: string } => {
|
||||
return Object.assign(
|
||||
new Error(
|
||||
'duplicate key value violates unique constraint "idx_user_oidc_providers_provider_sub_unique"',
|
||||
),
|
||||
{ code: '23505' },
|
||||
);
|
||||
};
|
||||
|
||||
const createStore = (rows: readonly OidcRow[]) => {
|
||||
const db = {
|
||||
write: vi.fn(
|
||||
async (_sql: string, _params: readonly unknown[]): Promise<void> => {
|
||||
throw createPostgresUniqueError();
|
||||
},
|
||||
),
|
||||
read: vi.fn(
|
||||
async (
|
||||
_sql: string,
|
||||
_params: readonly unknown[],
|
||||
): Promise<readonly OidcRow[]> => rows,
|
||||
),
|
||||
};
|
||||
const store = new OIDCStore({}, { db });
|
||||
|
||||
return { db, store };
|
||||
};
|
||||
|
||||
describe('OIDCStore', () => {
|
||||
it('treats a Postgres unique violation as idempotent for an existing same-user link', async () => {
|
||||
const { db, store } = createStore([
|
||||
{
|
||||
user_id: 123,
|
||||
provider: 'test-provider',
|
||||
provider_sub: 'subject-1',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
store.link(123, 'test-provider', 'subject-1'),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(db.read).toHaveBeenCalledWith(
|
||||
expect.stringContaining('user_oidc_providers'),
|
||||
['test-provider', 'subject-1'],
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a Postgres unique violation for an existing different-user link', async () => {
|
||||
const { store } = createStore([
|
||||
{
|
||||
user_id: 456,
|
||||
provider: 'test-provider',
|
||||
provider_sub: 'subject-1',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
store.link(123, 'test-provider', 'subject-1'),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
legacyCode: 'conflict',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -169,11 +169,10 @@ export class PermissionStore extends PuterStore {
|
||||
permission: string,
|
||||
extra: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const upsertClause = this.clients.db.case<string>({
|
||||
mysql: 'ON DUPLICATE KEY UPDATE `extra` = ?',
|
||||
otherwise:
|
||||
'ON CONFLICT(`holder_user_id`, `issuer_user_id`, `permission`) DO UPDATE SET `extra` = ?',
|
||||
});
|
||||
const upsertClause = this.clients.db.upsertClause(
|
||||
['holder_user_id', 'issuer_user_id', 'permission'],
|
||||
['extra'],
|
||||
);
|
||||
await this.clients.db.write(
|
||||
'INSERT INTO `user_to_user_permissions` (`holder_user_id`, `issuer_user_id`, `permission`, `extra`) ' +
|
||||
`VALUES (?, ?, ?, ?) ${upsertClause}`,
|
||||
@@ -262,11 +261,10 @@ export class PermissionStore extends PuterStore {
|
||||
permission: string,
|
||||
extra: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const upsertClause = this.clients.db.case<string>({
|
||||
mysql: 'ON DUPLICATE KEY UPDATE `extra` = ?',
|
||||
otherwise:
|
||||
'ON CONFLICT(`user_id`, `app_id`, `permission`) DO UPDATE SET `extra` = ?',
|
||||
});
|
||||
const upsertClause = this.clients.db.upsertClause(
|
||||
['user_id', 'app_id', 'permission'],
|
||||
['extra'],
|
||||
);
|
||||
await this.clients.db.write(
|
||||
'INSERT INTO `user_to_app_permissions` (`user_id`, `app_id`, `permission`, `extra`) ' +
|
||||
`VALUES (?, ?, ?, ?) ${upsertClause}`,
|
||||
@@ -353,11 +351,10 @@ export class PermissionStore extends PuterStore {
|
||||
permission: string,
|
||||
extra: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const upsertClause = this.clients.db.case<string>({
|
||||
mysql: 'ON DUPLICATE KEY UPDATE `extra` = ?',
|
||||
otherwise:
|
||||
'ON CONFLICT(`user_id`, `app_id`, `permission`) DO UPDATE SET `extra` = ?',
|
||||
});
|
||||
const upsertClause = this.clients.db.upsertClause(
|
||||
['user_id', 'app_id', 'permission'],
|
||||
['extra'],
|
||||
);
|
||||
await this.clients.db.write(
|
||||
'INSERT INTO `dev_to_app_permissions` (`user_id`, `app_id`, `permission`, `extra`) ' +
|
||||
`VALUES (?, ?, ?, ?) ${upsertClause}`,
|
||||
@@ -443,11 +440,10 @@ export class PermissionStore extends PuterStore {
|
||||
permission: string,
|
||||
extra: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const upsertClause = this.clients.db.case<string>({
|
||||
mysql: 'ON DUPLICATE KEY UPDATE `extra` = ?',
|
||||
otherwise:
|
||||
'ON CONFLICT(`user_id`, `group_id`, `permission`) DO UPDATE SET `extra` = ?',
|
||||
});
|
||||
const upsertClause = this.clients.db.upsertClause(
|
||||
['user_id', 'group_id', 'permission'],
|
||||
['extra'],
|
||||
);
|
||||
await this.clients.db.write(
|
||||
'INSERT INTO `user_to_group_permissions` (`user_id`, `group_id`, `permission`, `extra`) ' +
|
||||
`VALUES (?, ?, ?, ?) ${upsertClause}`,
|
||||
|
||||
@@ -55,14 +55,15 @@ export const WORKER_WINDOW_SECONDS = 99 * 365 * 24 * 60 * 60; // 99y (virtually
|
||||
// FK / type violations must bubble up; otherwise the caller caches a
|
||||
// row that was never inserted.
|
||||
// better-sqlite3 surfaces SqliteError.code; mysql2 surfaces .code and
|
||||
// .errno (1062 = ER_DUP_ENTRY).
|
||||
// .errno (1062 = ER_DUP_ENTRY); pg surfaces SQLSTATE 23505.
|
||||
function isUniqueViolation(err) {
|
||||
if (!err) return false;
|
||||
const code = err.code;
|
||||
if (
|
||||
code === 'SQLITE_CONSTRAINT_UNIQUE' ||
|
||||
code === 'SQLITE_CONSTRAINT_PRIMARYKEY' ||
|
||||
code === 'ER_DUP_ENTRY'
|
||||
code === 'ER_DUP_ENTRY' ||
|
||||
code === '23505'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -462,7 +463,7 @@ export class SessionStore extends PuterStore {
|
||||
*
|
||||
* `appUid` is allowed null for user-scoped workers — the partial
|
||||
* unique index treats those distinctly (SQLite via NULL-distinct
|
||||
* semantics, MySQL via IFNULL in the generated key).
|
||||
* semantics, MySQL/Postgres via COALESCE in the generated key).
|
||||
*
|
||||
* @param userId - User row id (numeric).
|
||||
* @param opts.appUid - App UID or null for user-scoped workers.
|
||||
@@ -598,9 +599,16 @@ export class SessionStore extends PuterStore {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const coalesceLastIp = this.clients.db.nullCoalesce('`last_ip`', "''");
|
||||
const coalesceBoundIp = this.clients.db.nullCoalesce('?', "''");
|
||||
const coalesceLastUserAgent = this.clients.db.nullCoalesce(
|
||||
'`last_user_agent`',
|
||||
"''",
|
||||
);
|
||||
const coalesceBoundUserAgent = this.clients.db.nullCoalesce('?', "''");
|
||||
const selectOldest = () =>
|
||||
this.clients.db.read(
|
||||
"SELECT * FROM `sessions` WHERE `kind` = 'web' AND `user_id` = ? AND `created_via` = 'legacy_backfill' AND IFNULL(`last_ip`, '') = IFNULL(?, '') AND IFNULL(`last_user_agent`, '') = IFNULL(?, '') AND `revoked_at` IS NULL AND (`expires_at` IS NULL OR `expires_at` > ?) ORDER BY `id` ASC LIMIT 1",
|
||||
`SELECT * FROM \`sessions\` WHERE \`kind\` = 'web' AND \`user_id\` = ? AND \`created_via\` = 'legacy_backfill' AND ${coalesceLastIp} = ${coalesceBoundIp} AND ${coalesceLastUserAgent} = ${coalesceBoundUserAgent} AND \`revoked_at\` IS NULL AND (\`expires_at\` IS NULL OR \`expires_at\` > ?) ORDER BY \`id\` ASC LIMIT 1`,
|
||||
[opts.userId, ip, ua, now],
|
||||
);
|
||||
|
||||
@@ -713,7 +721,7 @@ export class SessionStore extends PuterStore {
|
||||
/** Update user-level last activity timestamp. */
|
||||
async updateUserActivity(userId, lastActivityTs) {
|
||||
await this.clients.db.write(
|
||||
'UPDATE `user` SET `last_activity_ts` = ? WHERE `id` = ? AND (`last_activity_ts` IS NULL OR `last_activity_ts` < ?) LIMIT 1',
|
||||
'UPDATE `user` SET `last_activity_ts` = ? WHERE `id` = ? AND (`last_activity_ts` IS NULL OR `last_activity_ts` < ?)',
|
||||
[lastActivityTs, userId, lastActivityTs],
|
||||
);
|
||||
}
|
||||
@@ -915,23 +923,18 @@ export class SessionStore extends PuterStore {
|
||||
/**
|
||||
* Active worker session for (userId, appUid, workerName). Matches
|
||||
* the partial unique index `idx_sessions_user_worker_active`.
|
||||
* `appUid` is allowed null for user-scoped workers; IFNULL keeps
|
||||
* `appUid` is allowed null for user-scoped workers; COALESCE keeps
|
||||
* the comparison correct since SQL `= NULL` doesn't match.
|
||||
*
|
||||
* MySQL's `JSON_EXTRACT` returns a JSON-typed value with embedded
|
||||
* quoting (`"name"` rather than `name`), which never equals a
|
||||
* plain string bind. Use `JSON_UNQUOTE(JSON_EXTRACT(...))` on
|
||||
* MySQL to strip that. SQLite's `json_extract` already returns the
|
||||
* unwrapped scalar so the literal form works there.
|
||||
*/
|
||||
async #selectWorkerRow(userId, appUid, workerName) {
|
||||
const now = nowSeconds();
|
||||
const workerNameExpr = this.clients.db.case({
|
||||
sqlite: "json_extract(`meta`, '$.worker_name')",
|
||||
otherwise: "JSON_UNQUOTE(JSON_EXTRACT(`meta`, '$.worker_name'))",
|
||||
});
|
||||
const workerNameExpr = this.clients.db.jsonTextExtract('`meta`', [
|
||||
'worker_name',
|
||||
]);
|
||||
const appUidExpr = this.clients.db.nullCoalesce('`app_uid`', "''");
|
||||
const appUidBound = this.clients.db.nullCoalesce('?', "''");
|
||||
const rows = await this.clients.db.read(
|
||||
`SELECT * FROM \`sessions\` WHERE \`kind\` = 'worker' AND \`user_id\` = ? AND IFNULL(\`app_uid\`, '') = IFNULL(?, '') AND ${workerNameExpr} = ? AND \`revoked_at\` IS NULL AND (\`expires_at\` IS NULL OR \`expires_at\` > ?) LIMIT 1`,
|
||||
`SELECT * FROM \`sessions\` WHERE \`kind\` = 'worker' AND \`user_id\` = ? AND ${appUidExpr} = ${appUidBound} AND ${workerNameExpr} = ? AND \`revoked_at\` IS NULL AND (\`expires_at\` IS NULL OR \`expires_at\` > ?) LIMIT 1`,
|
||||
[userId, appUid ?? null, workerName, now],
|
||||
);
|
||||
return this.#normalizeRow(rows[0]);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { setupTestServer } from '../../testUtil.ts';
|
||||
import { PuterServer } from '../../server.ts';
|
||||
|
||||
describe('UserStore', () => {
|
||||
let server: PuterServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('keeps cached booleans normalized after update', async () => {
|
||||
const username = `us-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const user = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
});
|
||||
|
||||
await server.stores.user.update(user.id, {
|
||||
email_confirmed: false,
|
||||
requires_email_confirmation: true,
|
||||
});
|
||||
|
||||
const cachedUser = await server.stores.user.getById(user.id);
|
||||
|
||||
expect(cachedUser?.email_confirmed).toBe(false);
|
||||
expect(cachedUser?.requires_email_confirmation).toBe(true);
|
||||
expect(typeof cachedUser?.email_confirmed).toBe('boolean');
|
||||
expect(typeof cachedUser?.requires_email_confirmation).toBe('boolean');
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,15 @@ const LATIN1_USER_COLUMNS: ReadonlySet<string> = new Set([
|
||||
'username',
|
||||
'clean_email',
|
||||
]);
|
||||
const USER_BOOLEAN_COLUMNS: ReadonlySet<string> = new Set([
|
||||
'requires_email_confirmation',
|
||||
'email_confirmed',
|
||||
'dev_approved_for_incentive_program',
|
||||
'dev_joined_incentive_program',
|
||||
'suspended',
|
||||
'unsubscribed',
|
||||
'otp_enabled',
|
||||
]);
|
||||
|
||||
const assertLatin1Writable = (fields: Record<string, unknown>): void => {
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
@@ -336,7 +345,7 @@ export class UserStore extends PuterStore {
|
||||
signup_server,
|
||||
referrer,
|
||||
last_activity_ts)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)${this.clients.db.returningIdClause()}`,
|
||||
[
|
||||
fields.username,
|
||||
fields.email,
|
||||
@@ -344,7 +353,9 @@ export class UserStore extends PuterStore {
|
||||
fields.password,
|
||||
fields.uuid,
|
||||
fields.free_storage ?? null,
|
||||
fields.requires_email_confirmation ? 1 : 0,
|
||||
this.clients.db.booleanValue(
|
||||
Boolean(fields.requires_email_confirmation),
|
||||
),
|
||||
fields.email_confirm_code ?? null,
|
||||
fields.email_confirm_token ?? null,
|
||||
fields.audit_metadata
|
||||
@@ -380,13 +391,23 @@ export class UserStore extends PuterStore {
|
||||
userId: number,
|
||||
patch: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const keys = Object.keys(patch);
|
||||
const dbPatch: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
dbPatch[key] =
|
||||
USER_BOOLEAN_COLUMNS.has(key) &&
|
||||
value !== null &&
|
||||
value !== undefined
|
||||
? this.clients.db.booleanValue(Boolean(value))
|
||||
: value;
|
||||
}
|
||||
|
||||
const keys = Object.keys(dbPatch);
|
||||
if (keys.length === 0) return;
|
||||
|
||||
assertLatin1Writable(patch);
|
||||
assertLatin1Writable(dbPatch);
|
||||
|
||||
const setClause = keys.map((k) => `\`${k}\` = ?`).join(', ');
|
||||
const values = keys.map((k) => patch[k]);
|
||||
const values = keys.map((k) => dbPatch[k]);
|
||||
|
||||
await this.clients.db.write(
|
||||
`UPDATE \`user\` SET ${setClause} WHERE \`id\` = ?`,
|
||||
@@ -395,7 +416,7 @@ export class UserStore extends PuterStore {
|
||||
|
||||
const fresh = await this.getByProperty('id', userId, { force: true });
|
||||
if (fresh) {
|
||||
await this.#refreshCache({ ...fresh, ...patch });
|
||||
await this.#refreshCache(fresh);
|
||||
} else {
|
||||
await this.invalidateById(userId);
|
||||
}
|
||||
@@ -428,13 +449,19 @@ export class UserStore extends PuterStore {
|
||||
`UPDATE \`user\`
|
||||
SET \`email\` = NULL,
|
||||
\`clean_email\` = NULL,
|
||||
\`email_confirmed\` = 0,
|
||||
\`requires_email_confirmation\` = 0,
|
||||
\`email_confirmed\` = ?,
|
||||
\`requires_email_confirmation\` = ?,
|
||||
\`email_confirm_code\` = NULL,
|
||||
\`email_confirm_token\` = NULL
|
||||
WHERE \`id\` != ?
|
||||
AND (\`email\` = ? OR \`clean_email\` = ?)`,
|
||||
[userId, email, cleanEmailValue],
|
||||
[
|
||||
this.clients.db.booleanValue(false),
|
||||
this.clients.db.booleanValue(false),
|
||||
userId,
|
||||
email,
|
||||
cleanEmailValue,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+125
-4
@@ -1,8 +1,96 @@
|
||||
import { deepMerge } from '../../tools/lib/configMigration.mjs';
|
||||
import { PuterServer } from './server';
|
||||
import { IConfig } from './types';
|
||||
import { puterClients } from './clients';
|
||||
import {
|
||||
PostgresDatabaseClient,
|
||||
type PostgresPool,
|
||||
} from './clients/database/PostgresDatabaseClient';
|
||||
import type { PoolConfig } from 'pg';
|
||||
|
||||
export const setupTestServer = async (configOverrides?: IConfig) => {
|
||||
export const POSTGRES_TEST_MIGRATIONS_PATH =
|
||||
'src/backend/clients/database/migrations/postgres';
|
||||
|
||||
type PgMockPostgresHarness = {
|
||||
client: PostgresDatabaseClient;
|
||||
createClient: () => PostgresDatabaseClient;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
const usesPgMockPostgres = (config: IConfig): boolean => {
|
||||
const database = config.database;
|
||||
if (database?.engine !== 'postgres' || database.inMemory !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !(
|
||||
database.connectionString ||
|
||||
database.url ||
|
||||
database.host ||
|
||||
database.port ||
|
||||
database.user ||
|
||||
database.password ||
|
||||
database.database ||
|
||||
database.replica
|
||||
);
|
||||
};
|
||||
|
||||
export const createPgMockPostgresDatabaseClient = async (
|
||||
config: IConfig,
|
||||
): Promise<PgMockPostgresHarness> => {
|
||||
const [{ PostgresMock }, { Pool }] = await Promise.all([
|
||||
import('pgmock'),
|
||||
import('pg'),
|
||||
]);
|
||||
const mock = await PostgresMock.create();
|
||||
const pgMockConfig = mock.getNodePostgresConfig();
|
||||
const createPgMockStream = pgMockConfig.stream;
|
||||
const poolFactory = (poolConfig: PoolConfig): PostgresPool =>
|
||||
new Pool({
|
||||
...poolConfig,
|
||||
database: 'postgres',
|
||||
...pgMockConfig,
|
||||
stream: () => {
|
||||
const stream = createPgMockStream();
|
||||
stream.ref = () => stream;
|
||||
stream.unref = () => stream;
|
||||
return stream;
|
||||
},
|
||||
}) as unknown as PostgresPool;
|
||||
const createClient = () => new PostgresDatabaseClient(config, poolFactory);
|
||||
|
||||
return {
|
||||
client: createClient(),
|
||||
createClient,
|
||||
destroy: () => mock.destroy(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* When `PUTER_TEST_DB_ENGINE=postgres` is set, `setupTestServer` swaps its
|
||||
* default sqlite test database for an in-memory Postgres backed by pgmock
|
||||
* (with the bundled Postgres migrations applied on boot). Tests that
|
||||
* explicitly override `database` still win — the env var only affects the
|
||||
* implicit default used by callers that don't pass any DB overrides.
|
||||
*
|
||||
* Recognized values: `postgres` → pgmock. Anything else (including unset) →
|
||||
* the original sqlite-in-memory default.
|
||||
*/
|
||||
const testDatabaseDefault = (): IConfig['database'] => {
|
||||
const engine = (process.env.PUTER_TEST_DB_ENGINE ?? '').toLowerCase();
|
||||
if (engine === 'postgres') {
|
||||
return {
|
||||
engine: 'postgres',
|
||||
inMemory: true,
|
||||
migrationPaths: [POSTGRES_TEST_MIGRATIONS_PATH],
|
||||
};
|
||||
}
|
||||
return { engine: 'sqlite', inMemory: true };
|
||||
};
|
||||
|
||||
export const setupTestServer = async (
|
||||
configOverrides?: IConfig,
|
||||
): Promise<PuterServer> => {
|
||||
// read default config json
|
||||
const defaultConfig = await import('../../config.default.json', {
|
||||
with: {
|
||||
@@ -14,7 +102,7 @@ export const setupTestServer = async (configOverrides?: IConfig) => {
|
||||
deepMerge(defaultConfig, {
|
||||
extensions: [],
|
||||
port: 0,
|
||||
database: { engine: 'sqlite', inMemory: true },
|
||||
database: testDatabaseDefault(),
|
||||
dynamo: { inMemory: true, bootstrapTables: true },
|
||||
redis: { useMock: true },
|
||||
s3: { localConfig: { inMemory: true } },
|
||||
@@ -22,8 +110,41 @@ export const setupTestServer = async (configOverrides?: IConfig) => {
|
||||
no_devwatch: true,
|
||||
}),
|
||||
configOverrides ?? {},
|
||||
) as IConfig;
|
||||
|
||||
let pgMockClient: PgMockPostgresHarness | undefined;
|
||||
if (usesPgMockPostgres(config)) {
|
||||
const database = config.database;
|
||||
if (!database) {
|
||||
throw new Error('Postgres test database config is missing');
|
||||
}
|
||||
database.migrationPaths ??= [POSTGRES_TEST_MIGRATIONS_PATH];
|
||||
pgMockClient = await createPgMockPostgresDatabaseClient(config);
|
||||
}
|
||||
|
||||
const server = new PuterServer(
|
||||
config,
|
||||
pgMockClient ? { ...puterClients, db: pgMockClient.client } : undefined,
|
||||
);
|
||||
const server = new PuterServer(config);
|
||||
await server.start(true);
|
||||
|
||||
if (pgMockClient) {
|
||||
const originalShutdown = server.shutdown.bind(server);
|
||||
let destroyPgMock: (() => void) | undefined = pgMockClient.destroy;
|
||||
server.shutdown = async () => {
|
||||
try {
|
||||
await originalShutdown();
|
||||
} finally {
|
||||
destroyPgMock?.();
|
||||
destroyPgMock = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await server.start(true);
|
||||
} catch (e) {
|
||||
pgMockClient?.destroy();
|
||||
throw e;
|
||||
}
|
||||
return server;
|
||||
};
|
||||
|
||||
@@ -306,7 +306,7 @@ export interface IS3Config {
|
||||
}
|
||||
|
||||
export interface IDatabaseConfig {
|
||||
engine: 'sqlite' | 'mysql';
|
||||
engine: 'sqlite' | 'mysql' | 'postgres';
|
||||
// sqlite
|
||||
/**
|
||||
* SQLite database file path. Defaults to `':memory:'` (the
|
||||
@@ -317,7 +317,8 @@ export interface IDatabaseConfig {
|
||||
/**
|
||||
* Force in-memory SQLite (ignores `path`). Equivalent to
|
||||
* `path: ':memory:'`. Intended for tests so each suite gets a
|
||||
* pristine in-process database.
|
||||
* pristine in-process database. Test utilities also use
|
||||
* `engine: 'postgres'` with `inMemory: true` to run against pgmock.
|
||||
*/
|
||||
inMemory?: boolean;
|
||||
targetVersion?: number;
|
||||
@@ -327,17 +328,21 @@ export interface IDatabaseConfig {
|
||||
user?: string;
|
||||
password?: string;
|
||||
database?: string;
|
||||
connectionString?: string;
|
||||
url?: string;
|
||||
replica?: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
database?: string;
|
||||
connectionString?: string;
|
||||
url?: string;
|
||||
};
|
||||
/**
|
||||
* Ordered list of directories whose `.sql` files are run sequentially at
|
||||
* server start (mysql engine only). Files within a directory are sorted
|
||||
* lexically; directories are processed in array order. Files MUST be
|
||||
* server start (mysql/postgres engines). Numbered migration filenames sort
|
||||
* numerically; directories are processed in array order. Files MUST be
|
||||
* idempotent — there is no per-file applied-state tracking.
|
||||
* Relative paths resolve from `process.cwd()`.
|
||||
*/
|
||||
|
||||
@@ -81,10 +81,11 @@ export async function generateDefaultFsentries(
|
||||
]),
|
||||
];
|
||||
|
||||
// Each row: uuid, parent_uid, user_id, name, path, created, modified
|
||||
// is_dir and immutable are hardcoded to 1.
|
||||
// Each row: uuid, parent_uid, user_id, name, path, created, modified.
|
||||
// is_dir and immutable are hardcoded to the database's true literal.
|
||||
const trueLiteral = db.booleanLiteral(true);
|
||||
const placeholders = rows
|
||||
.map(() => '(?, ?, ?, ?, ?, 1, ?, ?, 1)')
|
||||
.map(() => `(?, ?, ?, ?, ?, ${trueLiteral}, ?, ?, ${trueLiteral})`)
|
||||
.join(', ');
|
||||
const params: unknown[] = [];
|
||||
for (const [uuid, parent, name, path] of rows) {
|
||||
|
||||
@@ -27,6 +27,16 @@ const isCi = process.env.CI === 'true';
|
||||
const backendDir = __dirname;
|
||||
const repoRoot = path.resolve(backendDir, '../..');
|
||||
|
||||
// pgmock boots a WASM-emulated Postgres on every `setupTestServer()` call —
|
||||
// migrations alone take ~60s, and the WASM VM serializes badly across
|
||||
// concurrent emulator instances. So in pgmock mode we (a) bump hook/test
|
||||
// timeouts well beyond the 10s default and (b) disable file parallelism so
|
||||
// only one pgmock VM runs at a time. Tests still race their own logic
|
||||
// internally; we only serialize the *files*.
|
||||
const isPgmockMode =
|
||||
(process.env.PUTER_TEST_DB_ENGINE ?? '').toLowerCase() === 'postgres';
|
||||
const pgmockTimeoutMs = 600_000;
|
||||
|
||||
// Vite 8's oxc transform leaves TC39 stage-3 decorators in place
|
||||
// (used by `@Controller`/`@Post`), so they reach Node verbatim and
|
||||
// crash with "SyntaxError: Invalid or unexpected token". Pre-transform
|
||||
@@ -72,6 +82,13 @@ export default defineConfig(({ mode }) => ({
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
...(isPgmockMode
|
||||
? {
|
||||
testTimeout: pgmockTimeoutMs,
|
||||
hookTimeout: pgmockTimeoutMs,
|
||||
fileParallelism: false,
|
||||
}
|
||||
: {}),
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: isCi
|
||||
|
||||
Reference in New Issue
Block a user