Files
racket-chatgpt-bootstrap/promptstore.php
T
2026-05-25 13:47:46 +02:00

1075 lines
29 KiB
PHP

<?php
/*
* promptstore.php
*
* Prompt storage for Racket sandbox.
*
* Supports:
* - personal user prompts
* - global default prompts managed by admins
* - prompt versions
* - automatic SQLite migration
*
* Database:
* data/racket-sandbox.sqlite
*
* Model:
* user_id = 0 and is_default = 1:
* global default prompt
*
* user_id > 0 and is_default = 0:
* personal user prompt
*/
class PromptStoreException extends Exception
{
}
class PromptRecord
{
private $row;
public function __construct($row)
{
$this->row = is_array($row) ? $row : array();
}
public function id()
{
return (int)($this->row['id'] ?? 0);
}
public function userId()
{
return (int)($this->row['user_id'] ?? 0);
}
public function isDefault()
{
return (int)($this->row['is_default'] ?? 0) === 1;
}
public function defaultKey()
{
return (string)($this->row['default_key'] ?? '');
}
public function name()
{
return (string)($this->row['name'] ?? '');
}
public function language()
{
return (string)($this->row['language'] ?? '');
}
public function content()
{
return (string)($this->row['content'] ?? '');
}
public function createdAt()
{
return isset($this->row['created_at']) ? (int)$this->row['created_at'] : null;
}
public function updatedAt()
{
return isset($this->row['updated_at']) ? (int)$this->row['updated_at'] : null;
}
public function deletedAt()
{
return isset($this->row['deleted_at']) && $this->row['deleted_at'] !== null
? (int)$this->row['deleted_at']
: null;
}
public function raw()
{
return $this->row;
}
}
class PromptVersionRecord
{
private $row;
public function __construct($row)
{
$this->row = is_array($row) ? $row : array();
}
public function id()
{
return (int)($this->row['id'] ?? 0);
}
public function promptId()
{
return (int)($this->row['prompt_id'] ?? 0);
}
public function userId()
{
return (int)($this->row['user_id'] ?? 0);
}
public function versionNo()
{
return (int)($this->row['version_no'] ?? 0);
}
public function name()
{
return (string)($this->row['name'] ?? '');
}
public function language()
{
return (string)($this->row['language'] ?? '');
}
public function content()
{
return (string)($this->row['content'] ?? '');
}
public function note()
{
return (string)($this->row['note'] ?? '');
}
public function createdAt()
{
return isset($this->row['created_at']) ? (int)$this->row['created_at'] : null;
}
public function raw()
{
return $this->row;
}
}
class PromptStore
{
private $db;
public function __construct($dbFile)
{
$dir = dirname($dbFile);
if (!is_dir($dir)) {
if (!mkdir($dir, 0700, true)) {
throw new PromptStoreException('Could not create database directory: ' . $dir);
}
}
$this->db = new PDO('sqlite:' . $dbFile);
$this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->db->exec('PRAGMA journal_mode = WAL');
$this->db->exec('PRAGMA busy_timeout = 5000');
$this->init();
}
private function init()
{
/*
* Canonical schema.
*
* Existing databases are migrated below. In particular, this version
* does not require nullable user_id. Global defaults use user_id = 0.
*/
$this->db->exec(
'CREATE TABLE IF NOT EXISTS prompts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
is_default INTEGER NOT NULL DEFAULT 0,
default_key TEXT NULL,
name TEXT NOT NULL,
language TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER NULL
)'
);
$this->migratePromptsTable();
$this->db->exec(
'CREATE INDEX IF NOT EXISTS idx_prompts_user
ON prompts(user_id, is_default, deleted_at, language, name)'
);
$this->db->exec(
'CREATE INDEX IF NOT EXISTS idx_prompts_default
ON prompts(is_default, deleted_at, language, default_key)'
);
$this->db->exec(
'CREATE TABLE IF NOT EXISTS prompt_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prompt_id INTEGER NOT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
version_no INTEGER NOT NULL,
name TEXT NOT NULL,
language TEXT NOT NULL,
content TEXT NOT NULL,
note TEXT NOT NULL DEFAULT "",
created_at INTEGER NOT NULL,
FOREIGN KEY(prompt_id) REFERENCES prompts(id),
UNIQUE(prompt_id, version_no)
)'
);
$this->migratePromptVersionsTable();
$this->db->exec(
'CREATE INDEX IF NOT EXISTS idx_prompt_versions_prompt
ON prompt_versions(prompt_id, version_no)'
);
}
private function migratePromptsTable()
{
$cols = $this->tableColumns('prompts');
if (!isset($cols['user_id'])) {
$this->db->exec('ALTER TABLE prompts ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0');
}
if (!isset($cols['is_default'])) {
$this->db->exec('ALTER TABLE prompts ADD COLUMN is_default INTEGER NOT NULL DEFAULT 0');
}
if (!isset($cols['default_key'])) {
$this->db->exec('ALTER TABLE prompts ADD COLUMN default_key TEXT NULL');
}
if (!isset($cols['deleted_at'])) {
$this->db->exec('ALTER TABLE prompts ADD COLUMN deleted_at INTEGER NULL');
}
/*
* Normalize existing rows.
*/
$this->db->exec(
'UPDATE prompts
SET is_default = 0
WHERE is_default IS NULL'
);
$this->db->exec(
'UPDATE prompts
SET user_id = 0
WHERE is_default = 1'
);
}
private function migratePromptVersionsTable()
{
$cols = $this->tableColumns('prompt_versions');
if (!isset($cols['user_id'])) {
$this->db->exec('ALTER TABLE prompt_versions ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0');
}
if (!isset($cols['note'])) {
$this->db->exec('ALTER TABLE prompt_versions ADD COLUMN note TEXT NOT NULL DEFAULT ""');
}
$this->db->exec(
'UPDATE prompt_versions
SET user_id = 0
WHERE user_id IS NULL'
);
}
private function tableColumns($table)
{
$stmt = $this->db->query('PRAGMA table_info(' . $table . ')');
$cols = array();
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$cols[$row['name']] = true;
}
return $cols;
}
public function supportedLanguages()
{
return array('en', 'nl');
}
public function languageLabel($language)
{
if ($language === 'nl') {
return 'Nederlands';
}
if ($language === 'en') {
return 'English';
}
return $language;
}
/* ------------------------------------------------------------------ */
/* Personal prompts */
/* ------------------------------------------------------------------ */
public function listPrompts($userId, $language = null)
{
$userId = $this->safeUserId($userId);
if ($language !== null && $language !== '') {
$language = $this->safeLanguage($language);
$stmt = $this->db->prepare(
'SELECT *
FROM prompts
WHERE user_id = :user_id
AND is_default = 0
AND deleted_at IS NULL
AND language = :language
ORDER BY name COLLATE NOCASE'
);
$stmt->execute(array(
':user_id' => $userId,
':language' => $language,
));
} else {
$stmt = $this->db->prepare(
'SELECT *
FROM prompts
WHERE user_id = :user_id
AND is_default = 0
AND deleted_at IS NULL
ORDER BY language, name COLLATE NOCASE'
);
$stmt->execute(array(':user_id' => $userId));
}
return $this->promptRows($stmt);
}
public function getPrompt($userId, $promptId)
{
$userId = $this->safeUserId($userId);
$promptId = $this->safeId($promptId);
$stmt = $this->db->prepare(
'SELECT *
FROM prompts
WHERE id = :id
AND user_id = :user_id
AND is_default = 0
AND deleted_at IS NULL'
);
$stmt->execute(array(
':id' => $promptId,
':user_id' => $userId,
));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? new PromptRecord($row) : null;
}
public function createPrompt($userId, $name, $language, $content)
{
$userId = $this->safeUserId($userId);
$name = $this->safeName($name);
$language = $this->safeLanguage($language);
$content = $this->safeContent($content);
$now = time();
$stmt = $this->db->prepare(
'INSERT INTO prompts
(user_id, is_default, default_key, name, language, content,
created_at, updated_at, deleted_at)
VALUES
(:user_id, 0, NULL, :name, :language, :content,
:created_at, :updated_at, NULL)'
);
$stmt->execute(array(
':user_id' => $userId,
':name' => $name,
':language' => $language,
':content' => $content,
':created_at' => $now,
':updated_at' => $now,
));
$promptId = (int)$this->db->lastInsertId();
$this->createVersionForPrompt($promptId, $userId, 'initial version');
return $this->getPrompt($userId, $promptId);
}
public function updatePrompt($userId, $promptId, $name, $language, $content, $createVersion, $note = '')
{
$userId = $this->safeUserId($userId);
$promptId = $this->safeId($promptId);
$name = $this->safeName($name);
$language = $this->safeLanguage($language);
$content = $this->safeContent($content);
$prompt = $this->getPrompt($userId, $promptId);
if ($prompt === null) {
throw new PromptStoreException('Prompt not found.');
}
$stmt = $this->db->prepare(
'UPDATE prompts
SET name = :name,
language = :language,
content = :content,
updated_at = :updated_at
WHERE id = :id
AND user_id = :user_id
AND is_default = 0
AND deleted_at IS NULL'
);
$stmt->execute(array(
':name' => $name,
':language' => $language,
':content' => $content,
':updated_at' => time(),
':id' => $promptId,
':user_id' => $userId,
));
if ($createVersion) {
$this->createVersionForPrompt($promptId, $userId, $note);
}
return $this->getPrompt($userId, $promptId);
}
public function deletePrompt($userId, $promptId)
{
$userId = $this->safeUserId($userId);
$promptId = $this->safeId($promptId);
$stmt = $this->db->prepare(
'UPDATE prompts
SET deleted_at = :deleted_at,
updated_at = :updated_at
WHERE id = :id
AND user_id = :user_id
AND is_default = 0
AND deleted_at IS NULL'
);
$stmt->execute(array(
':deleted_at' => time(),
':updated_at' => time(),
':id' => $promptId,
':user_id' => $userId,
));
}
/* ------------------------------------------------------------------ */
/* Default prompts */
/* ------------------------------------------------------------------ */
public function listDefaultPrompts($language = null)
{
if ($language !== null && $language !== '') {
$language = $this->safeLanguage($language);
$stmt = $this->db->prepare(
'SELECT *
FROM prompts
WHERE user_id = 0
AND is_default = 1
AND deleted_at IS NULL
AND language = :language
ORDER BY name COLLATE NOCASE'
);
$stmt->execute(array(':language' => $language));
} else {
$stmt = $this->db->query(
'SELECT *
FROM prompts
WHERE user_id = 0
AND is_default = 1
AND deleted_at IS NULL
ORDER BY language, name COLLATE NOCASE'
);
}
return $this->promptRows($stmt);
}
public function getDefaultPrompt($promptId)
{
$promptId = $this->safeId($promptId);
$stmt = $this->db->prepare(
'SELECT *
FROM prompts
WHERE id = :id
AND user_id = 0
AND is_default = 1
AND deleted_at IS NULL'
);
$stmt->execute(array(':id' => $promptId));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? new PromptRecord($row) : null;
}
public function createDefaultPrompt($defaultKey, $name, $language, $content)
{
$defaultKey = $this->safeDefaultKey($defaultKey);
$name = $this->safeName($name);
$language = $this->safeLanguage($language);
$content = $this->safeContent($content);
$now = time();
$stmt = $this->db->prepare(
'INSERT INTO prompts
(user_id, is_default, default_key, name, language, content,
created_at, updated_at, deleted_at)
VALUES
(0, 1, :default_key, :name, :language, :content,
:created_at, :updated_at, NULL)'
);
$stmt->execute(array(
':default_key' => $defaultKey,
':name' => $name,
':language' => $language,
':content' => $content,
':created_at' => $now,
':updated_at' => $now,
));
$promptId = (int)$this->db->lastInsertId();
$this->createVersionForPrompt($promptId, 0, 'initial default version');
return $this->getDefaultPrompt($promptId);
}
public function updateDefaultPrompt($promptId, $defaultKey, $name, $language, $content, $createVersion, $note = '')
{
$promptId = $this->safeId($promptId);
$defaultKey = $this->safeDefaultKey($defaultKey);
$name = $this->safeName($name);
$language = $this->safeLanguage($language);
$content = $this->safeContent($content);
$prompt = $this->getDefaultPrompt($promptId);
if ($prompt === null) {
throw new PromptStoreException('Default prompt not found.');
}
$stmt = $this->db->prepare(
'UPDATE prompts
SET default_key = :default_key,
name = :name,
language = :language,
content = :content,
updated_at = :updated_at
WHERE id = :id
AND user_id = 0
AND is_default = 1
AND deleted_at IS NULL'
);
$stmt->execute(array(
':default_key' => $defaultKey,
':name' => $name,
':language' => $language,
':content' => $content,
':updated_at' => time(),
':id' => $promptId,
));
if ($createVersion) {
$this->createVersionForPrompt($promptId, 0, $note);
}
return $this->getDefaultPrompt($promptId);
}
public function deleteDefaultPrompt($promptId)
{
$promptId = $this->safeId($promptId);
$stmt = $this->db->prepare(
'UPDATE prompts
SET deleted_at = :deleted_at,
updated_at = :updated_at
WHERE id = :id
AND user_id = 0
AND is_default = 1
AND deleted_at IS NULL'
);
$stmt->execute(array(
':deleted_at' => time(),
':updated_at' => time(),
':id' => $promptId,
));
}
public function copyDefaultPromptToUser($userId, $defaultPromptId)
{
$userId = $this->safeUserId($userId);
$defaultPromptId = $this->safeId($defaultPromptId);
$default = $this->getDefaultPrompt($defaultPromptId);
if ($default === null) {
throw new PromptStoreException('Default prompt not found.');
}
$now = time();
$stmt = $this->db->prepare(
'INSERT INTO prompts
(user_id, is_default, default_key, name, language, content,
created_at, updated_at, deleted_at)
VALUES
(:user_id, 0, :default_key, :name, :language, :content,
:created_at, :updated_at, NULL)'
);
$stmt->execute(array(
':user_id' => $userId,
':default_key' => $default->defaultKey(),
':name' => $default->name(),
':language' => $default->language(),
':content' => $default->content(),
':created_at' => $now,
':updated_at' => $now,
));
$promptId = (int)$this->db->lastInsertId();
$this->createVersionForPrompt($promptId, $userId, 'copied from default ' . $default->defaultKey());
return $this->getPrompt($userId, $promptId);
}
public function copyAllDefaultsToUser($userId, $language)
{
$userId = $this->safeUserId($userId);
$language = $this->safeLanguage($language);
$defaults = $this->listDefaultPrompts($language);
$created = 0;
foreach ($defaults as $default) {
if ($this->userHasPromptForDefault($userId, $default->defaultKey(), $language)) {
continue;
}
$this->copyDefaultPromptToUser($userId, $default->id());
$created++;
}
return $created;
}
public function userHasPromptForDefault($userId, $defaultKey, $language)
{
$userId = $this->safeUserId($userId);
$defaultKey = $this->safeDefaultKey($defaultKey);
$language = $this->safeLanguage($language);
$stmt = $this->db->prepare(
'SELECT id
FROM prompts
WHERE user_id = :user_id
AND is_default = 0
AND deleted_at IS NULL
AND default_key = :default_key
AND language = :language'
);
$stmt->execute(array(
':user_id' => $userId,
':default_key' => $defaultKey,
':language' => $language,
));
return $stmt->fetch(PDO::FETCH_ASSOC) ? true : false;
}
/* ------------------------------------------------------------------ */
/* Versions */
/* ------------------------------------------------------------------ */
public function createVersion($userId, $promptId, $note = '')
{
$userId = $this->safeUserId($userId);
$promptId = $this->safeId($promptId);
if ($this->getPrompt($userId, $promptId) === null) {
throw new PromptStoreException('Prompt not found.');
}
return $this->createVersionForPrompt($promptId, $userId, $note);
}
public function createDefaultVersion($promptId, $note = '')
{
$promptId = $this->safeId($promptId);
if ($this->getDefaultPrompt($promptId) === null) {
throw new PromptStoreException('Default prompt not found.');
}
return $this->createVersionForPrompt($promptId, 0, $note);
}
public function listVersions($userId, $promptId)
{
$userId = $this->safeUserId($userId);
$promptId = $this->safeId($promptId);
if ($this->getPrompt($userId, $promptId) === null) {
throw new PromptStoreException('Prompt not found.');
}
return $this->listVersionsForPrompt($promptId);
}
public function listDefaultVersions($promptId)
{
$promptId = $this->safeId($promptId);
if ($this->getDefaultPrompt($promptId) === null) {
throw new PromptStoreException('Default prompt not found.');
}
return $this->listVersionsForPrompt($promptId);
}
public function restoreVersion($userId, $promptId, $versionNo)
{
$userId = $this->safeUserId($userId);
$promptId = $this->safeId($promptId);
if ($this->getPrompt($userId, $promptId) === null) {
throw new PromptStoreException('Prompt not found.');
}
$this->restoreVersionForPrompt($promptId, $versionNo, $userId);
}
public function restoreDefaultVersion($promptId, $versionNo)
{
$promptId = $this->safeId($promptId);
if ($this->getDefaultPrompt($promptId) === null) {
throw new PromptStoreException('Default prompt not found.');
}
$this->restoreVersionForPrompt($promptId, $versionNo, 0);
}
private function createVersionForPrompt($promptId, $userId, $note = '')
{
$promptId = $this->safeId($promptId);
$userId = (int)$userId;
$note = trim((string)$note);
if (strlen($note) > 500) {
$note = substr($note, 0, 500);
}
$stmt = $this->db->prepare(
'SELECT *
FROM prompts
WHERE id = :id
AND deleted_at IS NULL'
);
$stmt->execute(array(':id' => $promptId));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new PromptStoreException('Prompt not found.');
}
$stmt = $this->db->prepare(
'SELECT COALESCE(MAX(version_no), 0) + 1 AS next_no
FROM prompt_versions
WHERE prompt_id = :prompt_id'
);
$stmt->execute(array(':prompt_id' => $promptId));
$versionRow = $stmt->fetch(PDO::FETCH_ASSOC);
$versionNo = (int)$versionRow['next_no'];
$stmt = $this->db->prepare(
'INSERT INTO prompt_versions
(prompt_id, user_id, version_no, name, language, content, note, created_at)
VALUES
(:prompt_id, :user_id, :version_no, :name, :language, :content, :note, :created_at)'
);
$stmt->execute(array(
':prompt_id' => $promptId,
':user_id' => $userId,
':version_no' => $versionNo,
':name' => $row['name'],
':language' => $row['language'],
':content' => $row['content'],
':note' => $note,
':created_at' => time(),
));
return $versionNo;
}
private function listVersionsForPrompt($promptId)
{
$promptId = $this->safeId($promptId);
$stmt = $this->db->prepare(
'SELECT *
FROM prompt_versions
WHERE prompt_id = :prompt_id
ORDER BY version_no DESC'
);
$stmt->execute(array(':prompt_id' => $promptId));
$out = array();
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$out[] = new PromptVersionRecord($row);
}
return $out;
}
private function restoreVersionForPrompt($promptId, $versionNo, $userId)
{
$promptId = $this->safeId($promptId);
$versionNo = (int)$versionNo;
$userId = (int)$userId;
if ($versionNo <= 0) {
throw new PromptStoreException('Invalid version number.');
}
$stmt = $this->db->prepare(
'SELECT *
FROM prompt_versions
WHERE prompt_id = :prompt_id
AND version_no = :version_no'
);
$stmt->execute(array(
':prompt_id' => $promptId,
':version_no' => $versionNo,
));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new PromptStoreException('Version not found.');
}
$stmt = $this->db->prepare(
'UPDATE prompts
SET name = :name,
language = :language,
content = :content,
updated_at = :updated_at
WHERE id = :id
AND deleted_at IS NULL'
);
$stmt->execute(array(
':name' => $row['name'],
':language' => $row['language'],
':content' => $row['content'],
':updated_at' => time(),
':id' => $promptId,
));
$this->createVersionForPrompt($promptId, $userId, 'restored version ' . $versionNo);
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
private function promptRows($stmt)
{
$out = array();
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$out[] = new PromptRecord($row);
}
return $out;
}
private function safeUserId($userId)
{
$userId = (int)$userId;
if ($userId <= 0) {
throw new PromptStoreException('Invalid user id.');
}
return $userId;
}
private function safeId($id)
{
$id = (int)$id;
if ($id <= 0) {
throw new PromptStoreException('Invalid id.');
}
return $id;
}
private function safeName($name)
{
$name = trim((string)$name);
if ($name === '') {
throw new PromptStoreException('Prompt name is required.');
}
if (strlen($name) > 160) {
throw new PromptStoreException('Prompt name is too long.');
}
return $name;
}
private function safeLanguage($language)
{
$language = strtolower(trim((string)$language));
if (!in_array($language, $this->supportedLanguages(), true)) {
throw new PromptStoreException('Unsupported language: ' . $language);
}
return $language;
}
private function safeContent($content)
{
$content = trim((string)$content);
if ($content === '') {
throw new PromptStoreException('Prompt content is required.');
}
if (strlen($content) > 200000) {
throw new PromptStoreException('Prompt content is too large.');
}
return $content;
}
private function safeDefaultKey($key)
{
$key = trim((string)$key);
if ($key === '') {
throw new PromptStoreException('Default key is required.');
}
if (!preg_match('/^[A-Za-z0-9_-]+$/', $key)) {
throw new PromptStoreException('Invalid default key.');
}
return $key;
}
public function deleteVersion($userId, $promptId, $versionNo)
{
$userId = $this->safeUserId($userId);
$promptId = $this->safeId($promptId);
$versionNo = (int)$versionNo;
if ($versionNo <= 0) {
throw new PromptStoreException('Invalid version number.');
}
if ($this->getPrompt($userId, $promptId) === null) {
throw new PromptStoreException('Prompt not found.');
}
$this->deleteVersionForPrompt($promptId, $versionNo);
}
public function deleteDefaultVersion($promptId, $versionNo)
{
$promptId = $this->safeId($promptId);
$versionNo = (int)$versionNo;
if ($versionNo <= 0) {
throw new PromptStoreException('Invalid version number.');
}
if ($this->getDefaultPrompt($promptId) === null) {
throw new PromptStoreException('Default prompt not found.');
}
$this->deleteVersionForPrompt($promptId, $versionNo);
}
private function deleteVersionForPrompt($promptId, $versionNo)
{
$promptId = $this->safeId($promptId);
$versionNo = (int)$versionNo;
$stmt = $this->db->prepare(
'SELECT COUNT(*) AS n
FROM prompt_versions
WHERE prompt_id = :prompt_id'
);
$stmt->execute(array(':prompt_id' => $promptId));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row || (int)$row['n'] <= 1) {
throw new PromptStoreException('Cannot delete the last remaining version.');
}
$stmt = $this->db->prepare(
'DELETE FROM prompt_versions
WHERE prompt_id = :prompt_id
AND version_no = :version_no'
);
$stmt->execute(array(
':prompt_id' => $promptId,
':version_no' => $versionNo,
));
if ($stmt->rowCount() !== 1) {
throw new PromptStoreException('Version not found.');
}
}
}