Schema angelegt: Idempotenz-Fix, tags-Key-Index, Check-Skript

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 15:52:42 +00:00
parent a18945ce7f
commit 13bc7867cf
3 changed files with 57 additions and 1 deletions

View File

@@ -31,6 +31,8 @@ Der API-Key braucht die Scopes **Databases/Tables (read+write), Storage/Buckets
- **`created_at`-Spalten entfallen** Appwrite pflegt `$createdAt` automatisch - **`created_at`-Spalten entfallen** Appwrite pflegt `$createdAt` automatisch
(Indizes nutzen `$createdAt` direkt, z. B. `score_events`, `jobs`). (Indizes nutzen `$createdAt` direkt, z. B. `score_events`, `jobs`).
- **Enum-Werte ohne Umlaute** (`laeuft` statt `läuft`). - **Enum-Werte ohne Umlaute** (`laeuft` statt `läuft`).
- **`attributes.tags`: Key-Index statt Volltext-Index** Appwrite verbietet
Fulltext auf Array-Spalten; Tag-Suche läuft über `Query.contains`.
- **S3/Hetzner-Backend für Storage** ist eine *instanzweite* Appwrite-Einstellung - **S3/Hetzner-Backend für Storage** ist eine *instanzweite* Appwrite-Einstellung
(`_APP_STORAGE_DEVICE`) und betrifft alle Projekte auf dem Server wird daher (`_APP_STORAGE_DEVICE`) und betrifft alle Projekte auf dem Server wird daher
nicht vom Skript gesetzt, sondern muss bewusst am Server konfiguriert werden. nicht vom Skript gesetzt, sondern muss bewusst am Server konfiguriert werden.

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* BrandLoop Schema-Check: listet Tabellen (Spalten-/Index-Status), Buckets
* und Teams und meldet alles, was nicht "available" ist. Exit-Code 1 bei Problemen.
* Aufruf wie setup-appwrite.mjs (Key aus env oder /home/webklar/apps/.env).
*/
import { readFileSync } from 'node:fs';
const ENDPOINT = process.env.APPWRITE_ENDPOINT || 'https://appwrite.webklar.com/v1';
const PROJECT = process.env.APPWRITE_PROJECT || '6a5cee34002bb8360c34';
const ENV_FILE = process.env.ENV_FILE || '/home/webklar/apps/.env';
const DB_ID = 'brandloop';
const API_KEY = process.env.APPWRITE_API_KEY
|| readFileSync(ENV_FILE, 'utf8').match(/^BRANDLOOP_APPWRITE_API_KEY=(.+)$/m)?.[1]?.trim();
if (!API_KEY) { console.error('Kein API-Key gefunden.'); process.exit(1); }
async function api(path) {
const res = await fetch(`${ENDPOINT}${path}`, {
headers: { 'X-Appwrite-Project': PROJECT, 'X-Appwrite-Key': API_KEY },
});
if (!res.ok) throw new Error(`GET ${path}: HTTP ${res.status}`);
return res.json();
}
const limit = encodeURIComponent(JSON.stringify({ method: 'limit', values: [200] }));
let problems = 0;
const tables = await api(`/tablesdb/${DB_ID}/tables?queries[]=${limit}`);
for (const t of tables.tables) {
const cols = await api(`/tablesdb/${DB_ID}/tables/${t.$id}/columns?queries[]=${limit}`);
const idxs = await api(`/tablesdb/${DB_ID}/tables/${t.$id}/indexes?queries[]=${limit}`);
const badC = cols.columns.filter(c => c.status !== 'available');
const badI = idxs.indexes.filter(i => i.status !== 'available');
console.log(`${t.$id.padEnd(18)} ${String(cols.columns.length).padStart(2)} Spalten, ${idxs.indexes.length} Indizes` +
`${t.rowSecurity ? '' : ' [nur Team-Rechte]'}`);
for (const c of badC) { problems++; console.log(` ! Spalte ${c.key}: ${c.status} ${c.error || ''}`); }
for (const i of badI) { problems++; console.log(` ! Index ${i.key}: ${i.status} ${i.error || ''}`); }
}
console.log(`\nTabellen: ${tables.total}`);
const buckets = await api(`/storage/buckets?queries[]=${limit}`);
console.log(`Buckets: ${buckets.total} (${buckets.buckets.map(b => b.$id).join(', ')})`);
const teams = await api(`/teams?queries[]=${limit}`);
console.log(`Teams: ${teams.total} (${teams.teams.map(t => t.$id).join(', ')})`);
if (problems) { console.log(`\n${problems} Problem(e) gefunden.`); process.exit(1); }
console.log('\nAlles verfügbar.');

View File

@@ -162,7 +162,8 @@ const TABLES = [
{ key: 'idx_topn', type: 'key', columns: ['brand_id', 'category_id', 'status', 'score'], orders: ['ASC', 'ASC', 'ASC', 'DESC'] }, { key: 'idx_topn', type: 'key', columns: ['brand_id', 'category_id', 'status', 'score'], orders: ['ASC', 'ASC', 'ASC', 'DESC'] },
{ key: 'idx_parent', type: 'key', columns: ['parent_id'] }, { key: 'idx_parent', type: 'key', columns: ['parent_id'] },
{ key: 'idx_slug', type: 'key', columns: ['brand_id', 'slug'] }, { key: 'idx_slug', type: 'key', columns: ['brand_id', 'slug'] },
{ key: 'ft_tags', type: 'fulltext', columns: ['tags'], optional: true }, // Fulltext ist auf Array-Spalten verboten → Key-Index (reicht für Query.contains)
{ key: 'idx_tags', type: 'key', columns: ['tags'] },
], ],
}, },
{ {
@@ -340,6 +341,10 @@ async function main() {
rowSecurity: t.rowSecurity, rowSecurity: t.rowSecurity,
}); });
for (const c of t.columns) { for (const c of t.columns) {
// Erst GET: Appwrite prüft das Zeilengrößen-Limit VOR dem Duplikat-Check,
// ein erneutes POST auf große Spalten gäbe sonst 400 statt 409.
const { status } = await api('GET', `/tablesdb/${DB_ID}/tables/${t.id}/columns/${c.body.key}`);
if (status === 200) { skipped++; console.log(` = ${t.id}.${c.body.key} (existiert)`); continue; }
await ensure(`${t.id}.${c.body.key}`, 'POST', `/tablesdb/${DB_ID}/tables/${t.id}/columns/${c.type}`, c.body); await ensure(`${t.id}.${c.body.key}`, 'POST', `/tablesdb/${DB_ID}/tables/${t.id}/columns/${c.type}`, c.body);
} }
} }