feat(appwrite): Schema für Galerie, Meldungen und Galerie-Bilder
Setup-Skript um Tabellen gallery und reports (Spalten boolean/integer/enum, Volltext- und Key-Indizes) sowie den Bucket gallery-images erweitert. DESIRED auf tables/buckets umgebaut, Vergleich je Spaltentyp und Index, Indizes erst nach available-Spalten. Console-Anleitung um Abschnitt Community (JWT an, Label admin, Key aircard-api) ergänzt. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
25ff496548
commit
9e4f4ffacc
3 files changed
+803
-127
No files matched your search
+210
-68
@@ -2,8 +2,9 @@
|
||||
/**
|
||||
* AirCard – Appwrite-Schema-Setup (idempotent)
|
||||
*
|
||||
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) Datenbank, Tabelle
|
||||
* `cards` (TablesDB) mit vier Spalten und den Bucket `card-images` an.
|
||||
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) die Datenbank `aircard`,
|
||||
* die Tabellen `cards`, `gallery` und `reports` (TablesDB, mit Spalten und
|
||||
* Indizes) und die Buckets `card-images` und `gallery-images` an.
|
||||
* Reines `fetch`, keine Abhängigkeiten, Node 22+.
|
||||
*
|
||||
* Aufruf (vom Repo-Wurzelverzeichnis):
|
||||
@@ -13,8 +14,9 @@
|
||||
* AIRCARD_APPWRITE_API_KEY Projekt-API-Key `aircard-provision` (Pflicht)
|
||||
* AIRCARD_APPWRITE_ENDPOINT optional, Standard https://appwrite.webklar.com/v1
|
||||
*
|
||||
* Ohne --check: fehlende Objekte werden angelegt. Bestehende Objekte werden
|
||||
* nur verglichen – Abweichungen werden gemeldet, nie geändert.
|
||||
* Ohne --check: fehlende Objekte werden angelegt (Indizes erst, wenn alle Spalten
|
||||
* der Tabelle `available` sind). Bestehende Objekte werden nur verglichen –
|
||||
* Abweichungen werden gemeldet, nie geändert.
|
||||
* Mit --check: es wird nichts angelegt; Exit 1, wenn etwas fehlt oder abweicht.
|
||||
*
|
||||
* Ausgabe: eine Zeile je Objekt –
|
||||
@@ -34,30 +36,81 @@ export const DEFAULT_ENDPOINT = 'https://appwrite.webklar.com/v1';
|
||||
// Soll-Zustand
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const str = (key, size, required) => ({ key, type: 'string', size, required });
|
||||
|
||||
export const DESIRED = {
|
||||
database: { id: 'aircard', name: 'AirCard' },
|
||||
table: {
|
||||
id: 'cards',
|
||||
name: 'Cards',
|
||||
permissions: ['create("users")'],
|
||||
rowSecurity: true,
|
||||
columns: [
|
||||
{ key: 'name', size: 80, required: true },
|
||||
{ key: 'config', size: 2000, required: true },
|
||||
{ key: 'imageFileId', size: 36, required: true },
|
||||
{ key: 'previewFileId', size: 36, required: true },
|
||||
],
|
||||
},
|
||||
bucket: {
|
||||
id: 'card-images',
|
||||
name: 'Card Images',
|
||||
permissions: ['create("users")'],
|
||||
fileSecurity: true,
|
||||
maximumFileSize: 5242880,
|
||||
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
compression: 'none',
|
||||
antivirus: false,
|
||||
},
|
||||
// Reihenfolge ist Ablauf: cards, gallery, reports (je Tabelle: Spalten, dann Indizes).
|
||||
tables: [
|
||||
{
|
||||
id: 'cards',
|
||||
name: 'Cards',
|
||||
permissions: ['create("users")'],
|
||||
rowSecurity: true,
|
||||
columns: [str('name', 80, true), str('config', 2000, true), str('imageFileId', 36, true), str('previewFileId', 36, true)],
|
||||
indexes: [],
|
||||
},
|
||||
{
|
||||
// Zeilenrechte setzt nur die API (aircard-api), Clients dürfen nichts auf Tabellenebene.
|
||||
id: 'gallery',
|
||||
name: 'Gallery',
|
||||
permissions: [],
|
||||
rowSecurity: true,
|
||||
columns: [
|
||||
str('title', 60, true),
|
||||
str('issuer', 40, false),
|
||||
str('tags', 120, false),
|
||||
str('displayName', 30, false),
|
||||
str('imageFileId', 36, true),
|
||||
str('previewFileId', 36, true),
|
||||
str('sourceCardId', 36, false),
|
||||
str('ownerId', 36, true),
|
||||
{ key: 'hidden', type: 'boolean', required: false, default: false },
|
||||
{ key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 },
|
||||
str('searchText', 300, false),
|
||||
],
|
||||
indexes: [
|
||||
{ key: 'search_text', type: 'fulltext', columns: ['searchText'] },
|
||||
{ key: 'owner', type: 'key', columns: ['ownerId'] },
|
||||
{ key: 'hidden', type: 'key', columns: ['hidden'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Nur Admins lesen (Label `admin`); geschrieben wird ausschließlich über die API.
|
||||
id: 'reports',
|
||||
name: 'Reports',
|
||||
permissions: ['read("label:admin")'],
|
||||
rowSecurity: false,
|
||||
columns: [
|
||||
str('galleryId', 36, true),
|
||||
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
|
||||
str('reporterHash', 64, true),
|
||||
],
|
||||
indexes: [{ key: 'gallery', type: 'key', columns: ['galleryId'] }],
|
||||
},
|
||||
],
|
||||
buckets: [
|
||||
{
|
||||
id: 'card-images',
|
||||
name: 'Card Images',
|
||||
permissions: ['create("users")'],
|
||||
fileSecurity: true,
|
||||
maximumFileSize: 5242880,
|
||||
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
compression: 'none',
|
||||
antivirus: false,
|
||||
},
|
||||
{
|
||||
id: 'gallery-images',
|
||||
name: 'Gallery Images',
|
||||
permissions: ['create("users")'],
|
||||
fileSecurity: true,
|
||||
maximumFileSize: 5242880,
|
||||
allowedFileExtensions: ['png', 'webp'],
|
||||
compression: 'none',
|
||||
antivirus: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -94,11 +147,35 @@ export function compareTable(actual, desired) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Vergleich einer Spalte: `size`, `required`, `status` (erwartet `available`). */
|
||||
/**
|
||||
* Vergleich einer Spalte, je nach Typ:
|
||||
* string → type, size, required
|
||||
* boolean → type, required, default
|
||||
* integer → type, required, default (min/max der Antwort sind riesige Standardwerte, werden nicht verglichen)
|
||||
* enum → elements (sortiert), required (Appwrite meldet Enum-Spalten als type `string` + format `enum`)
|
||||
* Außerdem immer `status` (erwartet `available`).
|
||||
*/
|
||||
export function compareColumn(actual, desired) {
|
||||
const out = [];
|
||||
if (actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size));
|
||||
if (desired.type !== 'enum' && actual.type !== desired.type) out.push(mismatch('type', actual.type, desired.type));
|
||||
if (desired.type === 'string' && actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size));
|
||||
if (desired.type === 'enum' && !sameList(actual.elements, desired.elements)) {
|
||||
out.push(mismatch('elements', sorted(actual.elements), sorted(desired.elements)));
|
||||
}
|
||||
if (actual.required !== desired.required) out.push(mismatch('required', actual.required, desired.required));
|
||||
if ((desired.type === 'boolean' || desired.type === 'integer') && actual.default !== desired.default) {
|
||||
out.push(mismatch('default', actual.default, desired.default));
|
||||
}
|
||||
if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available'));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Vergleich eines Index: `type`, `columns` (ältere Antworten: `attributes`; Reihenfolge zählt), `status`. */
|
||||
export function compareIndex(actual, desired) {
|
||||
const out = [];
|
||||
const columns = Array.isArray(actual.columns) ? actual.columns : (actual.attributes ?? []);
|
||||
if (actual.type !== desired.type) out.push(mismatch('type', actual.type, desired.type));
|
||||
if (JSON.stringify(columns) !== JSON.stringify(desired.columns)) out.push(mismatch('columns', columns, desired.columns));
|
||||
if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available'));
|
||||
return out;
|
||||
}
|
||||
@@ -197,7 +274,9 @@ async function createObject(ctx, path, body, what) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DB_ID = DESIRED.database.id;
|
||||
const columnPath = (key) => `/tablesdb/${DB_ID}/tables/${DESIRED.table.id}/columns/${key}`;
|
||||
const tablePath = (tableId) => `/tablesdb/${DB_ID}/tables/${tableId}`;
|
||||
const columnPath = (tableId, key) => `${tablePath(tableId)}/columns/${key}`;
|
||||
const indexPath = (tableId, key) => `${tablePath(tableId)}/indexes/${key}`;
|
||||
|
||||
/** Gibt zurück, ob die Datenbank (jetzt) existiert. */
|
||||
export async function ensureDatabase(ctx) {
|
||||
@@ -217,13 +296,12 @@ export async function ensureDatabase(ctx) {
|
||||
}
|
||||
|
||||
/** Gibt zurück, ob die Tabelle (jetzt) existiert. `parentExists`: Datenbank vorhanden. */
|
||||
export async function ensureTable(ctx, parentExists) {
|
||||
const t = DESIRED.table;
|
||||
const label = `Tabelle ${t.id}`;
|
||||
const actual = parentExists ? await getObject(ctx, `/tablesdb/${DB_ID}/tables/${t.id}`, label) : null;
|
||||
export async function ensureTable(ctx, table, parentExists) {
|
||||
const label = `Tabelle ${table.id}`;
|
||||
const actual = parentExists ? await getObject(ctx, tablePath(table.id), label) : null;
|
||||
if (actual) {
|
||||
const deviations = compareTable(actual, t);
|
||||
if (deviations.length) ctx.deviations(t.id, deviations);
|
||||
const deviations = compareTable(actual, table);
|
||||
if (deviations.length) ctx.deviations(table.id, deviations);
|
||||
else ctx.ok(label);
|
||||
return true;
|
||||
}
|
||||
@@ -234,31 +312,39 @@ export async function ensureTable(ctx, parentExists) {
|
||||
await createObject(
|
||||
ctx,
|
||||
`/tablesdb/${DB_ID}/tables`,
|
||||
{ tableId: t.id, name: t.name, permissions: t.permissions, rowSecurity: t.rowSecurity },
|
||||
{ tableId: table.id, name: table.name, permissions: table.permissions, rowSecurity: table.rowSecurity },
|
||||
label,
|
||||
);
|
||||
ctx.created(label);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Spalten und Indizes entstehen asynchron (`processing`) und werden über ihren eigenen Pfad abgefragt. */
|
||||
const CHILD_KINDS = {
|
||||
column: { noun: 'Spalte', plural: 'Spalte(n)', path: columnPath },
|
||||
index: { noun: 'Index', plural: 'Index(e)', path: indexPath },
|
||||
};
|
||||
|
||||
/**
|
||||
* Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten `available` sind.
|
||||
* Gibt eine Map Schlüssel → aktueller Spaltenzustand zurück.
|
||||
* Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten (`kind` = 'column')
|
||||
* bzw. Indizes (`kind` = 'index') der Tabelle `available` sind.
|
||||
* Gibt eine Map Schlüssel → aktueller Zustand zurück.
|
||||
*/
|
||||
export async function waitAvailable(ctx, tableId, keys) {
|
||||
export async function waitAvailable(ctx, tableId, keys, kind = 'column') {
|
||||
const { noun, plural, path } = CHILD_KINDS[kind];
|
||||
const deadline = Date.now() + ctx.timeoutMs;
|
||||
const done = new Map();
|
||||
let pending = [...keys];
|
||||
for (;;) {
|
||||
const still = [];
|
||||
for (const key of pending) {
|
||||
const label = `Spalte ${tableId}.${key}`;
|
||||
const actual = await getObject(ctx, columnPath(key), label);
|
||||
const label = `${noun} ${tableId}.${key}`;
|
||||
const actual = await getObject(ctx, path(tableId, key), label);
|
||||
if (!actual) throw new SetupError(`${label} ist beim Warten auf „available“ verschwunden.`);
|
||||
if (actual.status === 'available') {
|
||||
done.set(key, actual);
|
||||
} else if (actual.status === 'failed' || actual.status === 'stuck') {
|
||||
throw new SetupError(`${label}: Status „${actual.status}“. Spalte in der Console prüfen, ggf. löschen, und das Skript erneut starten.`);
|
||||
throw new SetupError(`${label}: Status „${actual.status}“. ${noun} in der Console prüfen, ggf. löschen, und das Skript erneut starten.`);
|
||||
} else {
|
||||
still.push(key);
|
||||
}
|
||||
@@ -266,7 +352,7 @@ export async function waitAvailable(ctx, tableId, keys) {
|
||||
if (!still.length) return done;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new SetupError(
|
||||
`Spalte(n) ${still.map((key) => `${tableId}.${key}`).join(', ')} nicht innerhalb von ${ctx.timeoutMs / 1000} s verfügbar. Das Skript später erneut starten.`,
|
||||
`${plural} ${still.map((key) => `${tableId}.${key}`).join(', ')} nicht innerhalb von ${ctx.timeoutMs / 1000} s verfügbar. Das Skript später erneut starten.`,
|
||||
);
|
||||
}
|
||||
pending = still;
|
||||
@@ -274,49 +360,102 @@ export async function waitAvailable(ctx, tableId, keys) {
|
||||
}
|
||||
}
|
||||
|
||||
/** REST-Pfad und Body zum Anlegen einer Spalte (je Typ eine eigene Route). */
|
||||
function columnRequest(table, col) {
|
||||
const path = `${tablePath(table.id)}/columns/${col.type}`;
|
||||
switch (col.type) {
|
||||
case 'string':
|
||||
return { path, body: { key: col.key, size: col.size, required: col.required } };
|
||||
case 'boolean':
|
||||
return { path, body: { key: col.key, required: col.required, default: col.default } };
|
||||
case 'integer':
|
||||
return { path, body: { key: col.key, required: col.required, default: col.default, min: col.min } };
|
||||
case 'enum':
|
||||
return { path, body: { key: col.key, elements: col.elements, required: col.required } };
|
||||
default:
|
||||
throw new SetupError(`Unbekannter Spaltentyp „${col.type}“ für ${table.id}.${col.key}.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spalten nacheinander anlegen, danach auf `available` warten – damit sind
|
||||
* sie bereit, bevor jemand schreibt. `parentExists`: Tabelle vorhanden.
|
||||
* Gemeinsamer Ablauf für Spalten und Indizes einer Tabelle: vorhandene lesen,
|
||||
* fehlende nacheinander anlegen, danach auf `available` warten (nur ohne
|
||||
* --check) und alles mit dem Soll vergleichen. Gibt die Einträge zurück
|
||||
* (`actual` ist dort gesetzt, wo das Objekt existiert).
|
||||
* `canCreate: false` → fehlende Objekte werden nur gemeldet, auch ohne --check.
|
||||
*/
|
||||
export async function ensureColumns(ctx, parentExists) {
|
||||
const t = DESIRED.table;
|
||||
async function ensureChildren(ctx, table, parentExists, { kind, items, request, compare, canCreate = true }) {
|
||||
const { noun, path } = CHILD_KINDS[kind];
|
||||
const entries = [];
|
||||
for (const col of t.columns) {
|
||||
const label = `Spalte ${t.id}.${col.key}`;
|
||||
const actual = parentExists ? await getObject(ctx, columnPath(col.key), label) : null;
|
||||
for (const item of items) {
|
||||
const label = `${noun} ${table.id}.${item.key}`;
|
||||
const actual = parentExists ? await getObject(ctx, path(table.id, item.key), label) : null;
|
||||
if (actual) {
|
||||
entries.push({ col, label, actual, created: false });
|
||||
} else if (ctx.check) {
|
||||
entries.push({ item, label, actual, created: false });
|
||||
} else if (ctx.check || !canCreate) {
|
||||
ctx.missing(label);
|
||||
} else {
|
||||
await createObject(
|
||||
ctx,
|
||||
`/tablesdb/${DB_ID}/tables/${t.id}/columns/string`,
|
||||
{ key: col.key, size: col.size, required: col.required },
|
||||
label,
|
||||
);
|
||||
const { path: createPath, body } = request(item);
|
||||
await createObject(ctx, createPath, body, label);
|
||||
ctx.created(label);
|
||||
entries.push({ col, label, actual: null, created: true });
|
||||
entries.push({ item, label, actual: null, created: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (!ctx.check) {
|
||||
const waiting = entries.filter((e) => e.created || e.actual.status === 'processing');
|
||||
if (waiting.length) {
|
||||
const ready = await waitAvailable(ctx, t.id, waiting.map((e) => e.col.key));
|
||||
for (const e of waiting) e.actual = ready.get(e.col.key);
|
||||
const ready = await waitAvailable(ctx, table.id, waiting.map((e) => e.item.key), kind);
|
||||
for (const e of waiting) e.actual = ready.get(e.item.key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
const deviations = compareColumn(e.actual, e.col);
|
||||
if (deviations.length) ctx.deviations(`${t.id}.${e.col.key}`, deviations);
|
||||
const deviations = compare(e.actual, e.item);
|
||||
if (deviations.length) ctx.deviations(`${table.id}.${e.item.key}`, deviations);
|
||||
else if (!e.created) ctx.ok(e.label);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function ensureBucket(ctx) {
|
||||
const b = DESIRED.bucket;
|
||||
/**
|
||||
* Spalten nacheinander anlegen, danach auf `available` warten – damit sind
|
||||
* sie bereit, bevor jemand schreibt (und bevor Indizes angelegt werden).
|
||||
* `parentExists`: Tabelle vorhanden. Gibt zurück, ob alle Spalten vorhanden
|
||||
* und `available` sind (Voraussetzung für die Indizes).
|
||||
*/
|
||||
export async function ensureColumns(ctx, table, parentExists) {
|
||||
const entries = await ensureChildren(ctx, table, parentExists, {
|
||||
kind: 'column',
|
||||
items: table.columns,
|
||||
request: (col) => columnRequest(table, col),
|
||||
compare: compareColumn,
|
||||
});
|
||||
return entries.length === table.columns.length && entries.every((e) => e.actual.status === 'available');
|
||||
}
|
||||
|
||||
/**
|
||||
* Indizes der Tabelle sicherstellen. Angelegt wird erst, wenn alle Spalten
|
||||
* `available` sind (`columnsReady`), sonst wird der Index nur als fehlend
|
||||
* gemeldet (die Spalte steht dann schon als ABWEICHUNG/FEHLT in der Ausgabe).
|
||||
*/
|
||||
export async function ensureIndexes(ctx, table, parentExists, columnsReady = true) {
|
||||
const indexes = table.indexes ?? [];
|
||||
if (!indexes.length) return;
|
||||
await ensureChildren(ctx, table, parentExists, {
|
||||
kind: 'index',
|
||||
items: indexes,
|
||||
request: (idx) => ({
|
||||
path: `${tablePath(table.id)}/indexes`,
|
||||
body: { key: idx.key, type: idx.type, columns: idx.columns },
|
||||
}),
|
||||
compare: compareIndex,
|
||||
canCreate: columnsReady,
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureBucket(ctx, bucket) {
|
||||
const b = bucket;
|
||||
const label = `Bucket ${b.id}`;
|
||||
const actual = await getObject(ctx, `/storage/buckets/${b.id}`, label);
|
||||
if (actual) {
|
||||
@@ -391,9 +530,12 @@ export async function main({
|
||||
log(`Appwrite ${endpoint}, Projekt ${project}, ${check ? 'nur prüfen (--check)' : 'prüfen und fehlende Objekte anlegen'}`);
|
||||
try {
|
||||
const databaseExists = await ensureDatabase(ctx);
|
||||
const tableExists = await ensureTable(ctx, databaseExists);
|
||||
await ensureColumns(ctx, tableExists);
|
||||
await ensureBucket(ctx);
|
||||
for (const table of DESIRED.tables) {
|
||||
const tableExists = await ensureTable(ctx, table, databaseExists);
|
||||
const columnsReady = await ensureColumns(ctx, table, tableExists);
|
||||
await ensureIndexes(ctx, table, tableExists, columnsReady);
|
||||
}
|
||||
for (const bucket of DESIRED.buckets) await ensureBucket(ctx, bucket);
|
||||
} catch (err) {
|
||||
if (!(err instanceof SetupError)) throw err;
|
||||
log(`ABBRUCH: ${err.message}`);
|
||||
|
||||
Reference in new issue
Block a user