diff --git a/DEDICATED_DB_GAPS.md b/DEDICATED_DB_GAPS.md new file mode 100644 index 0000000000..883be6c033 --- /dev/null +++ b/DEDICATED_DB_GAPS.md @@ -0,0 +1,131 @@ +# Dedicated Database — Console Feature Gaps + +Tracking file for all missing dedicated DB features in console vs cloud/edge/ddbs backends. +**Status: ALL ITEMS COMPLETE** + +## Legend +- [x] Done + +--- + +## 1. Enums & Types — ALL DONE + +- [x] `StorageClass` (ssd, nvme, hdd) +- [x] `BackupType` (full, incremental, wal) +- [x] `BackupStatusValue` (pending, running, completed, failed, verified) +- [x] `BackupStorageProvider` (s3, gcs, azure) +- [x] `RestorationType` (backup, pitr) +- [x] `RestorationStatusValue` (pending, running, completed, failed) +- [x] `HASyncMode` (async, sync, quorum) +- [x] `ReplicaRole` (primary, standby, readReplica) +- [x] `MaintenanceDay` (sun–sat) +- [x] `DataResidency` (eu, us, apac, global) +- [x] `KeyManagement` (appwriteKms, customerManaged) +- [x] `UpgradePolicy` (autoMinor, manual, scheduled) +- [x] `Capability` (24 capability flags) +- [x] `DatabaseEngine` includes mongodb +- [x] `DatabaseBackend` includes edge +- [x] `PoolerMode` (transaction, session) +- [x] `ConnectionRole` (readonly, readwrite) + +## 2. Properties on `DedicatedDatabase` Type — ALL DONE + +- [x] `projectId`, `externalIP`, `internalIP`, `lastActivityAt`, `idleUntil`, `networkPublicTcp` +- [x] `storageAutoscaling`, `storageAutoscalingMaxGb`, `storageAutoscalingThresholdPercent` +- [x] `securityEncryptionAtRest`, `securityKeyManagement`, `securityKeyRotationDays`, `securityCMKKeyId` +- [x] `securityAuditLogEnabled`, `securityLogRetentionDays`, `securityDataResidency` +- [x] `maintenanceWindowDay`, `maintenanceWindowHourUtc`, `maintenanceWindowDurationMinutes`, `maintenanceUpgradePolicy` +- [x] `metricsSlowQueryLogThresholdMs`, `metricsTraceSampleRate`, `lastMetricsPollAt` +- [x] `sqlApiEnabled`, `sqlApiAllowedStatements`, `sqlApiMaxBytes`, `sqlApiMaxRows`, `sqlApiTimeoutSeconds` + +## 3. SDK Methods — ALL 45 DONE + +- [x] Credential rotation, connections CRUD, active connections +- [x] Extensions CRUD (PostgreSQL) +- [x] Connection pooler (get, update, enable, disable) +- [x] HA status, manual failover +- [x] Cross-region (enable, disable, status, failover) +- [x] Read replicas (create, list, delete, status) +- [x] Backups (create, list, get, delete) +- [x] Restorations (create from backup, create PITR, list, get) +- [x] PITR windows +- [x] Metrics, slow queries, performance insights, audit logs +- [x] Storage resize, maintenance window, version upgrade +- [x] Backup storage (configure, get, delete) +- [x] Database status, migrate, set limits + +## 4. Response Types — ALL 25 DONE + +- [x] Backup, BackupList, Restoration, RestorationList +- [x] HAStatus, HAStatusReplica, ReadReplica, ReadReplicaList +- [x] CrossRegionStatus, PoolerConfig, BackupStorageConfig +- [x] ActiveConnection, ActiveConnectionList, DatabaseMetrics +- [x] PerformanceInsights (+Query, +WaitEvent), PITRWindows +- [x] AuditLog, AuditLogList, SlowQuery, SlowQueryList +- [x] DatabaseExtensions, DatabaseConnection, DatabaseConnectionList +- [x] DatabaseStatusDetail (+Connections, +Replica, +Volume) + +## 5. UI — Overview Page — ALL DONE + +- [x] Storage Autoscaling, Security, Maintenance Window, SQL API, Monitoring sections +- [x] `storageClass` in Resources, `externalIP`/`internalIP` in Connection + +## 6. UI — Settings Page — ALL 19 COMPONENTS DONE + +- [x] updateName, updateTier, updateStorage, updateNetwork +- [x] updateMaintenance, updateBackups, updateAutoscaling, updatePooler +- [x] rotateCredentials, upgradeVersion, dangerZone +- [x] updateExtensions (PostgreSQL), updateConnections (database users) +- [x] updateReadReplicas, updateCrossRegion, updateHAStatus +- [x] updateBackupStorage, updateSecurity, updateSqlApi + +## 7. UI — Backup Management — ALL DONE + +- [x] dedicatedBackups.svelte (list, create, delete, restore, PITR) +- [x] Integrated into backups/+page.svelte with type-conditional rendering + +## 8. UI — Monitoring Page — ALL DONE + +- [x] monitoring/+page.svelte + +page.ts +- [x] Metrics dashboard, active connections, slow queries, performance insights, audit logs + +## 9. Navigation & Routing — ALL DONE + +- [x] Monitoring tab in database header +- [x] Monitoring route loader + +--- + +## Files Changed Summary + +### New Files (28) +- `settings/updateName.svelte` +- `settings/updateTier.svelte` +- `settings/updateStorage.svelte` +- `settings/updateNetwork.svelte` +- `settings/updateMaintenance.svelte` +- `settings/updateBackups.svelte` +- `settings/updateAutoscaling.svelte` +- `settings/updatePooler.svelte` +- `settings/rotateCredentials.svelte` +- `settings/upgradeVersion.svelte` +- `settings/dangerZone.svelte` +- `settings/updateExtensions.svelte` +- `settings/updateConnections.svelte` +- `settings/updateReadReplicas.svelte` +- `settings/updateCrossRegion.svelte` +- `settings/updateHAStatus.svelte` +- `settings/updateBackupStorage.svelte` +- `settings/updateSecurity.svelte` +- `settings/updateSqlApi.svelte` +- `backups/dedicatedBackups.svelte` +- `monitoring/+page.svelte` +- `monitoring/+page.ts` + +### Modified Files (5) +- `src/lib/sdk/dedicatedDatabases.ts` — Complete rewrite with all types, enums, and 45 SDK methods +- `dedicatedOverview.svelte` — Added 5 new CardGrid sections + IP/storageClass fields +- `settings/+page.svelte` — Rewritten with dedicated type branch + 19 sub-component imports +- `header.svelte` — Added Monitoring tab +- `backups/+page.svelte` — Added dedicated backups conditional +- `src/lib/actions/analytics.ts` — Added Submit enum entries for new operations diff --git a/bun.lock b/bun.lock index 12377f9149..a51b25d5cf 100644 --- a/bun.lock +++ b/bun.lock @@ -6,12 +6,22 @@ "name": "@appwrite/console", "dependencies": { "@ai-sdk/svelte": "^1.1.24", - "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@8e7decc", + "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@3ec199d", "@appwrite.io/pink-icons": "0.25.0", - "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@df765cc", + "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@b92a389", "@appwrite.io/pink-legacy": "^1.0.3", - "@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@df765cc", + "@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@b92a389", + "@codemirror/autocomplete": "^6.19.0", + "@codemirror/commands": "^6.9.0", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/language": "^6.11.3", + "@codemirror/lint": "^6.9.0", + "@codemirror/search": "^6.5.11", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.38.6", "@faker-js/faker": "^9.9.0", + "@lezer/highlight": "^1.2.1", "@plausible-analytics/tracker": "^0.4.4", "@popperjs/core": "^2.11.8", "@sentry/sveltekit": "^8.38.0", @@ -20,11 +30,13 @@ "@threlte/extras": "^9.7.1", "ai": "^6.0.67", "analytics": "^0.8.16", + "codemirror-json5": "^1.0.3", "cron-parser": "^4.9.0", "dayjs": "^1.11.13", "deep-equal": "^2.2.3", "echarts": "^5.6.0", "ignore": "^6.0.2", + "json5": "^2.2.3", "nanoid": "^5.1.5", "nanotar": "^0.1.1", "pretty-bytes": "^6.1.1", @@ -36,6 +48,7 @@ "devDependencies": { "@eslint/compat": "^1.3.1", "@eslint/js": "^9.31.0", + "@lezer/common": "^1.5.0", "@melt-ui/pp": "^0.3.2", "@melt-ui/svelte": "^0.86.5", "@playwright/test": "^1.55.1", @@ -108,15 +121,15 @@ "@analytics/type-utils": ["@analytics/type-utils@0.6.4", "", {}, "sha512-Ou1gQxFakOWLcPnbFVsrPb8g1wLLUZYYJXDPjHkG07+5mustGs5yqACx42UAu4A6NszNN6Z5gGxhyH45zPWRxw=="], - "@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@8e7decc", { "dependencies": { "json-bigint": "1.0.0" } }], + "@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@3ec199d", {}], "@appwrite.io/pink-icons": ["@appwrite.io/pink-icons@0.25.0", "", {}, "sha512-0O3i2oEuh5mWvjO80i+X6rbzrWLJ1m5wmv2/M3a1p2PyBJsFxN8xQMTEmTn3Wl/D26SsM7SpzbdW6gmfgoVU9Q=="], - "@appwrite.io/pink-icons-svelte": ["@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@df765cc", { "peerDependencies": { "svelte": "^4.0.0" } }], + "@appwrite.io/pink-icons-svelte": ["@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@b92a389", { "peerDependencies": { "svelte": "^4.0.0" } }], "@appwrite.io/pink-legacy": ["@appwrite.io/pink-legacy@1.0.3", "", { "dependencies": { "@appwrite.io/pink-icons": "1.0.0", "the-new-css-reset": "^1.11.2" } }, "sha512-GGde5fmPhs+s6/3aFeMPc/kKADG/gTFkYQSy6oBN8pK0y0XNCLrZZgBv+EBbdhwdtqVEWXa0X85Mv9w7jcIlwQ=="], - "@appwrite.io/pink-svelte": ["@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@df765cc", { "dependencies": { "@appwrite.io/pink-icons-svelte": "2.0.0-RC.1", "@floating-ui/dom": "^1.6.13", "@melt-ui/pp": "^0.3.2", "@melt-ui/svelte": "^0.86.6", "@tanstack/svelte-virtual": "^3.13.10", "ansicolor": "^2.0.3", "d3": "^7.9.0", "fuse.js": "^7.1.0", "pretty-bytes": "^6.1.1", "shiki": "^1.18.0", "svelte-motion": "^0.12.2", "svelte-sonner": "^0.3.28" }, "peerDependencies": { "svelte": "^4.0.0" } }], + "@appwrite.io/pink-svelte": ["@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@b92a389", { "dependencies": { "@appwrite.io/pink-icons-svelte": "2.0.0-RC.1", "@floating-ui/dom": "^1.6.13", "@melt-ui/pp": "^0.3.2", "@melt-ui/svelte": "^0.86.6", "@tanstack/svelte-virtual": "^3.13.10", "ansicolor": "^2.0.3", "d3": "^7.9.0", "fuse.js": "^7.1.0", "pretty-bytes": "^6.1.1", "shiki": "^1.18.0", "svelte-motion": "^0.12.2", "svelte-sonner": "^0.3.28" }, "peerDependencies": { "svelte": "^4.0.0" } }], "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], @@ -154,6 +167,24 @@ "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="], + + "@codemirror/commands": ["@codemirror/commands@6.10.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ=="], + + "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="], + + "@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="], + + "@codemirror/language": ["@codemirror/language@6.12.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg=="], + + "@codemirror/lint": ["@codemirror/lint@6.9.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA=="], + + "@codemirror/search": ["@codemirror/search@6.6.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw=="], + + "@codemirror/state": ["@codemirror/state@6.5.4", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw=="], + + "@codemirror/view": ["@codemirror/view@6.39.17", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-Aim4lFqhbijnchl83RLfABWueSGs1oUCSv0mru91QdhpXQeNKprIdRO9LWA4cYkJvuYTKGJN7++9MXx8XW43ag=="], + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], @@ -220,6 +251,18 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="], + + "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], + + "@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="], + + "@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="], + + "@lezer/lr": ["@lezer/lr@1.4.8", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA=="], + + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + "@melt-ui/pp": ["@melt-ui/pp@0.3.2", "", { "dependencies": { "estree-walker": "^3.0.3", "magic-string": "^0.30.5" }, "peerDependencies": { "@melt-ui/svelte": ">= 0.29.0", "svelte": "^3.55.0 || ^4.0.0 || ^5.0.0-next.1" } }, "sha512-xKkPvaIAFinklLXcQOpwZ8YSpqAFxykjWf8Y/fSJQwsixV/0rcFs07hJ49hJjPy5vItvw5Qa0uOjzFUbXzBypQ=="], "@melt-ui/svelte": ["@melt-ui/svelte@0.86.6", "", { "dependencies": { "@floating-ui/core": "^1.3.1", "@floating-ui/dom": "^1.4.5", "@internationalized/date": "^3.5.0", "dequal": "^2.0.3", "focus-trap": "^7.5.2", "nanoid": "^5.0.4" }, "peerDependencies": { "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.118" } }, "sha512-Jer+M7DgIwT5IHfTayb4Iw/fkkxWNmC/mqn/nMh9JrbPbkxmyabfLQnhJ+JDn5HK77f84j34lubO3iqFtYAfMg=="], @@ -602,8 +645,6 @@ "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], - "bignumber.js": ["bignumber.js@9.0.0", "", {}, "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A=="], - "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="], @@ -646,6 +687,8 @@ "code-red": ["code-red@1.0.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1", "acorn": "^8.10.0", "estree-walker": "^3.0.3", "periscopic": "^3.1.0" } }, "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw=="], + "codemirror-json5": ["codemirror-json5@1.0.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "json5": "^2.2.1", "lezer-json5": "^2.0.2" } }, "sha512-HmmoYO2huQxoaoG5ARKjqQc9mz7/qmNPvMbISVfIE2Gk1+4vZQg9X3G6g49MYM5IK00Ol3aijd7OKrySuOkA7Q=="], + "color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="], "color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="], @@ -662,6 +705,8 @@ "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + "cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -996,8 +1041,6 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], @@ -1016,6 +1059,8 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lezer-json5": ["lezer-json5@2.0.2", "", { "dependencies": { "@lezer/lr": "^1.0.0" } }, "sha512-NRmtBlKW/f8mA7xatKq8IUOq045t8GVHI4kZXrUtYYUdiVeGiO6zKGAV7/nUAnf5q+rYTY+SWX/gvQdFXMjNxQ=="], + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], @@ -1310,6 +1355,8 @@ "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], + "style-value-types": ["style-value-types@5.1.2", "", { "dependencies": { "hey-listen": "^1.0.8", "tslib": "2.4.0" } }, "sha512-Vs9fNreYF9j6W2VvuDTP7kepALi7sk0xtk2Tu8Yxi9UoajJdEVpNpCov0HsLTqXvNGKX+Uv09pkozVITi1jf3Q=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -1430,6 +1477,8 @@ "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="], diff --git a/package.json b/package.json index 0ff0eb93c9..7ea865161e 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,22 @@ }, "dependencies": { "@ai-sdk/svelte": "^1.1.24", - "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@8e7decc", + "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@3ec199d", "@appwrite.io/pink-icons": "0.25.0", - "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@df765cc", + "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@b92a389", "@appwrite.io/pink-legacy": "^1.0.3", - "@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@df765cc", + "@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@b92a389", + "@codemirror/autocomplete": "^6.19.0", + "@codemirror/commands": "^6.9.0", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/language": "^6.11.3", + "@codemirror/lint": "^6.9.0", + "@codemirror/search": "^6.5.11", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.38.6", "@faker-js/faker": "^9.9.0", + "@lezer/highlight": "^1.2.1", "@plausible-analytics/tracker": "^0.4.4", "@popperjs/core": "^2.11.8", "@sentry/sveltekit": "^8.38.0", @@ -34,11 +44,13 @@ "@threlte/extras": "^9.7.1", "ai": "^6.0.67", "analytics": "^0.8.16", + "codemirror-json5": "^1.0.3", "cron-parser": "^4.9.0", "dayjs": "^1.11.13", "deep-equal": "^2.2.3", "echarts": "^5.6.0", "ignore": "^6.0.2", + "json5": "^2.2.3", "nanoid": "^5.1.5", "nanotar": "^0.1.1", "pretty-bytes": "^6.1.1", @@ -50,6 +62,7 @@ "devDependencies": { "@eslint/compat": "^1.3.1", "@eslint/js": "^9.31.0", + "@lezer/common": "^1.5.0", "@melt-ui/pp": "^0.3.2", "@melt-ui/svelte": "^0.86.5", "@playwright/test": "^1.55.1", diff --git a/src/lib/actions/analytics.ts b/src/lib/actions/analytics.ts index 023e2fbeee..cf124d072f 100644 --- a/src/lib/actions/analytics.ts +++ b/src/lib/actions/analytics.ts @@ -75,13 +75,12 @@ export function trackEvent(name: string, data: object = null): void { } } -export function trackError(exception: Error, event: Submit): void { - if (exception instanceof AppwriteException && exception.type && event) { - trackEvent(Submit.Error, { - type: exception.type, - form: event - }); - } +export function trackError(exception: Error, event?: Submit): void { + if (!(exception instanceof AppwriteException) || !exception.type) return; + + const data: Record = { type: exception.type }; + if (event) data.form = event; + trackEvent(Submit.Error, data); } export function trackPageView(path: string) { @@ -155,6 +154,7 @@ export enum Click { DatabaseRowDelete = 'click_row_delete', DatabaseDatabaseDelete = 'click_database_delete', DatabaseImportCsv = 'click_database_import_csv', + DatabaseImportJson = 'click_database_import_json', DatabaseExportCsv = 'click_database_export_csv', DomainCreateClick = 'click_domain_create', DomainDeleteClick = 'click_domain_delete', @@ -283,8 +283,37 @@ export enum Submit { DatabaseUpdateName = 'submit_database_update_name', DatabaseImportCsv = 'submit_database_import_csv', DatabaseExportCsv = 'submit_database_export_csv', + DatabaseImportJSON = 'submit_database_import_json', DatabaseBackupDelete = 'submit_database_backup_delete', DatabaseBackupPolicyCreate = 'submit_database_backup_policy_create', + DatabaseUpdateTier = 'submit_database_update_tier', + DatabaseResizeStorage = 'submit_database_resize_storage', + DatabaseUpdateNetwork = 'submit_database_update_network', + DatabaseUpdateMaintenance = 'submit_database_update_maintenance', + DatabaseUpdateBackups = 'submit_database_update_backups', + DatabaseUpdateAutoscaling = 'submit_database_update_autoscaling', + DatabaseUpdatePooler = 'submit_database_update_pooler', + DatabaseRotateCredentials = 'submit_database_rotate_credentials', + DatabaseUpgradeVersion = 'submit_database_upgrade_version', + DedicatedBackupCreate = 'submit_dedicated_backup_create', + DedicatedBackupDelete = 'submit_dedicated_backup_delete', + DedicatedBackupRestore = 'submit_dedicated_backup_restore', + DedicatedPitrRestore = 'submit_dedicated_pitr_restore', + DatabaseInstallExtension = 'submit_database_install_extension', + DatabaseUninstallExtension = 'submit_database_uninstall_extension', + DatabaseCreateConnection = 'submit_database_create_connection', + DatabaseDeleteConnection = 'submit_database_delete_connection', + DatabaseCreateReadReplica = 'submit_database_create_read_replica', + DatabaseDeleteReadReplica = 'submit_database_delete_read_replica', + DatabaseEnableCrossRegion = 'submit_database_enable_cross_region', + DatabaseDisableCrossRegion = 'submit_database_disable_cross_region', + DatabaseTriggerCrossRegionFailover = 'submit_database_trigger_cross_region_failover', + DatabaseUpdateHA = 'submit_database_update_ha', + DatabaseManualFailover = 'submit_database_manual_failover', + DatabaseConfigureBackupStorage = 'submit_database_configure_backup_storage', + DatabaseDeleteBackupStorage = 'submit_database_delete_backup_storage', + DatabaseUpdateSecurity = 'submit_database_update_security', + DatabaseUpdateSqlApi = 'submit_database_update_sql_api', ColumnCreate = 'submit_column_create', ColumnUpdate = 'submit_column_update', @@ -296,6 +325,11 @@ export enum Submit { RowUpdate = 'submit_row_update', RowUpdatePermissions = 'submit_row_update_permissions', + DocumentCreate = 'submit_document_create', + DocumentDelete = 'submit_document_delete', + DocumentUpdate = 'submit_document_update', + DocumentUpdatePermissions = 'submit_document_update_permissions', + IndexCreate = 'submit_index_create', IndexDelete = 'submit_index_delete', @@ -307,6 +341,14 @@ export enum Submit { TableUpdateEnabled = 'submit_table_update_enabled', TableUpdateDisplayNames = 'submit_table_update_display_names', + CollectionCreate = 'submit_collection_create', + CollectionDelete = 'submit_collection_delete', + CollectionUpdateName = 'submit_collection_update_name', + CollectionUpdatePermissions = 'submit_collection_update_permissions', + CollectionUpdateSecurity = 'submit_collection_update_security', + CollectionUpdateEnabled = 'submit_collection_update_enabled', + CollectionUpdateDisplayNames = 'submit_collection_update_display_names', + FunctionCreate = 'submit_function_create', FunctionDelete = 'submit_function_delete', FunctionUpdateName = 'submit_function_update_name', diff --git a/src/lib/commandCenter/commands.ts b/src/lib/commandCenter/commands.ts index e4f3837184..7c8cfd15e9 100644 --- a/src/lib/commandCenter/commands.ts +++ b/src/lib/commandCenter/commands.ts @@ -29,9 +29,11 @@ const groups = [ 'migrations', 'users', 'tables', + 'collections', 'columns', 'indexes', 'rows', + 'documents', 'teams', 'security', 'buckets', @@ -102,8 +104,19 @@ const commandsEnabled = derived(disabledMap, ($disabledMap) => { export function isTargetInputLike(element: EventTarget | null) { if (!(element instanceof HTMLElement)) return false; + return !!element.closest( - 'input,textarea,select,[contenteditable],[role="combobox"],[role="textbox"],[role="searchbox"],[data-command-center-ignore]' + [ + 'input', + 'textarea', + 'select', + '[contenteditable]', + '[role="combobox"]', + '[role="textbox"]', + '[role="searchbox"]', + '[data-command-center-ignore]', + '.cm-editor' + ].join(',') ); } diff --git a/src/lib/components/columnSelector.svelte b/src/lib/components/columnSelector.svelte index 506735df38..9bb6c23c07 100644 --- a/src/lib/components/columnSelector.svelte +++ b/src/lib/components/columnSelector.svelte @@ -10,8 +10,10 @@ Layout, Popover, Selector, - Typography + Typography, + Icon } from '@appwrite.io/pink-svelte'; + import { IconPlus } from '@appwrite.io/pink-icons-svelte'; import { Button } from '$lib/elements/forms'; let { @@ -21,7 +23,8 @@ allowNoColumns = false, showAnyway = false, children, - onPreferencesUpdated = null + onPreferencesUpdated = null, + onCustomOptionClick = null }: { columns: Writable; isCustomTable?: boolean; @@ -30,6 +33,7 @@ showAnyway?: boolean; children: Snippet<[toggle: () => void, selectedColumnsNumber: number]>; onPreferencesUpdated?: () => void; + onCustomOptionClick?: () => void; } = $props(); let search = $state(''); @@ -115,7 +119,7 @@ cols.map((col) => col.exclude ? col - : filteredColumns.some((fc) => fc.id === col.id) + : filteredColumns.some((fc) => fc.id === col.id && !col.disable) ? { ...col, hide: false } : col ) @@ -126,7 +130,9 @@ function deselectAll() { columns.update((cols) => { const realColumns = cols.filter((col) => !col.exclude && !col.isAction); - const filtered = filteredColumns.filter((col) => !col.exclude && !col.isAction); + const filtered = filteredColumns.filter( + (col) => !col.exclude && !col.isAction && !col.disable + ); if (filtered.length === 0) return cols; @@ -187,7 +193,7 @@ {@const placement = isNewStyle ? 'bottom-start' : 'bottom-end'} {@render children(toggle, selectedColumnsNumber)} - +
{#if isNewStyle && showActions} @@ -231,7 +237,8 @@ on:click={() => toggleColumn(column)} disabled={allowNoColumns ? false - : visibleRealColumns.length <= 1 && !column.hide}> + : (visibleRealColumns.length <= 1 && !column.hide) || + column.disable}> + + {#if onCustomOptionClick && isCustomTable} + + + + + {/if}
diff --git a/src/lib/components/csvImportBox.svelte b/src/lib/components/csvImportBox.svelte index e8a6e167ed..6069ac4c41 100644 --- a/src/lib/components/csvImportBox.svelte +++ b/src/lib/components/csvImportBox.svelte @@ -14,7 +14,7 @@ // re-render the key for sheet UI. import { hash } from '$lib/helpers/string'; - import { spreadsheetRenderKey } from '$routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/store'; + import { spreadsheetRenderKey } from '$database/store'; import { Link } from '$lib/elements'; type CsvImportError = { diff --git a/src/lib/components/domains/viewLogsModal.svelte b/src/lib/components/domains/viewLogsModal.svelte index 3f558c71a0..9d53b088a5 100644 --- a/src/lib/components/domains/viewLogsModal.svelte +++ b/src/lib/components/domains/viewLogsModal.svelte @@ -36,8 +36,8 @@ domainId: domain.$id }); } - } catch { - // Ignore error + } catch (e) { + console.warn('[viewLogsModal] Failed to update nameservers:', e?.message ?? e); } try { diff --git a/src/lib/components/id.svelte b/src/lib/components/id.svelte index 201f651c1b..babd1b602b 100644 --- a/src/lib/components/id.svelte +++ b/src/lib/components/id.svelte @@ -1,7 +1,8 @@ - {#key value} @@ -107,7 +125,7 @@ style:overflow="hidden" style:word-break="break-all" use:truncateText> - + {@render children()} diff --git a/src/lib/components/modal.svelte b/src/lib/components/modal.svelte index 8181338f81..0626393404 100644 --- a/src/lib/components/modal.svelte +++ b/src/lib/components/modal.svelte @@ -30,27 +30,29 @@
- - - {#if error} -
- { - error = null; - }}> - {error} - -
- {/if} - - - - - - -
+
+ + + {#if error} +
+ { + error = null; + }}> + {error} + +
+ {/if} + + + + + + +
+
diff --git a/src/lib/components/sortButton.svelte b/src/lib/components/sortButton.svelte index d202cf664b..bb24a2bb24 100644 --- a/src/lib/components/sortButton.svelte +++ b/src/lib/components/sortButton.svelte @@ -5,10 +5,10 @@ diff --git a/src/lib/sdk/dedicatedDatabases.ts b/src/lib/sdk/dedicatedDatabases.ts new file mode 100644 index 0000000000..f06c437cfd --- /dev/null +++ b/src/lib/sdk/dedicatedDatabases.ts @@ -0,0 +1,1009 @@ +import type { Client } from '@appwrite.io/console'; + +// ── Enums ────────────────────────────────────────────────────────────────── + +export type DatabaseEngine = 'postgres' | 'mysql' | 'mariadb' | 'mongodb'; +export type DatabaseTypeValue = 'shared' | 'dedicated'; +export type DatabaseBackend = 'prisma' | 'appwrite' | 'edge'; +export type DatabaseStatusValue = + | 'provisioning' + | 'ready' + | 'active' + | 'inactive' + | 'paused' + | 'failed' + | 'deleted' + | 'restoring' + | 'scaling'; +export type ContainerStatusValue = + | 'inactive' + | 'starting' + | 'running' + | 'active' + | 'spinning_down' + | 'freezing' + | null; +export type StorageClass = 'ssd' | 'nvme' | 'hdd'; +export type BackupType = 'full' | 'incremental' | 'wal'; +export type BackupStatusValue = 'pending' | 'running' | 'completed' | 'failed' | 'verified'; +export type BackupStorageProvider = 's3' | 'gcs' | 'azure'; +export type RestorationType = 'backup' | 'pitr'; +export type RestorationStatusValue = 'pending' | 'running' | 'completed' | 'failed'; +export type HASyncMode = 'async' | 'sync' | 'quorum'; +export type ReplicaRole = 'primary' | 'standby' | 'readReplica'; +export type MaintenanceDay = 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'; +export type DataResidency = 'eu' | 'us' | 'apac' | 'global'; +export type KeyManagement = 'appwriteKms' | 'customerManaged'; +export type UpgradePolicy = 'autoMinor' | 'manual' | 'scheduled'; +export type PoolerMode = 'transaction' | 'session'; +export type ConnectionRole = 'readonly' | 'readwrite'; + +export type Capability = + | 'pitr' + | 'ha' + | 'coldStart' + | 'pause' + | 'scaling' + | 'storageScaling' + | 'backupCreate' + | 'backupRestore' + | 'backupVerification' + | 'connections' + | 'usageMetrics' + | 'versionUpgrade' + | 'maintenanceWindow' + | 'extensions' + | 'connectionPooler' + | 'ipAllowlist' + | 'slowQueryLog' + | 'auditLog' + | 'credentialRotation' + | 'failover' + | 'crossRegionFailover' + | 'multiRegionReplica' + | 'backupOffCluster' + | 'performanceInsights'; + +// ── Response Types ───────────────────────────────────────────────────────── + +export type DedicatedDatabase = { + $id: string; + $createdAt: string; + $updatedAt: string; + projectId: string; + name: string; + engine: DatabaseEngine; + version: string; + type: DatabaseTypeValue; + region: string; + tier: string; + backend: DatabaseBackend; + cpu: number; + memory: number; + storage: number; + storageClass: StorageClass; + maxStorageGb: number; + hostname: string; + connectionPort: number; + connectionUser: string; + connectionPassword: string; + connectionString: string; + status: DatabaseStatusValue; + externalIP: string; + internalIP: string; + containerStatus: ContainerStatusValue; + lastActivityAt: string; + idleUntil: string; + idleTimeoutMinutes: number | null; + highAvailability: boolean; + haReplicaCount: number; + haSyncMode: HASyncMode | null; + networkMaxConnections: number; + networkIdleTimeoutSeconds: number; + networkIPAllowlist: string[]; + networkPublicTcp: boolean; + backupEnabled: boolean; + backupPitr: boolean; + backupCron: string; + backupRetentionDays: number; + pitrRetentionDays: number; + storageAutoscaling: boolean; + storageAutoscalingThresholdPercent: number; + storageAutoscalingMaxGb: number; + maintenanceWindowDay: MaintenanceDay; + maintenanceWindowHourUtc: number; + maintenanceWindowDurationMinutes: number; + maintenanceUpgradePolicy: UpgradePolicy; + metricsEnabled: boolean; + metricsSlowQueryLogThresholdMs: number; + metricsTraceSampleRate: number; + securityEncryptionAtRest: boolean; + securityKeyManagement: KeyManagement; + securityKeyRotationDays: number; + securityCMKKeyId: string; + securityAuditLogEnabled: boolean; + securityLogRetentionDays: number; + securityDataResidency: DataResidency; + sqlApiEnabled: boolean; + sqlApiAllowedStatements: string[]; + sqlApiMaxBytes: number; + sqlApiMaxRows: number; + sqlApiTimeoutSeconds: number; + lastMetricsPollAt: number; + error?: string; +}; + +export type DedicatedDatabaseList = { + total: number; + databases: DedicatedDatabase[]; +}; + +export type DedicatedDatabaseCredentials = { + $id: string; + host: string; + port: number; + username: string; + password: string; + database: string; + engine: DatabaseEngine; + ssl: boolean; + connectionString: string; +}; + +export type DatabaseConnection = { + $id: string; + username: string; + database: string; + role: ConnectionRole; + $createdAt: string; +}; + +export type DatabaseConnectionList = { + total: number; + connections: DatabaseConnection[]; +}; + +export type Backup = { + $id: string; + $createdAt: string; + databaseId: string; + projectId: string; + type: BackupType; + status: BackupStatusValue; + sizeBytes: number; + startedAt: number; + completedAt: number; + verifiedAt: number; + expiresAt: number; + error?: string; +}; + +export type BackupList = { + total: number; + backups: Backup[]; +}; + +export type Restoration = { + $id: string; + $createdAt: string; + databaseId: string; + projectId: string; + backupId: string | null; + type: RestorationType; + status: RestorationStatusValue; + targetTime: number | null; + startedAt: number; + completedAt: number; + error?: string; +}; + +export type RestorationList = { + total: number; + restorations: Restoration[]; +}; + +export type HAStatusReplica = { + $id: string; + role: 'primary' | 'replica'; + status: 'healthy' | 'degraded' | 'unhealthy'; + lagSeconds: number; +}; + +export type HAStatus = { + enabled: boolean; + replicaCount: number; + syncMode: HASyncMode; + replicas: HAStatusReplica[]; +}; + +export type ReadReplica = { + $id: string; + databaseId: string; + targetRegion: string; + sourceRegion: string; + status: 'provisioning' | 'active' | 'degraded' | 'failed' | 'deleting'; + lagSeconds: number; + hostname: string; + externalIP: string; + crossZoneConsent: boolean; + $createdAt: string; +}; + +export type ReadReplicaList = { + total: number; + replicas: ReadReplica[]; +}; + +export type CrossRegionStatus = { + enabled: boolean; + primaryRegion: string; + standbyRegion: string; + standbyStatus: 'healthy' | 'degraded' | 'unhealthy' | 'provisioning'; + lagSeconds: number; + lastSyncedAt: string; +}; + +export type PoolerConfig = { + enabled: boolean; + mode: PoolerMode; + maxConnections: number; + defaultPoolSize: number; + port: number; +}; + +export type BackupStorageConfig = { + provider: BackupStorageProvider; + bucket: string; + region: string; + prefix: string; + endpoint: string; +}; + +export type ActiveConnection = { + pid: number; + user: string; + database: string; + state: 'active' | 'idle' | 'idle in transaction'; + query: string; + connectedAt: string; + waitEvent: string; +}; + +export type ActiveConnectionList = { + total: number; + activeConnections: ActiveConnection[]; +}; + +export type DatabaseMetrics = { + period: string; + cpuPercent: number; + memoryPercent: number; + memoryUsedBytes: number; + memoryMaxBytes: number; + storageUsedBytes: number; + connectionsActive: number; + connectionsMax: number; + iopsRead: number; + iopsWrite: number; + qps: number; +}; + +export type PerformanceInsightsQuery = { + query: string; + calls: number; + totalTimeMs: number; + meanTimeMs: number; + rows: number; +}; + +export type PerformanceInsightsWaitEvent = { + event: string; + type: string; + count: number; + totalWaitMs: number; +}; + +export type PerformanceInsights = { + topQueries: PerformanceInsightsQuery[]; + waitEvents: PerformanceInsightsWaitEvent[]; + totalCalls: number; + totalTimeMs: number; + avgTimeMs: number; +}; + +export type PITRWindows = { + earliest: string; + latest: string; +}; + +export type AuditLog = { + timestamp: string; + user: string; + database: string; + action: string; + object: string; + statement: string; + clientAddress: string; +}; + +export type AuditLogList = { + total: number; + auditLogs: AuditLog[]; +}; + +export type SlowQuery = { + query: string; + durationMs: number; + calls: number; + user: string; + database: string; +}; + +export type SlowQueryList = { + total: number; + slowQueries: SlowQuery[]; +}; + +export type DatabaseExtensions = { + installed: string[]; + available: string[]; +}; + +export type DatabaseStatusDetail = { + health: 'healthy' | 'degraded' | 'unhealthy'; + ready: boolean; + engine: DatabaseEngine; + version: string; + uptime: number; + connections: { + current: number; + max: number; + }; + replicas: { + index: number; + role: 'primary' | 'replica'; + healthy: boolean; + lagSeconds: number; + }[]; + volumes: { + path: string; + usedPercent: string; + available: string; + mounted: boolean; + }[]; +}; + +// ── Request Params ───────────────────────────────────────────────────────── + +export type CreateDedicatedDatabaseParams = { + databaseId: string; + name: string; + engine?: DatabaseEngine; + version?: string; + region?: string; + type?: DatabaseTypeValue; + tier?: string; + backend: DatabaseBackend; + cpu?: number; + memory?: number; + storage?: number; + storageClass?: StorageClass; + maxStorageGb?: number; + highAvailability?: boolean; + haReplicaCount?: number; + haSyncMode?: HASyncMode; + networkMaxConnections?: number; + networkIdleTimeoutSeconds?: number; + networkIPAllowlist?: string[]; + idleTimeoutMinutes?: number; + backupEnabled?: boolean; + backupPitr?: boolean; + backupSchedule?: string; + backupRetentionDays?: number; + pitrRetentionDays?: number; + storageAutoscaling?: boolean; + storageAutoscalingThresholdPercent?: number; + storageAutoscalingMaxGb?: number; + metricsEnabled?: boolean; +}; + +export type UpdateDedicatedDatabaseParams = { + name?: string; + status?: 'paused' | 'active' | 'inactive' | 'ready'; + cpu?: number; + memory?: number; + storage?: number; + storageClass?: StorageClass; + highAvailability?: boolean; + haReplicaCount?: number; + haSyncMode?: HASyncMode; + networkMaxConnections?: number; + networkIdleTimeoutSeconds?: number; + networkIPAllowlist?: string[]; + idleTimeoutMinutes?: number; + backupEnabled?: boolean; + backupPitr?: boolean; + backupCron?: string; + backupRetentionDays?: number; + pitrRetentionDays?: number; + storageAutoscaling?: boolean; + storageAutoscalingThresholdPercent?: number; + storageAutoscalingMaxGb?: number; + metricsEnabled?: boolean; + securityAuditLogEnabled?: boolean; + securityLogRetentionDays?: number; + sqlApiEnabled?: boolean; + sqlApiMaxBytes?: number; + sqlApiMaxRows?: number; + sqlApiTimeoutSeconds?: number; + sqlApiAllowedStatements?: string[]; +}; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +const JSON_HEADERS = { 'content-type': 'application/json' } as const; + +function filterUndefined(obj: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (value !== undefined) { + result[key] = value; + } + } + return result; +} + +// ── SDK Class ────────────────────────────────────────────────────────────── + +export class DedicatedDatabases { + client: Client; + + constructor(client: Client) { + this.client = client; + } + + private uri(path: string): URL { + return new URL(this.client.config.endpoint + path); + } + + // ── Database CRUD ────────────────────────────────────────────────── + + async create(params: CreateDedicatedDatabaseParams): Promise { + return await this.client.call('POST', this.uri('/compute/databases'), JSON_HEADERS, { + databaseId: params.databaseId, + name: params.name, + engine: params.engine ?? 'postgres', + version: params.version, + region: params.region ?? 'fra', + type: params.type ?? 'shared', + tier: params.tier ?? 'starter', + backend: params.backend, + cpu: params.cpu, + memory: params.memory, + storage: params.storage, + storageClass: params.storageClass, + maxStorageGb: params.maxStorageGb, + highAvailability: params.highAvailability, + haReplicaCount: params.haReplicaCount, + haSyncMode: params.haSyncMode, + networkMaxConnections: params.networkMaxConnections, + networkIdleTimeoutSeconds: params.networkIdleTimeoutSeconds, + networkIPAllowlist: params.networkIPAllowlist, + idleTimeoutMinutes: params.idleTimeoutMinutes, + backupEnabled: params.backupEnabled, + backupPitr: params.backupPitr, + backupCron: params.backupSchedule, + backupRetentionDays: params.backupRetentionDays, + pitrRetentionDays: params.pitrRetentionDays, + storageAutoscaling: params.storageAutoscaling, + storageAutoscalingThresholdPercent: params.storageAutoscalingThresholdPercent, + storageAutoscalingMaxGb: params.storageAutoscalingMaxGb, + metricsEnabled: params.metricsEnabled + }); + } + + async get(databaseId: string): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}`), + JSON_HEADERS + ); + } + + async list(queries: string[] = [], search?: string): Promise { + const params: Record = {}; + if (queries.length > 0) params.queries = queries; + if (search) params.search = search; + return await this.client.call( + 'GET', + this.uri('/compute/databases'), + JSON_HEADERS, + params + ); + } + + async update( + databaseId: string, + params: UpdateDedicatedDatabaseParams + ): Promise { + return await this.client.call( + 'PATCH', + this.uri(`/compute/databases/${databaseId}`), + JSON_HEADERS, + filterUndefined(params) + ); + } + + async delete(params: { databaseId: string }): Promise { + return await this.client.call( + 'DELETE', + this.uri(`/compute/databases/${params.databaseId}`), + JSON_HEADERS + ); + } + + // ── Lifecycle ────────────────────────────────────────────────────── + + async migrate(databaseId: string): Promise { + return await this.client.call( + 'POST', + this.uri(`/compute/databases/${databaseId}/migrations`), + JSON_HEADERS + ); + } + + async upgradeVersion(databaseId: string, targetVersion: string): Promise { + return await this.client.call( + 'POST', + this.uri(`/compute/databases/${databaseId}/upgrades`), + JSON_HEADERS, + { targetVersion } + ); + } + + async updateActivity( + databaseId: string, + params?: { inboundBytes?: number; outboundBytes?: number } + ): Promise { + return await this.client.call( + 'PATCH', + this.uri(`/compute/databases/${databaseId}/activity`), + JSON_HEADERS, + params ? filterUndefined(params) : undefined + ); + } + + // ── Status ───────────────────────────────────────────────────────── + + async getStatus(databaseId: string): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/status`), + JSON_HEADERS + ); + } + + // ── Credentials ──────────────────────────────────────────────────── + + async getCredentials(databaseId: string): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/credentials`), + JSON_HEADERS + ); + } + + async rotateCredentials(databaseId: string): Promise { + return await this.client.call( + 'POST', + this.uri(`/compute/databases/${databaseId}/credentials`), + JSON_HEADERS + ); + } + + // ── Connections (Database Users) ─────────────────────────────────── + + async createConnection( + databaseId: string, + username: string, + role: ConnectionRole = 'readwrite' + ): Promise { + return await this.client.call( + 'POST', + this.uri(`/compute/databases/${databaseId}/connections`), + JSON_HEADERS, + { username, role } + ); + } + + async listConnections(databaseId: string): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/connections`), + JSON_HEADERS + ); + } + + async deleteConnection(databaseId: string, connectionId: string): Promise { + return await this.client.call( + 'DELETE', + this.uri(`/compute/databases/${databaseId}/connections/${connectionId}`), + JSON_HEADERS + ); + } + + async getActiveConnections(databaseId: string): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/active-connections`), + JSON_HEADERS + ); + } + + // ── Extensions (PostgreSQL) ──────────────────────────────────────── + + async createExtension(databaseId: string, name: string): Promise { + return await this.client.call( + 'POST', + this.uri(`/compute/databases/${databaseId}/extensions`), + JSON_HEADERS, + { name } + ); + } + + async listExtensions(databaseId: string): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/extensions`), + JSON_HEADERS + ); + } + + async deleteExtension(databaseId: string, extensionName: string): Promise { + return await this.client.call( + 'DELETE', + this.uri(`/compute/databases/${databaseId}/extensions/${extensionName}`), + JSON_HEADERS + ); + } + + // ── Connection Pooler ────────────────────────────────────────────── + + async getPoolerConfig(databaseId: string): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/pooler`), + JSON_HEADERS + ); + } + + async updatePoolerConfig( + databaseId: string, + params: { mode?: PoolerMode; maxConnections?: number; defaultPoolSize?: number } + ): Promise { + return await this.client.call( + 'PATCH', + this.uri(`/compute/databases/${databaseId}/pooler`), + JSON_HEADERS, + filterUndefined(params) + ); + } + + // ── High Availability ────────────────────────────────────────────── + + async getHAStatus(databaseId: string): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/ha`), + JSON_HEADERS + ); + } + + async createFailover(databaseId: string, targetReplicaId?: string): Promise { + return await this.client.call( + 'POST', + this.uri(`/compute/databases/${databaseId}/ha/failovers`), + JSON_HEADERS, + targetReplicaId ? { targetReplicaId } : undefined + ); + } + + // ── Cross-Region Failover ────────────────────────────────────────── + + async enableCrossRegion( + databaseId: string, + standbyRegion: string + ): Promise { + return await this.client.call( + 'PUT', + this.uri(`/compute/databases/${databaseId}/cross-region`), + JSON_HEADERS, + { standbyRegion } + ); + } + + async disableCrossRegion(databaseId: string): Promise { + return await this.client.call( + 'DELETE', + this.uri(`/compute/databases/${databaseId}/cross-region`), + JSON_HEADERS + ); + } + + async getCrossRegionStatus(databaseId: string): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/cross-region`), + JSON_HEADERS + ); + } + + async triggerCrossRegionFailover(databaseId: string): Promise { + return await this.client.call( + 'POST', + this.uri(`/compute/databases/${databaseId}/cross-region/failovers`), + JSON_HEADERS + ); + } + + // ── Read Replicas ────────────────────────────────────────────────── + + async createReadReplica( + databaseId: string, + targetRegion: string, + crossZoneConsent: boolean = false + ): Promise { + return await this.client.call( + 'POST', + this.uri(`/compute/databases/${databaseId}/replicas`), + JSON_HEADERS, + { targetRegion, crossZoneConsent } + ); + } + + async listReadReplicas(databaseId: string): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/replicas`), + JSON_HEADERS + ); + } + + async deleteReadReplica(databaseId: string, replicaId: string): Promise { + return await this.client.call( + 'DELETE', + this.uri(`/compute/databases/${databaseId}/replicas/${replicaId}`), + JSON_HEADERS + ); + } + + async getReadReplica(databaseId: string, replicaId: string): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/replicas/${replicaId}`), + JSON_HEADERS + ); + } + + // ── Backups ──────────────────────────────────────────────────────── + + async createBackup( + databaseId: string, + type: 'full' | 'incremental' = 'full' + ): Promise { + return await this.client.call( + 'POST', + this.uri(`/compute/databases/${databaseId}/backups`), + JSON_HEADERS, + { type } + ); + } + + async listBackups( + databaseId: string, + params?: { + status?: BackupStatusValue; + type?: BackupType; + limit?: number; + offset?: number; + } + ): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/backups`), + JSON_HEADERS, + params ? filterUndefined(params) : undefined + ); + } + + async getBackup(databaseId: string, backupId: string): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/backups/${backupId}`), + JSON_HEADERS + ); + } + + async deleteBackup(databaseId: string, backupId: string): Promise { + return await this.client.call( + 'DELETE', + this.uri(`/compute/databases/${databaseId}/backups/${backupId}`), + JSON_HEADERS + ); + } + + // ── Restorations ─────────────────────────────────────────────────── + + async createRestoration(databaseId: string, backupId: string): Promise { + return await this.client.call( + 'POST', + this.uri(`/compute/databases/${databaseId}/restorations`), + JSON_HEADERS, + { type: 'backup', backupId } + ); + } + + async createPITRRestoration(databaseId: string, targetTime: number): Promise { + return await this.client.call( + 'POST', + this.uri(`/compute/databases/${databaseId}/restorations`), + JSON_HEADERS, + { type: 'pitr', targetTime } + ); + } + + async listRestorations( + databaseId: string, + params?: { + status?: RestorationStatusValue; + type?: RestorationType; + limit?: number; + offset?: number; + } + ): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/restorations`), + JSON_HEADERS, + params ? filterUndefined(params) : undefined + ); + } + + async getRestoration(databaseId: string, restorationId: string): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/restorations/${restorationId}`), + JSON_HEADERS + ); + } + + // ── PITR ─────────────────────────────────────────────────────────── + + async getPITRWindows(databaseId: string): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/pitr-windows`), + JSON_HEADERS + ); + } + + // ── Metrics & Monitoring ─────────────────────────────────────────── + + async getMetrics( + databaseId: string, + period: '1h' | '24h' | '7d' | '30d' = '24h' + ): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/metrics`), + JSON_HEADERS, + { period } + ); + } + + async getSlowQueries( + databaseId: string, + params?: { limit?: number; thresholdMs?: number } + ): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/slow-queries`), + JSON_HEADERS, + params ? filterUndefined(params) : undefined + ); + } + + async getPerformanceInsights( + databaseId: string, + params?: { period?: '1h' | '24h' | '7d'; limit?: number } + ): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/performance-insights`), + JSON_HEADERS, + params ? filterUndefined(params) : undefined + ); + } + + async getAuditLogs( + databaseId: string, + params?: { startTime?: string; endTime?: string; limit?: number } + ): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/audit-logs`), + JSON_HEADERS, + params ? filterUndefined(params) : undefined + ); + } + + // ── Maintenance ──────────────────────────────────────────────────── + + async updateMaintenance( + databaseId: string, + params: { + day: MaintenanceDay; + hourUtc: number; + durationMinutes?: number; + } + ): Promise { + return await this.client.call( + 'PATCH', + this.uri(`/compute/databases/${databaseId}/maintenance`), + JSON_HEADERS, + filterUndefined(params) + ); + } + + // ── Backup Storage (Off-Cluster) ─────────────────────────────────── + + async configureBackupStorage( + databaseId: string, + params: { + provider: BackupStorageProvider; + bucket: string; + region: string; + accessKeyId: string; + secretAccessKey: string; + prefix?: string; + endpoint?: string; + } + ): Promise { + return await this.client.call( + 'PUT', + this.uri(`/compute/databases/${databaseId}/backup-storage`), + JSON_HEADERS, + params + ); + } + + async getBackupStorageConfig(databaseId: string): Promise { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/backup-storage`), + JSON_HEADERS + ); + } + + async deleteBackupStorageConfig(databaseId: string): Promise { + return await this.client.call( + 'DELETE', + this.uri(`/compute/databases/${databaseId}/backup-storage`), + JSON_HEADERS + ); + } + + // ── Usage ────────────────────────────────────────────────────────── + + async getUsage( + databaseId: string, + range: '24h' | '30d' | '90d' = '24h' + ): Promise> { + return await this.client.call( + 'GET', + this.uri(`/compute/databases/${databaseId}/usage`), + JSON_HEADERS, + { range } + ); + } +} diff --git a/src/lib/stores/preferences.ts b/src/lib/stores/preferences.ts index e537ca3b52..311e4bd440 100644 --- a/src/lib/stores/preferences.ts +++ b/src/lib/stores/preferences.ts @@ -32,7 +32,11 @@ type ConsolePreferencesStore = { [key: string]: ConsolePreferences['columns']; }; displayNames?: { - [key: string]: TeamPreferences['names']; + [key: string]: + | TeamPreferences['names'] + | { + [key: string]: TeamPreferences['names']; + }; }; columnOrder?: { [key: string]: TeamPreferences['order']; @@ -213,19 +217,28 @@ function createPreferences() { return n; }), - deleteTableDetails: async (orgId: string, tableId: string) => { + // `databaseType` fallback for legacy cases. + deleteEntityDetails: async (orgId: string, entityId: string, databaseType?: string) => { + const dbType = databaseType ?? 'tables'; // remove from account preferences const removeCustomTableColumns = updateAndSync((n) => { - n = ensureObjectProperty(n, 'tables'); - delete n.tables[tableId]; + n = ensureObjectProperty(n, dbType); + delete n.tables[entityId]; return n; }); - delete teamPreferences?.displayNames?.[tableId]; - delete teamPreferences?.columnOrder?.[tableId]; - delete teamPreferences?.columnWidths?.[tableId]; - delete teamPreferences?.columnWidths?.[tableId + '#columns']; - delete teamPreferences?.columnWidths?.[tableId + '#indexes']; + delete teamPreferences?.columnOrder?.[entityId]; + delete teamPreferences?.columnWidths?.[entityId]; + delete teamPreferences?.columnWidths?.[entityId + '#columns']; + delete teamPreferences?.columnWidths?.[entityId + '#indexes']; + + if (teamPreferences.displayNames?.[dbType]?.[entityId]) { + // new structure + delete teamPreferences?.displayNames?.[dbType]?.[entityId]; + } else { + // legacy structure + delete teamPreferences?.displayNames?.[entityId]; + } const removeTablePreferences = sdk.forConsole.teams.updatePrefs({ teamId: orgId, @@ -237,18 +250,32 @@ function createPreferences() { loadTeamPrefs: loadTeamPreferences, - getDisplayNames: (tableId: string) => { - const names = teamPreferences?.displayNames?.[tableId]; + getDisplayNames: (entityId: string, databaseType?: string) => { + let names = teamPreferences?.displayNames?.[entityId]; + if (databaseType != null) { + names = teamPreferences?.displayNames?.[databaseType]?.[entityId]; + } + return Array.isArray(names) && names.length > 0 ? names : ['$id']; }, setDisplayNames: async ( orgId: string, - tableId: string, - displayNames: TeamPreferences['names'] + entityId: string, + displayNames: TeamPreferences['names'], + databaseType?: string ) => { teamPreferences = ensureObjectProperty(teamPreferences, 'displayNames'); - teamPreferences.displayNames[tableId] = displayNames; + if (databaseType == null) { + // legacy! + teamPreferences.displayNames[entityId] = displayNames; + } else { + teamPreferences.displayNames = ensureObjectProperty( + teamPreferences.displayNames, + databaseType + ); + teamPreferences.displayNames[databaseType][entityId] = displayNames; + } await sdk.forConsole.teams.updatePrefs({ teamId: orgId, diff --git a/src/lib/stores/sdk.ts b/src/lib/stores/sdk.ts index c2ff97c857..027dc6b0a2 100644 --- a/src/lib/stores/sdk.ts +++ b/src/lib/stores/sdk.ts @@ -28,6 +28,7 @@ import { Organizations } from '@appwrite.io/console'; import { Sources } from '$lib/sdk/sources'; +import { DedicatedDatabases } from '$lib/sdk/dedicatedDatabases'; import { REGION_FRA, REGION_NYC, @@ -136,7 +137,8 @@ const sdkForProject = { migrations: new Migrations(clientProject), sites: new Sites(clientProject), tablesDB: new TablesDB(clientProject), - /*documentsDB: new DocumentsDB(clientProject),*/ + documentsDB: new DocumentsDB(clientProject), + dedicatedDatabases: new DedicatedDatabases(clientProject), console: new Console(clientProject) // for suggestions API }; diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/dedicated-db.svg b/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/dedicated-db.svg new file mode 100644 index 0000000000..5e07417621 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/dedicated-db.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + Dedicated Database + diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/documents-db.svg b/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/documents-db.svg new file mode 100644 index 0000000000..87cbe9afba --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/documents-db.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/mongo-db.svg b/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/mongo-db.svg new file mode 100644 index 0000000000..98239f7288 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/mongo-db.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/prisma-postgres.svg b/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/prisma-postgres.svg new file mode 100644 index 0000000000..5748b0f2db --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/prisma-postgres.svg @@ -0,0 +1,8 @@ + + + + + + + Prisma Postgres + diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/tables-db.svg b/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/tables-db.svg new file mode 100644 index 0000000000..6b2739c52b --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/tables-db.svg @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/dedicated-db.svg b/src/routes/(console)/project-[region]-[project]/databases/(assets)/dedicated-db.svg new file mode 100644 index 0000000000..8cdf27a121 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/dedicated-db.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + Dedicated Database + diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/documents-db.svg b/src/routes/(console)/project-[region]-[project]/databases/(assets)/documents-db.svg new file mode 100644 index 0000000000..a5de231492 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/documents-db.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/mongo-db.svg b/src/routes/(console)/project-[region]-[project]/databases/(assets)/mongo-db.svg new file mode 100644 index 0000000000..5f4f09c805 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/mongo-db.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/mongo.svelte b/src/routes/(console)/project-[region]-[project]/databases/(assets)/mongo.svelte new file mode 100644 index 0000000000..1c60684d35 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/mongo.svelte @@ -0,0 +1,27 @@ + + + +
+ mongo-db artwork +
+ + diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/prisma-postgres.svg b/src/routes/(console)/project-[region]-[project]/databases/(assets)/prisma-postgres.svg new file mode 100644 index 0000000000..7cdc40d6da --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/prisma-postgres.svg @@ -0,0 +1,8 @@ + + + + + + + Prisma Postgres + diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/tables-db.svg b/src/routes/(console)/project-[region]-[project]/databases/(assets)/tables-db.svg new file mode 100644 index 0000000000..32519e9ab5 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/tables-db.svg @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/routes/(console)/project-[region]-[project]/databases/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/+page.svelte index 6140f5e2e9..22eb0358d2 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/+page.svelte @@ -1,17 +1,15 @@ + + +
+ + {#if typeFromParams === null} +
+ {@render selectDatabaseType()} +
+ {/if} + +
+ + + + {#if !showCustomId} +
+ (showCustomId = true)}> + + Database ID + +
+ {/if} + + +
+
+ + {#if showRegionSelect} +
+ + {#if showEngineSelect} + + {/if} + + + + {#if showTierSelect} + + + + {/if} + +
+ {/if} + + {#if isSharedType} +
+ + Shared databases are free and scale to zero when idle. The following + limits apply: + + + + + Storage + + + {sharedTierLimits.storage} + + + + + Max Connections + + + {sharedTierLimits.maxConnections} + + + + + Query Timeout + + + {sharedTierLimits.queryTimeout} + + + + + Idle Timeout + + + {sharedTierLimits.idleTimeout} (scales to zero) + + + +
+ {/if} + +
+ {#if backupSystem === 'appwrite'} + {#if isCloud} + {@render cloudBackupOptions()} + {:else} + {@render selfHostedBackupOptions()} + {/if} + {:else if backupSystem === 'prisma'} + {@render prismaBackupOptions()} + {:else if backupSystem === 'shared'} + {@render sharedBackupOptions()} + {:else if backupSystem === 'dedicated'} + {@render dedicatedBackupOptions()} + {/if} +
+
+
+ + + + + +
+ +{#snippet cloudBackupOptions()} + {#if $currentPlan?.backupsEnabled} +
+ +
+ {:else} + + Upgrade your plan to ensure your data stays safe and backed up. + + + + + {/if} +{/snippet} + +{#snippet selfHostedBackupOptions()} + + + Backups promo + + + + + Backups are available on Appwrite Cloud + + + + Sign up to access backups. Schedule automatic or manual backups to protect + your data and ensure quick recovery. + + + + + + + + + +{/snippet} + +{#snippet prismaBackupOptions()} + + + Prisma Postgres automatically creates daily snapshots with 30-day retention. Backups are + managed by Prisma and cannot be customized. + + +{/snippet} + +{#snippet sharedBackupOptions()} + + + Shared databases on the free tier do not include automatic backups. Upgrade to a + dedicated database for configurable backup and point-in-time recovery options. + + +{/snippet} + +{#snippet dedicatedBackupOptions()} + + + + {#if backupEnabled} + + + + + {#if backupPitr} + + + + PITR allows you to restore your database to any point within the {pitrRetentionDays}-day + retention window using WAL archiving. This provides more granular recovery + options but increases storage usage. + + {/if} + {/if} + +{/snippet} + +{#snippet selectDatabaseType()} + + {#each databaseTypes as databaseType} +
+ + {databaseType.subtitle} + +
+ {/each} +
+{/snippet} + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/navigation.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/navigation.ts new file mode 100644 index 0000000000..601e4999d7 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/navigation.ts @@ -0,0 +1,6 @@ +import { page } from '$app/state'; +import type { RecordType } from '$database/(entity)'; + +export function buildFieldUrl(recordType: RecordType, recordId: string) { + return `${page.url}/${recordType}-${recordId}`; +} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/sdk.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/sdk.ts index 34c5a65872..28a08a4def 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/sdk.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/sdk.ts @@ -1,11 +1,50 @@ import { sdk } from '$lib/stores/sdk'; import type { Page } from '@sveltejs/kit'; import type { TerminologyResult } from './types'; -import { type DatabaseType, type Entity, type EntityList, toSupportiveEntity } from './terminology'; +import { + type DatabaseType, + type Entity, + type EntityList, + type Record, + type RecordList, + toSupportiveEntity, + toSupportiveRecord +} from './terminology'; import type { Models } from '@appwrite.io/console'; +export type DedicatedDatabaseParams = { + databaseId: string; + name: string; + enabled?: boolean; + engine?: 'postgres' | 'mysql' | 'mariadb'; + region?: string; + tier?: string; + highAvailability?: boolean; + backupEnabled?: boolean; + backupSchedule?: string; + backupRetentionDays?: number; + backupPitr?: boolean; + pitrRetentionDays?: number; +}; + export type DatabaseSdkResult = { + create: ( + type: DatabaseType, + params: + | { + databaseId: string; + name: string; + enabled?: boolean; + } + | DedicatedDatabaseParams + ) => Promise; list: (params: { queries?: string[]; search?: string }) => Promise; + createEntity: (params: { + databaseId: string; + entityId: string; + name: string; + databaseType?: DatabaseType; + }) => Promise; getEntity: (params: { databaseId: string; entityId: string; @@ -18,6 +57,46 @@ export type DatabaseSdkResult = { databaseType?: DatabaseType; }) => Promise; delete: (params: { databaseId: string; databaseType?: DatabaseType }) => Promise<{}>; + deleteEntity: (params: { + databaseId: string; + entityId: string; + databaseType?: DatabaseType; + }) => Promise<{}>; + createRecord: (params: { + databaseId: string; + entityId: string; + recordId: string; + data?: object; + permissions?: string[]; + databaseType?: DatabaseType; + }) => Promise; + updateRecord: (params: { + databaseId: string; + entityId: string; + recordId: string; + data?: object; + permissions?: string[]; + databaseType?: DatabaseType; + }) => Promise; + updateRecordPermissions: (params: { + databaseId: string; + entityId: string; + recordId: string; + permissions: string[]; + databaseType?: DatabaseType; + }) => Promise; + deleteRecord: (params: { + databaseId: string; + entityId: string; + recordId?: string; + databaseType?: DatabaseType; + }) => Promise; + deleteRecords: (params: { + databaseId: string; + entityId: string; + queries?: string[]; + databaseType?: DatabaseType; + }) => Promise; }; export function useDatabaseSdk( @@ -34,7 +113,7 @@ export function useDatabaseSdk( region = regionOrPage?.params?.region || ''; project = regionOrPage?.params?.project || ''; } else { - type = databaseType!; + type = databaseType; region = regionOrPage as string; project = projectOrTerminology as string; } @@ -42,6 +121,66 @@ export function useDatabaseSdk( const baseSdk = sdk.forProject(region, project); return { + async create(type, params): Promise { + switch (type) { + case 'legacy': /* databases api */ + case 'tablesdb': { + return await baseSdk.tablesDB.create(params); + } + case 'documentsdb': { + return await baseSdk.documentsDB.create(params); + } + case 'prisma': { + // Prisma databases are created via the compute/databases endpoint + // with backend: 'prisma' + const prismaParams = params as DedicatedDatabaseParams; + return (await baseSdk.dedicatedDatabases.create({ + databaseId: prismaParams.databaseId, + name: prismaParams.name, + backend: 'prisma', + engine: 'postgres', + region: prismaParams.region, + tier: prismaParams.tier + })) as unknown as Models.Database; + } + case 'shared': { + // Shared (free tier) databases via compute/databases with type: 'shared' + const sharedParams = params as DedicatedDatabaseParams; + return (await baseSdk.dedicatedDatabases.create({ + databaseId: sharedParams.databaseId, + name: sharedParams.name, + backend: 'appwrite', + engine: 'postgres', + region: sharedParams.region, + type: 'shared' + })) as unknown as Models.Database; + } + case 'dedicated': { + // Dedicated databases are created via the compute/databases endpoint + // with backend: 'appwrite' + const dedicatedParams = params as DedicatedDatabaseParams; + return (await baseSdk.dedicatedDatabases.create({ + databaseId: dedicatedParams.databaseId, + name: dedicatedParams.name, + backend: 'appwrite', + engine: dedicatedParams.engine, + region: dedicatedParams.region, + tier: dedicatedParams.tier, + highAvailability: dedicatedParams.highAvailability, + backupEnabled: dedicatedParams.backupEnabled, + backupSchedule: dedicatedParams.backupSchedule, + backupRetentionDays: dedicatedParams.backupRetentionDays, + backupPitr: dedicatedParams.backupPitr, + pitrRetentionDays: dedicatedParams.pitrRetentionDays + })) as unknown as Models.Database; + } + case 'vectordb': + throw new Error('Database type not supported yet'); + default: + throw new Error('Unknown database type'); + } + }, + async list(params): Promise { const results = await Promise.all([ baseSdk.tablesDB.list(params) @@ -59,6 +198,35 @@ export function useDatabaseSdk( ); }, + async createEntity(params) { + switch (type ?? params.databaseType) { + case 'legacy': /* databases api */ + case 'tablesdb': { + const table = await baseSdk.tablesDB.createTable({ + ...params, + tableId: params.entityId + }); + return toSupportiveEntity(table); + } + case 'prisma': + case 'shared': + case 'dedicated': + throw new Error('External databases do not support entity creation via Appwrite'); + case 'documentsdb': { + const table = await baseSdk.documentsDB.createCollection({ + ...params, + collectionId: params.entityId + }); + + return toSupportiveEntity(table); + } + case 'vectordb': + throw new Error('Database type not supported yet'); + default: + throw new Error('Unknown database type'); + } + }, + async listEntities(params) { switch (type ?? params.databaseType) { case 'legacy': /* databases api */ @@ -66,11 +234,21 @@ export function useDatabaseSdk( const { total, tables } = await baseSdk.tablesDB.listTables(params); return { total, entities: tables.map(toSupportiveEntity) }; } + case 'prisma': + case 'shared': + case 'dedicated': { + // External databases don't have entities managed by Appwrite + return { total: 0, entities: [] }; + } + case 'documentsdb': { + const { total, collections } = + await baseSdk.documentsDB.listCollections(params); + return { total, entities: collections.map(toSupportiveEntity) }; + } case 'vectordb': - case 'documentsdb': - throw new Error(`Database type not supported yet`); + throw new Error('Database type not supported yet'); default: - throw new Error(`Unknown database type`); + throw new Error('Unknown database type'); } }, @@ -84,22 +262,210 @@ export function useDatabaseSdk( }); return toSupportiveEntity(table); } - case 'documentsdb': + case 'prisma': + case 'shared': + case 'dedicated': + throw new Error('External databases do not support entity retrieval via Appwrite'); + case 'documentsdb': { + const table = await baseSdk.documentsDB.getCollection({ + databaseId: params.databaseId, + collectionId: params.entityId + }); + return toSupportiveEntity(table); + } case 'vectordb': - throw new Error(`Database type not supported yet`); + throw new Error('Database type not supported yet'); default: throw new Error(`Unknown database type`); } }, - async delete(params) { + async delete(params): Promise<{}> { switch (type ?? params.databaseType) { case 'legacy': /* databases api */ case 'tablesdb': return await baseSdk.tablesDB.delete(params); case 'documentsdb': + return await baseSdk.documentsDB.delete(params); + case 'prisma': + case 'shared': + case 'dedicated': + await baseSdk.dedicatedDatabases.delete(params); + return {}; + case 'vectordb': + throw new Error('Database type not supported yet'); + default: + throw new Error(`Unknown database type`); + } + }, + + async deleteEntity(params) { + switch (type ?? params.databaseType) { + case 'legacy': /* databases api */ + case 'tablesdb': + return await baseSdk.tablesDB.deleteTable({ + databaseId: params.databaseId, + tableId: params.entityId + }); + case 'prisma': + case 'shared': + case 'dedicated': + throw new Error('External databases do not support entity deletion via Appwrite'); + case 'documentsdb': + return await baseSdk.documentsDB.deleteCollection({ + databaseId: params.databaseId, + collectionId: params.entityId + }); + case 'vectordb': + throw new Error('Database type not supported yet'); + default: + throw new Error(`Unknown database type`); + } + }, + + async createRecord(params) { + switch (type ?? params.databaseType) { + case 'legacy': /* databases api */ + case 'tablesdb': + return await baseSdk.tablesDB.createRow({ + databaseId: params.databaseId, + tableId: params.entityId, + rowId: params.recordId, + data: params.data, + permissions: params.permissions + }); + case 'prisma': + case 'shared': + case 'dedicated': + throw new Error('External databases do not support record creation via Appwrite'); + case 'documentsdb': + return await baseSdk.documentsDB.createDocument({ + databaseId: params.databaseId, + collectionId: params.entityId, + documentId: params.recordId, + data: params.data, + permissions: params.permissions + }); + case 'vectordb': + throw new Error('Database type not supported yet'); + default: + throw new Error(`Unknown database type`); + } + }, + + async updateRecord(params) { + switch (type ?? params.databaseType) { + case 'legacy': /* databases api */ + case 'tablesdb': + return await baseSdk.tablesDB.updateRow({ + databaseId: params.databaseId, + tableId: params.entityId, + rowId: params.recordId, + data: params.data, + permissions: params.permissions + }); + case 'prisma': + case 'shared': + case 'dedicated': + throw new Error('External databases do not support record updates via Appwrite'); + case 'documentsdb': + return await baseSdk.documentsDB.upsertDocument({ + databaseId: params.databaseId, + collectionId: params.entityId, + documentId: params.recordId, + data: params.data, + permissions: params.permissions + }); + case 'vectordb': + throw new Error('Database type not supported yet'); + default: + throw new Error(`Unknown database type`); + } + }, + + async updateRecordPermissions(params) { + switch (type ?? params.databaseType) { + case 'legacy': /* databases api */ + case 'tablesdb': + return await baseSdk.tablesDB.updateRow({ + databaseId: params.databaseId, + tableId: params.entityId, + rowId: params.recordId, + permissions: params.permissions + }); + case 'prisma': + case 'shared': + case 'dedicated': + throw new Error('External databases do not support permission updates via Appwrite'); + case 'documentsdb': + return await baseSdk.documentsDB.upsertDocument({ + databaseId: params.databaseId, + collectionId: params.entityId, + documentId: params.recordId, + permissions: params.permissions + }); + case 'vectordb': + throw new Error('Database type not supported yet'); + default: + throw new Error(`Unknown database type`); + } + }, + + async deleteRecord(params) { + switch (type ?? params.databaseType) { + case 'legacy': /* databases api */ + case 'tablesdb': { + const row = await baseSdk.tablesDB.deleteRow({ + databaseId: params.databaseId, + tableId: params.entityId, + rowId: params.recordId + }); + return toSupportiveRecord(row); + } + case 'prisma': + case 'shared': + case 'dedicated': + throw new Error('External databases do not support record deletion via Appwrite'); + case 'documentsdb': { + const document = await baseSdk.documentsDB.deleteDocument({ + databaseId: params.databaseId, + collectionId: params.entityId, + documentId: params.recordId + }); + return toSupportiveRecord(document); + } + case 'vectordb': + throw new Error('Database type not supported yet'); + default: + throw new Error(`Unknown database type`); + } + }, + + async deleteRecords(params) { + switch (type ?? params.databaseType) { + case 'legacy': /* databases api */ + case 'tablesdb': { + const { total, rows } = await baseSdk.tablesDB.deleteRows({ + databaseId: params.databaseId, + tableId: params.entityId, + queries: params.queries + }); + return { total, records: rows.map(toSupportiveRecord) }; + } + case 'prisma': + case 'shared': + case 'dedicated': + throw new Error('External databases do not support bulk record deletion via Appwrite'); + case 'documentsdb': { + const { total, documents } = await baseSdk.documentsDB.deleteDocuments({ + databaseId: params.databaseId, + collectionId: params.entityId, + queries: params.queries + }); + return { total, records: documents.map(toSupportiveRecord) }; + } case 'vectordb': - throw new Error(`Database type not supported yet`); + throw new Error('Database type not supported yet'); default: throw new Error(`Unknown database type`); } diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/terminology.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/terminology.ts index 6fff779c4e..486102e3c8 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/terminology.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/terminology.ts @@ -1,13 +1,26 @@ import type { Page } from '@sveltejs/kit'; import { capitalize, plural } from '$lib/helpers/string'; -import { AppwriteException, type Models } from '@appwrite.io/console'; -import type { Attributes, Columns, Table } from '$database/table-[table]/store'; +import type { Models } from '@appwrite.io/console'; +import type { Attributes, Collection, Columns, Table } from '$database/store'; import type { Term, TerminologyResult, TerminologyShape } from '$database/(entity)/helpers/types'; -export type DatabaseType = 'legacy' | 'tablesdb' | 'documentsdb' | 'vectordb'; +type BaseTerminology = typeof baseTerminology; +type ImplementedDBTypes = Omit; -export type Entity = Partial & { +export type DatabaseType = + | 'legacy' + | 'tablesdb' + | 'documentsdb' + | 'vectordb' + | 'prisma' + | 'shared' + | 'dedicated'; + +export type RecordType = ImplementedDBTypes[keyof ImplementedDBTypes]['record']; + +export type Entity = Partial & { + schema: boolean; indexes?: Index[]; fields?: (Attributes | Columns)[]; recordSecurity?: Models.Collection['documentSecurity'] | Models.Table['rowSecurity']; @@ -15,6 +28,10 @@ export type Entity = Partial & { export type Field = Partial | Partial; +export type Record = Partial & { + entityId?: Models.Document['$collectionId'] | Models.Row['$tableId']; +}; + export type Index = Partial & { fields: Models.Index['attributes'] | Models.ColumnIndex['columns']; }; @@ -24,6 +41,11 @@ export type EntityList = { entities: Entity[]; }; +export type RecordList = { + total: number; + records: Record[]; +}; + export const baseTerminology = { /** * this is no longer used on console so @@ -45,7 +67,22 @@ export const baseTerminology = { field: 'attribute', record: 'document' }, - vectordb: {} + vectordb: {}, + prisma: { + entity: 'table', + field: 'column', + record: 'row' + }, + shared: { + entity: 'table', + field: 'column', + record: 'row' + }, + dedicated: { + entity: 'table', + field: 'column', + record: 'row' + } } as const; const createTerm = (singular: string, pluralForm: string): Term => { @@ -94,7 +131,8 @@ export function toSupportiveEntity(raw: Models.Collection | Models.Table): Entit ...raw, fields, recordSecurity, - indexes + indexes, + schema: !isTable /* not table, so considering schema less */ } as Entity; } @@ -102,6 +140,22 @@ export function toRelationalField(raw: Field): Columns { return raw as Columns; } +export function toSupportiveRecord(raw: Record | Models.Document | Models.Row): Record { + const isRow = '$tableId' in raw; + const isRecord = 'entityId' in raw; + + if (isRecord && raw.entityId) { + return raw as Record; + } + + const entityId = isRow ? (raw as Models.Row).$tableId : (raw as Models.Document).$collectionId; + + return { + ...raw, + entityId + } as Record; +} + /** * @internal * Use `getTerminologies()` instead when in `database-[database]` routes where context is available. @@ -113,12 +167,15 @@ export function useTerminology(pageOrType: Page | DatabaseType): TerminologyResu : pageOrType; if (!type) { // strict check because this should always be available! - throw new AppwriteException('Database type is required', 500); + throw new Error('Database type is required for terminology lookup'); } const dbTerminologies = terminologyData[type] || {}; + const strictSchema = type === 'legacy' || type === 'tablesdb'; + return { type, + schema: strictSchema, source: dbTerminologies, field: dbTerminologies.field, record: dbTerminologies.record, diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/types.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/types.ts index 639c7412d7..70695234e8 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/types.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/types.ts @@ -12,6 +12,7 @@ export type Term = { singular: string; plural: string }; export type TerminologyResult = { type: DatabaseType; + schema: boolean; source: { entity?: { lower: Term; title: Term }; field?: { lower: Term; title: Term }; @@ -60,7 +61,7 @@ export type AnalyticsResult = { // for derived dependencies! export type DependenciesResult = { - [K in keyof Omit]: { + [K in keyof Omit]: { singular: Dependencies; plural: Dependencies; }; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/create.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/create.svelte index c8830a36e9..eec6a4f781 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/create.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/create.svelte @@ -8,7 +8,7 @@ import { addNotification } from '$lib/stores/notifications'; import { Input as SuggestionsInput, - tableColumnSuggestions + entityColumnSuggestions } from '$database/(suggestions)/index'; import { getTerminologies } from '../helpers'; @@ -41,12 +41,12 @@ function enableThinkingModeForSuggestions(id: string, name: string) { if (!useSuggestions) return; - if ($tableColumnSuggestions.enabled) { + if ($entityColumnSuggestions.enabled) { // if enabled, trigger thinking mode! - tableColumnSuggestions.update((store) => ({ + entityColumnSuggestions.update((store) => ({ ...store, thinking: true, - table: { + entity: { id, name } @@ -91,6 +91,14 @@ show = false; } + /** + * Converts string to valid Appwrite ID format matching backend rules: + * - Lowercase alphanumeric characters, hyphens, underscores, and dots only + * - Cannot start with a hyphen + * - Cannot end with a dot + * - Consecutive underscores collapsed to single underscore + * - Maximum 36 characters + */ function toIdFormat(str: string): string { return str .toLowerCase() @@ -99,7 +107,7 @@ .replace(/^-+/, '') .replace(/\.+$/, '') .replace(/_{2,}/g, '_') - .slice(0, 36); // max length + .slice(0, 36); } $effect(() => { @@ -116,10 +124,10 @@ $effect(() => { // reset is OK here, we don't have to check for entity type! - if (show && isOnEntitiesPage && $tableColumnSuggestions.table) { - tableColumnSuggestions.update((store) => ({ + if (show && isOnEntitiesPage && $entityColumnSuggestions.entity) { + entityColumnSuggestions.update((store) => ({ ...store, - table: null + entity: null })); } }); @@ -152,7 +160,7 @@ }} /> {#if useSuggestions} - + {/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/field/activity.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/field/activity.svelte new file mode 100644 index 0000000000..a9fc197224 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/field/activity.svelte @@ -0,0 +1,87 @@ + + +{#if loading} +
+ + +
+{:else if recordActivityLogs} +
+ +
+{/if} + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/field/editPermissions.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/field/editPermissions.svelte new file mode 100644 index 0000000000..446c0715ed --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/field/editPermissions.svelte @@ -0,0 +1,107 @@ + + +

+ A user requires appropriate permissions at either the {entityTerm} level or + {recordTerm} level to access a {recordTerm}. If no permissions are configured, no user + can access the {recordTerm} + Learn more about database permissions. +

+ +{#if entity.recordSecurity} + {#if showPermissionAlert} + (showPermissionAlert = false)}> + Users will be able to access this {recordTerm} if they have been granted + either {recordTerm} or {entityTerm} permissions. + + {/if} + {#if permissions} + + {/if} +{:else} + + If you want to assign {recordTerm} permissions. Go to {entityTermTitle} settings and enable {recordTerm} + security. Otherwise, only {entityTerm} permissions will be used. + +{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/field/index.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/field/index.ts index dfe3a26079..640842e601 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/field/index.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/field/index.ts @@ -1 +1,3 @@ +export { default as RecordActivity } from './activity.svelte'; export { default as CsvDisabled } from './csvDisabled.svelte'; +export { default as EditRecordPermissions } from './editPermissions.svelte'; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/header.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/header.svelte index 8258a787cd..91f9749a1e 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/header.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/header.svelte @@ -8,8 +8,8 @@ import { type Entity, useTerminology } from '$database/(entity)'; import { resolveRoute, withPath } from '$lib/stores/navigation'; + import { expandTabs } from '$database/store'; import { preferences } from '$lib/stores/preferences'; - import { expandTabs } from '$database/table-[table]/store'; interface EntityTab { href: string; @@ -65,7 +65,7 @@ $effect(() => { if (nonSheetPages) expandTabs.set(true); else { - expandTabs.set(preferences.getKey('tableHeaderExpanded', true)); + expandTabs.set(preferences.getKey('entityHeaderExpanded', true)); } }); diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/indexes/view.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/indexes/view.svelte index 032575c19e..091994fbe2 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/indexes/view.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/indexes/view.svelte @@ -53,7 +53,7 @@ onCreateIndex: (index: CreateIndexesCallbackType) => Promise; onDeleteIndexes: (indexKeys: string[]) => Promise; emptyIndexesSheetView: Snippet<[() => void]>; - emptyEntitiesSheetView?: Snippet; + emptyEntitiesSheetView?: Snippet<[() => void]>; } = $props(); let showCreateIndex = $state(false); @@ -119,9 +119,13 @@ // example: documentsdb.*.collections.indexes.* // this is needed because `documentsdb` doesn't use `database` prefix don't exist const derivedEventsForIndex = `${terminology.type}.*.${terminology.entity.lower.plural}.*.indexes.*`; + const indexEvents = + terminology.type === 'documentsdb' + ? [derivedEventsForIndex] + : ['databases.*.tables.*.indexes.*', derivedEventsForIndex]; return realtime.forProject(page.params.region, ['project', 'console'], (response) => { - if (response.events.includes(derivedEventsForIndex)) { + if (indexEvents.some((event) => response.events.includes(event))) { invalidate(dependencies.entity.singular); } }); @@ -200,7 +204,7 @@
- {#if entity.fields?.length} + {#if !terminology.schema || entity.fields?.length} {#if entity.indexes.length} (showCreateIndex = true))} {/if} {:else} - {@render emptyEntitiesSheetView?.()} + {@render emptyEntitiesSheetView?.(() => (showCreateIndex = true))} {/if} {#if selectedIndexes.length > 0} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/empty.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/empty.svelte index 640bb39351..b00e5696d6 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/empty.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/empty.svelte @@ -11,14 +11,16 @@ import { isSmallViewport, isTabletViewport } from '$lib/stores/viewport'; import { SortButton } from '$lib/components'; import type { Column } from '$lib/helpers/types'; - import { SpreadsheetContainer } from '$database/(entity)'; + import { type DatabaseType, getTerminologies, SpreadsheetContainer } from '$database/(entity)'; import { onDestroy, onMount, type Snippet } from 'svelte'; import { debounce } from '$lib/helpers/debounce'; - import { expandTabs, spreadsheetLoading } from '$database/table-[table]/store'; + import { expandTabs, spreadsheetLoading } from '$database/store'; + import { NoSqlEditor } from '$database/collection-[collection]/(components)/editor'; - type Mode = 'rows' | 'rows-filtered' | 'indexes'; + type Mode = 'records' | 'records-filtered' | 'indexes'; const { + type = 'tablesdb', mode, title, actions, @@ -26,15 +28,16 @@ showActions = true, customColumns = [], onOpenCreateColumn - } = $props<{ + }: { + type?: DatabaseType; mode: Mode; - customColumns?: Column[]; title?: string; subtitle?: Snippet; actions?: Snippet; showActions?: boolean; + customColumns?: Column[]; onOpenCreateColumn?: () => Promise | void; - }>(); + } = $props(); let spreadsheetContainer: HTMLElement; let headerElement: HTMLElement | null = null; @@ -48,6 +51,8 @@ const baseColProps = { draggable: false, resizable: false }; + const { terminology } = getTerminologies(); + const updateOverlayLeftOffset = () => { if (spreadsheetContainer) { const containerRect = spreadsheetContainer.getBoundingClientRect(); @@ -89,6 +94,8 @@ onMount(async () => { if (spreadsheetContainer) { + requestAnimationFrame(updateOverlayHeight); + resizeObserver = new ResizeObserver(debouncedUpdateOverlayHeight); resizeObserver.observe(spreadsheetContainer); @@ -214,6 +221,52 @@ return columns; }; + // TODO: @itznotabug - we probably don't need `hasCustomColumns` but check please. + const getDocumentsDbColumns = (): Column[] => [ + { + id: '$id', + title: '$id', + width: 225, + minimumWidth: 225, + draggable: false, + type: 'string', + icon: IconFingerPrint, + isEditable: false, + isPrimary: false + }, + { + id: '$createdAt', + title: '$createdAt', + width: 200, + minimumWidth: 200, + draggable: false, + type: 'datetime', + icon: IconCalendar, + isEditable: false + }, + { + id: '$updatedAt', + title: '$updatedAt', + width: 200, + minimumWidth: 200, + draggable: false, + type: 'datetime', + icon: IconCalendar, + isEditable: false + }, + { + id: 'actions', + title: '', + width: 40, + isAction: true, + draggable: false, + type: 'string', + resizable: false, + isEditable: false, + hide: false + } + ]; + const getIndexesColumns = (): Column[] => { const columns = [ { id: 'key', title: 'Key', icon: null, isPrimary: false }, @@ -234,18 +287,32 @@ return columns; }; - const spreadsheetColumns = $derived(mode === 'indexes' ? getIndexesColumns() : getRowColumns()); + const isRecordMode = $derived(mode === 'records' || mode === 'records-filtered'); + + const spreadsheetColumns = $derived.by(() => { + return isRecordMode + ? type !== 'documentsdb' + ? getRowColumns() + : getDocumentsDbColumns() + : getIndexesColumns(); + }); const emptyCells = $derived( ($isSmallViewport ? 14 : $isTabletViewport ? 17 : 24) + (!$expandTabs ? 2 : 0) ); + + const modeTerminology = $derived( + mode === 'indexes' ? 'indexes' : terminology.record.lower.plural + );
0} + class:no-custom-columns={spreadsheetColumns.length <= 0} + data-loading={$spreadsheetLoading} bind:this={spreadsheetContainer} - class:custom-columns={customColumns.length > 0} - class:no-custom-columns={customColumns.length <= 0} class="databases-spreadsheet spreadsheet-container-outer"> { - if (mode === 'rows') { + if (isRecordMode) { onOpenCreateColumn?.(); } }}> @@ -311,15 +378,21 @@ {/if} + + {#snippet noSqlEditor()} + {#if type === 'documentsdb' && mode === 'records'} + + {/if} + {/snippet} {#if !$spreadsheetLoading}
0} + class:custom-columns={spreadsheetColumns.length > 0} data-collapsed-tabs={!$expandTabs} - style:--overlay-left={overlayLeftOffset} style:--overlay-top={overlayTopOffset} + style:--overlay-left={overlayLeftOffset} style:--dynamic-overlay-height={dynamicOverlayHeight}>
- {title ?? `You have no ${mode} yet`} + {title ?? `You have no ${modeTerminology} yet`} {@render subtitle?.()} {#if showActions && actions} - {@const inline = mode === 'rows-filtered'} -
- + {@const isOnlyIndexes = mode === 'indexes' && type === 'documentsdb'} + {@const inline = mode === 'records-filtered' || isOnlyIndexes} +
+ {#if inline} {@render actions?.()} {:else} @@ -373,6 +450,10 @@ width: unset; } + &[data-mode='indexes'] { + width: 100%; + } + &.no-custom-columns { @media (max-width: 768px) { & :global(.spreadsheet-wrapper) { @@ -397,9 +478,8 @@ pointer-events: none; } - &[data-mode='rows'] { + &[data-mode='records'][data-type='tablesdb'] { --top-actions-spacing: 50%; - & :global([role='rowheader'] :nth-last-child(2) [role='presentation']) { display: none; } @@ -420,6 +500,19 @@ } } } + + &[data-mode='records'][data-type='documentsdb'] { + position: unset; + // disable animation when not loading! + &[data-loading='false'] :global(.skeleton) { + animation: none; + } + } + + & :global([data-select='true']) { + opacity: 0.85; + pointer-events: none; + } } .spreadsheet-fade-bottom { @@ -440,6 +533,9 @@ justify-content: center; transition: none !important; + // TODO: @itznotabug - check if we need it. + height: var(--dynamic-overlay-height, 70.5vh); + &.custom-columns { pointer-events: none; } @@ -452,6 +548,11 @@ width: 538px; max-width: 538px; } + + &.single-mode { + display: flex; + justify-content: center; + } } :global(.theme-dark) .spreadsheet-fade-bottom { diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/index.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/index.ts index 36dbcca040..9f3d5ab4e9 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/index.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/index.ts @@ -1,4 +1,7 @@ export { default as EmptySheet } from './empty.svelte'; export { default as SideSheet } from './sidesheet.svelte'; +export { default as SpreadsheetOptions } from './sheetOptions.svelte'; export { default as EmptySheetCards } from './emptySheetCards.svelte'; export { default as SpreadsheetContainer } from './spreadsheet.svelte'; + +export type { HeaderCellAction, RowCellAction } from './sheetOptions.svelte'; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/sheetOptions.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/sheetOptions.svelte similarity index 91% rename from src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/sheetOptions.svelte rename to src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/sheetOptions.svelte index 3fdf50d14a..35a9268880 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/sheetOptions.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/sheetOptions.svelte @@ -36,8 +36,8 @@ IconSortDescending, IconTrash } from '@appwrite.io/pink-icons-svelte'; - import { type Columns, databaseColumnSheetOptions } from './store'; - import { isRelationship } from './rows/store'; + import { type Columns } from '$database/store'; + import { isRelationship } from '$database/table-[table]/rows/store'; interface MenuItem { label?: string; @@ -84,7 +84,7 @@ onVisibilityChanged, type }: { - column: Columns; + column?: Columns; columnId?: string; type: 'header' | 'row'; onVisibilityChanged?: (visible: boolean) => void; @@ -94,20 +94,6 @@ function handleSelect(action: HeaderCellAction | RowCellAction, hide: () => void) { hide(); - $databaseColumnSheetOptions.column = column; - - if (action === 'column-left') { - $databaseColumnSheetOptions.direction = { - neighbour: columnId, - to: 'left' - }; - } else if (action === 'column-right') { - $databaseColumnSheetOptions.direction = { - neighbour: columnId, - to: 'right' - }; - } - onSelect(action, columnId); } diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/sidesheet.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/sidesheet.svelte index c0b525c612..5bcbb1968e 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/sidesheet.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/sidesheet.svelte @@ -19,12 +19,14 @@ titleBadge = null, topAction = null, topEndActions = null, + noContentPadding = false, ...restProps }: { show: boolean; title: string; titleBadge?: string; closeOnBlur?: boolean; + noContentPadding?: boolean; topAction?: | { text: string; @@ -62,7 +64,7 @@ beforeNavigate(() => (show = false)); -
+
@@ -186,6 +188,10 @@ padding-bottom: 15rem; } } + + &.noContentPadding :global(section) { + padding: unset !important; + } } .sheet-footer { diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/spreadsheet.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/spreadsheet.svelte index 30483eb680..51a6f41ac2 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/spreadsheet.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/spreadsheet.svelte @@ -2,11 +2,36 @@ import { debounce } from '$lib/helpers/debounce'; import { scrollStore, sheetHeightStore } from './store'; import { onMount, onDestroy, type Snippet, tick } from 'svelte'; + import { isSmallViewport } from '$lib/stores/viewport'; + import { SideSheet } from '$database/(entity)'; let { - children + children, + noSqlEditor, + sideSheetHeaderAction, + sideSheetOptions = null, + sideSheetStateCallbacks = null, + showEditorSideSheet = $bindable(false) }: { children: Snippet; + noSqlEditor?: Snippet; + sideSheetHeaderAction?: Snippet; + showEditorSideSheet?: boolean; + /* this sheet is only on mobile */ + sideSheetStateCallbacks?: { + onOpen?: () => void; + onClose?: () => void; + }; + sideSheetOptions?: { + sideSheetTitle?: string; + submit?: + | { + text: string; + disabled?: boolean; + onClick?: () => boolean | void | Promise; + } + | undefined; + }; } = $props(); let spreadsheetWrapper: HTMLDivElement; @@ -85,6 +110,16 @@ }); } + function manageStateCallbacks(isOpen: boolean) { + if (sideSheetStateCallbacks) { + if (isOpen) { + sideSheetStateCallbacks.onOpen?.(); + } else { + sideSheetStateCallbacks.onClose?.(); + } + } + } + /** save grid sheet scroll for restore */ export function saveGridSheetScroll(): void { if (initSpreadsheetGridContainer()) { @@ -110,14 +145,82 @@ resizeObserver?.disconnect(); mutationObserver?.disconnect(); }); + + let previousShowEditorSideSheet = showEditorSideSheet; + + $effect(() => { + if (showEditorSideSheet !== previousShowEditorSideSheet) { + manageStateCallbacks(showEditorSideSheet); + previousShowEditorSideSheet = showEditorSideSheet; + } + }); -
+
{@render children()} + +
+ {#if !$isSmallViewport} +
+ {@render noSqlEditor?.()} +
+ {:else} + { + // fires state callback. + showEditorSideSheet = false; + } + }} + title={sideSheetOptions?.sideSheetTitle ?? 'Edit document'}> + {@render noSqlEditor?.()} + + {#snippet topEndActions()} + {@render sideSheetHeaderAction?.()} + {/snippet} + + {/if} +
- diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/danger.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/danger.svelte index e55d6c604e..9a879b99d4 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/danger.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/danger.svelte @@ -40,7 +40,7 @@ // clear out! await Promise.all([ - preferences.deleteTableDetails($organization.$id, entity.$id), + preferences.deleteEntityDetails($organization.$id, entity.$id), navigate( '/(console)/project-[region]-[project]/databases/database-[database]', page.params diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(observer)/columnObserver.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(observer)/columnObserver.ts index 28ced29eb4..d48689b144 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(observer)/columnObserver.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(observer)/columnObserver.ts @@ -1,4 +1,4 @@ -import type { Columns } from '../table-[table]/store'; +import type { Columns } from '$database/store'; import type { RealtimeResponse } from '$lib/stores/sdk'; export function setupColumnObserver() { diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/columns.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/columns.svelte index 69cc26bcbb..8baafc9c62 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/columns.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/columns.svelte @@ -6,7 +6,7 @@ import Input from './input.svelte'; import { Modal } from '$lib/components'; import { Button } from '$lib/elements/forms'; - import { tableColumnSuggestions } from './store'; + import { entityColumnSuggestions } from './store'; let { show = $bindable(false) @@ -19,17 +19,17 @@ function resetSuggestionsStore() { show = false; - $tableColumnSuggestions.table = null; - $tableColumnSuggestions.context = null; + $entityColumnSuggestions.entity = null; + $entityColumnSuggestions.context = null; - $tableColumnSuggestions.force = false; - $tableColumnSuggestions.enabled = false; - $tableColumnSuggestions.thinking = false; + $entityColumnSuggestions.force = false; + $entityColumnSuggestions.enabled = false; + $entityColumnSuggestions.thinking = false; } async function triggerColumnSuggestions() { // set table info. first! - $tableColumnSuggestions.table = { + $entityColumnSuggestions.entity = { id: page.params.table, name: page.data.table?.name ?? 'Table' }; @@ -48,8 +48,8 @@ ); } - $tableColumnSuggestions.force = true; - $tableColumnSuggestions.enabled = true; + $entityColumnSuggestions.force = true; + $entityColumnSuggestions.enabled = true; show = false; } diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/empty.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/empty.svelte index 7355bf9df1..ed1a9ef450 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/empty.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/empty.svelte @@ -15,8 +15,9 @@ import { isSmallViewport, isTabletViewport } from '$lib/stores/viewport'; import type { Column } from '$lib/helpers/types'; import { SortButton } from '$lib/components'; - import { expandTabs, columnsOrder, columnsWidth, reorderItems } from '../table-[table]/store'; + import { columnsOrder, columnsWidth, reorderItems } from '../table-[table]/store'; import { preferences } from '$lib/stores/preferences'; + import { expandTabs, type Columns } from '../store'; import { SpreadsheetContainer } from '$database/(entity)'; import { onDestroy, onMount, tick } from 'svelte'; import { sdk, realtime, type RealtimeResponse } from '$lib/stores/sdk'; @@ -26,7 +27,7 @@ type ColumnInput, mapSuggestedColumns, type SuggestedColumnSchema, - tableColumnSuggestions, + entityColumnSuggestions, basicColumnOptions, mockSuggestions, showIndexesSuggestions @@ -37,7 +38,6 @@ import { invalidate } from '$app/navigation'; import { Dependencies } from '$lib/constants'; import { isWithinSafeRange } from '$lib/helpers/numbers'; - import type { Columns } from '../table-[table]/store'; import { columnOptions } from '../table-[table]/columns/store'; import Options from './options.svelte'; import { InputSelect, InputText } from '$lib/elements/forms'; @@ -607,27 +607,27 @@ }); function resetSuggestionsStore(fullReset: boolean = true) { - if ($tableColumnSuggestions.table?.id !== page.params.table) { + if ($entityColumnSuggestions.entity?.id !== page.params.table) { return; } if (fullReset) { // these are referenced in // `table-[table]/+page.svelte` - $tableColumnSuggestions.table = null; - $tableColumnSuggestions.force = false; - $tableColumnSuggestions.enabled = false; + $entityColumnSuggestions.entity = null; + $entityColumnSuggestions.force = false; + $entityColumnSuggestions.enabled = false; } - $tableColumnSuggestions.context = null; - $tableColumnSuggestions.thinking = false; + $entityColumnSuggestions.context = null; + $entityColumnSuggestions.thinking = false; // reset selection! resetSelectedColumn(); } async function suggestColumns() { - $tableColumnSuggestions.thinking = true; + $entityColumnSuggestions.thinking = true; await tick(); scrollToFirstCustomColumn(); @@ -651,7 +651,7 @@ .console.suggestColumns({ databaseId: page.params.database, tableId: page.params.table, - context: $tableColumnSuggestions.context ?? undefined, + context: $entityColumnSuggestions.context ?? undefined, min: 6 })) as unknown as { total: number; @@ -659,7 +659,7 @@ }; } - const tableName = $tableColumnSuggestions.table?.name ?? undefined; + const tableName = $entityColumnSuggestions.entity?.name ?? undefined; trackEvent(Submit.ColumnSuggestions, { tableName, total: suggestedColumns.total @@ -1298,7 +1298,7 @@ role="none" bind:this={spreadsheetContainer} class:custom-columns={customColumns.length > 0} - class:thinking={$tableColumnSuggestions.thinking} + class:thinking={$entityColumnSuggestions.thinking} class="databases-spreadsheet spreadsheet-container-outer" style:--overlay-icon-color="#fd366e99" style:--non-overlay-icon-color="--fgcolor-neutral-weak" @@ -1309,7 +1309,7 @@ bind:this={rangeOverlayEl} class="columns-range-overlay" class:no-transition={hasTransitioned && customColumns.length > 0} - class:thinking={$tableColumnSuggestions.thinking || creatingColumns}> + class:thinking={$entityColumnSuggestions.thinking || creatingColumns}>
{@render edgeGradients('left')} {@render edgeGradients('right')} @@ -1599,7 +1599,7 @@ data-collapsed-tabs={!$expandTabs}>
- {#if $tableColumnSuggestions.thinking} + {#if $entityColumnSuggestions.thinking}
@@ -1775,7 +1775,7 @@ !isInlineEditing && !$isTabletViewport && !$isSmallViewport && - !$tableColumnSuggestions.thinking && + !$entityColumnSuggestions.thinking && !creatingColumns && hoveredColumnId !== column.id ) { @@ -1877,38 +1877,6 @@
{/snippet} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+page.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+page.ts index 358f8d97ee..e4c26b4868 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+page.ts @@ -5,17 +5,32 @@ import { CARD_LIMIT, Dependencies } from '$lib/constants'; import { type DatabaseType, useDatabaseSdk } from '$database/(entity)'; export const load: PageLoad = async ({ params, url, route, depends, parent }) => { - const { database } = await parent(); + const { database, dedicatedDatabase } = await parent(); depends(Dependencies.TABLES); + const databaseType = database.type as DatabaseType; + + // For dedicated databases, we don't fetch entities (tables/collections) + const isDedicatedType = databaseType === 'prisma' || databaseType === 'dedicated'; + + if (isDedicatedType) { + return { + offset: 0, + limit: 0, + search: '', + view: View.Grid, + entities: { total: 0, entities: [] }, + isDedicatedType: true, + dedicatedDatabase + }; + } + const page = getPage(url); const search = getSearch(url); const limit = getLimit(url, route, CARD_LIMIT); const view = getView(url, route, View.Grid); const offset = pageToOffset(page, limit); - const databaseType = database.type as DatabaseType; - const databaseSdk = useDatabaseSdk(params.region, params.project, databaseType); const entities = await databaseSdk.listEntities({ databaseId: database.$id, @@ -28,6 +43,8 @@ export const load: PageLoad = async ({ params, url, route, depends, parent }) => limit, search, view, - entities + entities, + isDedicatedType: false, + dedicatedDatabase: null }; }; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/[...rest]/+page.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/[...rest]/+page.ts index 1b91b31ff8..0543bf4f1e 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/[...rest]/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/[...rest]/+page.ts @@ -1,67 +1,45 @@ -import { base } from '$app/paths'; import type { PageLoad } from './$types'; import { redirect } from '@sveltejs/kit'; import { AppwriteException } from '@appwrite.io/console'; import { databaseRowSheetOptions } from '../table-[table]/store'; +import { noSqlDocument } from '../collection-[collection]/store'; +import { resolveRoute } from '$lib/stores/navigation'; -const ROUTE_MAPPINGS: { - pattern: RegExp; - replacement?: string; - sheet?: boolean; -}[] = [ - // collection page redirect to table - { pattern: /^collection-([^/]+)/, replacement: 'table-$1' }, - - // document detail page redirect to their parent table - { pattern: /^document-([^/]+)/, sheet: true }, - - // attributes list page redirect to table columns - { pattern: /^attributes$/, replacement: 'columns' }, - - // New format routes, - { pattern: /^row-([^/]+)/, sheet: true } -] as const; - -function isLegacyRoute(segments: string[]): boolean { - return segments.some((segment) => - ROUTE_MAPPINGS.some((mapping) => mapping.pattern.test(segment)) +export const load: PageLoad = async ({ params, url }) => { + const restSegments = params.rest ? params.rest.split('/').filter(Boolean) : []; + const baseUrl = resolveRoute( + '/(console)/project-[region]-[project]/databases/database-[database]', + params ); -} -function rewriteLegacySegments(segments: string[]): string[] { - return segments - .map((segment) => { - for (const mapping of ROUTE_MAPPINGS) { - if (mapping.pattern.test(segment)) { - if (mapping.sheet) { - const match = segment.match(mapping.pattern); - if (match && match[1]) { - databaseRowSheetOptions.update((options) => ({ - ...options, - rowId: match[1], - title: 'Update row' - })); - } - } + if (restSegments.length === 0) { + throw new AppwriteException('Not Found', 404); + } - return !mapping.replacement - ? null - : segment.replace(mapping.pattern, mapping.replacement); - } - } - return segment; - }) - .filter(Boolean); -} + const lastSegment = restSegments[restSegments.length - 1]; -export const load: PageLoad = async ({ params, url }) => { - const restSegments = params.rest ? params.rest.split('/').filter(Boolean) : []; - const baseUrl = `${base}/project-${params.region}-${params.project}/databases/database-${params.database}`; + const rowMatch = lastSegment.match(/^row-([^/]+)$/); + if (rowMatch) { + const rowId = rowMatch[1]; + databaseRowSheetOptions.update((options) => ({ + ...options, + rowId, + show: true, + title: 'Update row' + })); + + const parentSegments = restSegments.slice(0, -1); + const newPath = `${baseUrl}/${parentSegments.join('/')}`; + redirect(308, newPath + url.search); + } - if (isLegacyRoute(restSegments)) { - const rewrittenSegments = rewriteLegacySegments(restSegments); - const newPath = `${baseUrl}/${rewrittenSegments.join('/')}`; + const documentMatch = lastSegment.match(/^document-([^/]+)$/); + if (documentMatch) { + const documentId = documentMatch[1]; + noSqlDocument.update({ documentId }); + const parentSegments = restSegments.slice(0, -1); + const newPath = `${baseUrl}/${parentSegments.join('/')}`; redirect(308, newPath + url.search); } diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/+page.svelte index 12cb11a541..f01cecb302 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/+page.svelte @@ -5,6 +5,7 @@ import BackupPolicy from './policy.svelte'; import LockedCard from './locked.svelte'; import Table from './table.svelte'; + import DedicatedBackups from './dedicatedBackups.svelte'; import type { PageProps } from './$types'; import CreatePolicy from './createPolicy.svelte'; import { Button } from '$lib/elements/forms'; @@ -24,9 +25,16 @@ import { Layout, Typography } from '@appwrite.io/pink-svelte'; import { page } from '$app/state'; import IconQuestionMarkCircle from './components/questionIcon.svelte'; + import type { DedicatedDatabase } from '$lib/sdk/dedicatedDatabases'; const { data }: PageProps = $props(); + const isDedicatedType = $derived( + data.database?.type === 'prisma' || + data.database?.type === 'dedicated' || + data.database?.type === 'shared' + ); + let policyCreateError: string | null = $state(null); let totalPolicies: UserBackupPolicy[] = $state([]); @@ -170,6 +178,9 @@ }); +{#if isDedicatedType && data.dedicatedDatabase} + +{:else}
{#if !isDisabled} @@ -234,6 +245,7 @@ {/if}
+{/if} policy.checked) - ].map((policy) => { - if (!policy.id) policy.id = ID.unique(); - return policy; - }); + ].map((policy) => ({ + ...policy, + id: policy.id || ID.unique() + })); let policyInEdit = null; let policyBeingEdited = null; @@ -97,6 +99,8 @@ listOfCustomPolicies = [...listOfCustomPolicies, userBackupPolicy]; + selectedPolicyGroup = 'custom'; + resetFormVariables(); showCustomPolicy = false; }; @@ -141,6 +145,7 @@ resetFormVariables(); showCustomPolicy = false; listOfCustomPolicies = []; + selectedPolicyGroup = null; presetPolicies.update((all) => all.map((policy) => { policy.checked = false; @@ -173,6 +178,44 @@ value: freq, label: freq.charAt(0).toUpperCase() + freq.slice(1) })); + + let selectedPolicyGroup: string = null; + $: if (selectedPolicyGroup) { + if (selectedPolicyGroup === 'custom') { + if (listOfCustomPolicies.length === 0) { + showCustomPolicy = true; + } + + presetPolicies.update((policies) => { + return policies.map((policy) => ({ + ...policy, + checked: false + })); + }); + } else if (selectedPolicyGroup !== 'custom') { + listOfCustomPolicies = []; + showCustomPolicy = false; + + presetPolicies.update((policies) => { + return policies.map((policy) => ({ + ...policy, + checked: selectedPolicyGroup !== 'none' && policy.id === selectedPolicyGroup + })); + }); + } + } + + onMount(() => { + if (isFromBackupsTab) { + presetPolicies.update((preset) => { + return preset.filter((policy) => policy.id !== 'none'); + }); + } else { + presetPolicies.update((preset) => { + return preset.filter((policy) => policy.id !== 'hourly'); + }); + } + });
@@ -240,25 +283,65 @@ {:else} -
- {#each $presetPolicies as policy, index (index)} - - {/each} -
+ + {/each} + {:else} + {@const none = $presetPolicies[1]} + {@const dailPreset = $presetPolicies[0]} + + One backup every 24 hours, retained for 30 days + + + + + Define your own schedule and retention + + + + + {none.description} + + {/if} + {#if listOfCustomPolicies.length} - + {#each listOfCustomPolicies as policy} @@ -476,6 +559,10 @@ policyInEdit = false; showCustomPolicy = false; + if (selectedPolicyGroup === 'custom') { + selectedPolicyGroup = null; + } + if (policyBeingEdited) { listOfCustomPolicies = [ ...listOfCustomPolicies, @@ -508,7 +595,7 @@
- {:else} + {:else if isFromBackupsTab}
{/if} + + {#if listOfCustomPolicies.length && !isFromBackupsTab && !showCustomPolicy && !policyInEdit} +
+ +
+ {/if}
{/if}
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/dedicatedBackups.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/dedicatedBackups.svelte new file mode 100644 index 0000000000..8025afa5c8 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/dedicatedBackups.svelte @@ -0,0 +1,685 @@ + + + + + + Backup Configuration + Current backup settings for this database. + + + + + Automatic Backups + + + + {#if database.backupEnabled} + + + Schedule + + {database.backupCron} + + + + Retention + + + {database.backupRetentionDays} days + + + + + Point-in-Time Recovery + + + + {/if} + + + + + + + + + {#if database.backupPitr} + + Point-in-Time Recovery + Restore your database to any point within the recovery window. + + {#if isLoadingPitr} + Loading recovery window... + {:else if pitrWindows} + + + + + Earliest Recovery Point + + + {formatPitrTime(pitrWindows.earliest)} + + + + + Latest Recovery Point + + + {formatPitrTime(pitrWindows.latest)} + + + + {#if database.pitrRetentionDays} + + Retention window: {database.pitrRetentionDays} days + + {/if} + + {:else} + + PITR is enabled but no recovery points are available yet. Recovery points + will appear after the first WAL archive is created. + + {/if} + + + {#if pitrWindows} + + {/if} + + + {/if} + + + + + (activeTab = 'backups')} + active={activeTab === 'backups'}> + Backups ({backups.total}) + + (activeTab = 'restorations')} + active={activeTab === 'restorations'}> + Restorations ({restorations.total}) + + + + {#if activeTab === 'backups'} + {#if isLoadingBackups} +
+ Loading backups... +
+ {:else if backups.total === 0} +
+ No backups yet. Create a manual backup or wait for the scheduled backup. +
+ {:else} + + + ID + Type + Status + Size + Started + Completed + Expires + + + {#each backups.backups as backup} + + + + {backup.$id.substring(0, 8)}... + + + + {formatBackupType(backup.type)} + + + + + + {backup.sizeBytes ? calculateSize(backup.sizeBytes) : '-'} + + + {formatTimestamp(backup.startedAt)} + + + {formatTimestamp(backup.completedAt)} + + + {formatTimestamp(backup.expiresAt)} + + + + + + + {#if backup.status === 'completed' || backup.status === 'verified'} + { + toggle(e); + restoreBackup = backup; + showRestoreConfirm = true; + }}> + Restore + + {/if} + { + toggle(e); + selectedBackup = backup; + showDeleteConfirm = true; + }}> + Delete + + + + + + + {/each} + + {/if} + {:else} + {#if isLoadingRestorations} +
+ Loading restorations... +
+ {:else if restorations.total === 0} +
+ No restorations yet. +
+ {:else} + + + ID + Type + Status + Backup ID + Target Time + Started + Completed + + {#each restorations.restorations as restoration} + + + + {restoration.$id.substring(0, 8)}... + + + + {restoration.type === 'pitr' ? 'Point-in-Time' : 'Backup'} + + + + + + {restoration.backupId + ? restoration.backupId.substring(0, 8) + '...' + : '-'} + + + {restoration.targetTime + ? formatTimestamp(restoration.targetTime) + : '-'} + + + {formatTimestamp(restoration.startedAt)} + + + {formatTimestamp(restoration.completedAt)} + + + {/each} + + {/if} + {/if} +
+
+ + + + + Are you sure you want to delete this backup? This action is irreversible. + + {#if selectedBackup?.error} + + {selectedBackup.error} + + {/if} + + + + + + + This will restore your database from the selected backup. Your database will be + unavailable during the restoration process. + + {#if restoreBackup} + + + + Backup ID + + {restoreBackup.$id} + + + + Type + + + {formatBackupType(restoreBackup.type)} + + + + + Size + + + {restoreBackup.sizeBytes + ? calculateSize(restoreBackup.sizeBytes) + : '-'} + + + + + Created + + + {toLocaleDateTime(restoreBackup.$createdAt)} + + + + {/if} + + The database will enter a restoring state and will be unavailable until the + restoration completes. + + + + + + + + + + + + + Select a target date and time to restore your database to. The target must be within + the available recovery window. + + {#if pitrWindows} + + + + Earliest + + + {formatPitrTime(pitrWindows.earliest)} + + + + + Latest + + + {formatPitrTime(pitrWindows.latest)} + + + + {/if} + + + Target Date and Time + + + + + The database will enter a restoring state and will be unavailable until the + restoration completes. All data after the selected point in time will be lost. + + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/policyPresets.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/policyPresets.svelte new file mode 100644 index 0000000000..39171ab629 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/policyPresets.svelte @@ -0,0 +1,32 @@ + + + + {#each policies as policy} + + {policy.description} + + {/each} + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/store.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/store.ts index 43cc2552d5..55a48f9263 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/store.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/store.ts @@ -5,8 +5,21 @@ import type { UserBackupPolicy } from '$lib/helpers/backups'; export const showCreatePolicy = writable(false); export const showCreateBackup = writable(false); +export const dailyPolicy: UserBackupPolicy = { + id: 'daily', + label: 'Daily', + retained: 7, + default: true, + checked: false, + schedule: '{time} * * *', + selectedTime: '00:00', + plainTextFrequency: 'daily', + description: 'Runs every day and is retained for 7 days' +}; + export const presetPolicies = writable([ { + id: 'hourly', label: 'Hourly', retained: 1, default: true, @@ -17,6 +30,7 @@ export const presetPolicies = writable([ description: 'Runs every hour and is retained for 24 hours' }, { + id: 'daily', label: 'Daily', retained: 7, default: true, @@ -25,6 +39,13 @@ export const presetPolicies = writable([ selectedTime: '00:00', plainTextFrequency: 'daily', description: 'Runs every day and is retained for 7 days' + }, + { + id: 'none', + label: 'No backup', + retained: null, + default: false, + description: 'Skip backups. You can change this later' } ]); diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/breadcrumbs.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/breadcrumbs.svelte index 5cddf0e8b3..e5e9b14702 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/breadcrumbs.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/breadcrumbs.svelte @@ -4,34 +4,36 @@ import type { Models } from '@appwrite.io/console'; import { resolveRoute } from '$lib/stores/navigation'; - const params = $derived(page.params); - const project = $derived(page.data.project); - const database = $derived(page.data.database); - const organization = $derived(page.data.organization) as Models.Organization; + const breadcrumbs = $derived.by(() => { + const params = page.params; + const project = page.data.project; + const database = page.data.database; + const organization = page.data.organization as Organization; - const breadcrumbs = $derived([ - { - href: resolveRoute('/(console)/organization-[organization]', { - organization: organization?.$id ?? project.teamId - }), - title: organization.name - }, - { - href: resolveRoute('/(console)/project-[region]-[project]', params), - title: project.name - }, - { - href: resolveRoute('/(console)/project-[region]-[project]/databases', params), - title: 'Databases' - }, - { - href: resolveRoute( - '/(console)/project-[region]-[project]/databases/database-[database]', - params - ), - title: database.name - } - ]); + return [ + { + href: resolveRoute('/(console)/organization-[organization]', { + organization: organization?.$id ?? project.teamId + }), + title: organization.name + }, + { + href: resolveRoute('/(console)/project-[region]-[project]', params), + title: project.name + }, + { + href: resolveRoute('/(console)/project-[region]-[project]/databases', params), + title: 'Databases' + }, + { + href: resolveRoute( + '/(console)/project-[region]-[project]/databases/database-[database]', + params + ), + title: database.name + } + ]; + }); diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/extensions/duplicates.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/extensions/duplicates.ts new file mode 100644 index 0000000000..70e770ba3a --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/extensions/duplicates.ts @@ -0,0 +1,102 @@ +import { json5ParseCache } from 'codemirror-json5'; +import { ensureSyntaxTree } from '@codemirror/language'; +import { linter, type Diagnostic } from '@codemirror/lint'; +import type { SyntaxNode, TreeCursor } from '@lezer/common'; +import type { EditorState, Extension } from '@codemirror/state'; + +type Options = { + delay?: number; + timeBudgetMs?: number; + maxDocLength?: number; +}; + +/** Time budget for duplicate detection in milliseconds. 200ms balances responsiveness with thoroughness. */ +const DEFAULT_TIME_BUDGET_MS = 200; +/** Check time budget every N nodes. 200 balances accuracy with overhead of time checks. */ +const CHECK_BUDGET_EVERY = 200; + +function normalizeKey(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.length < 2) return trimmed; + const first = trimmed[0]; + const last = trimmed[trimmed.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function nowMs(): number { + return typeof performance !== 'undefined' ? performance.now() : Date.now(); +} + +function readPropertyName( + node: SyntaxNode, + state: EditorState +): { key: string; from: number; to: number } | null { + const propName = node.getChild('PropertyName'); + if (!propName) return null; + const raw = state.doc.sliceString(propName.from, propName.to); + return { key: normalizeKey(raw), from: propName.from, to: propName.to }; +} + +function collectDuplicateKeys( + state: EditorState, + cursor: TreeCursor, + deadlineMs: number +): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + const walker = cursor; + let visited = 0; + + do { + if (visited % CHECK_BUDGET_EVERY === 0 && nowMs() > deadlineMs) { + break; + } + visited += 1; + if (walker.name === 'Object') { + const objectNode = walker.node; + const objectCursor = objectNode.cursor(); + const seen = new Map(); + if (objectCursor.firstChild()) { + do { + if (objectCursor.name === 'Property') { + const keyInfo = readPropertyName(objectCursor.node, state); + if (!keyInfo || !keyInfo.key) continue; + const previous = seen.get(keyInfo.key); + if (previous) { + diagnostics.push({ + from: keyInfo.from, + to: keyInfo.to, + severity: 'warning', + message: `Duplicate key "${keyInfo.key}"` + }); + } else { + seen.set(keyInfo.key, { from: keyInfo.from, to: keyInfo.to }); + } + } + } while (objectCursor.nextSibling()); + } + } + } while (walker.next()); + + return diagnostics; +} + +export function createDuplicateKeyLinter(options: Options = {}): Extension { + const timeBudgetMs = options.timeBudgetMs ?? DEFAULT_TIME_BUDGET_MS; + return linter( + (view) => { + if (options.maxDocLength && view.state.doc.length > options.maxDocLength) { + return []; + } + const parseCache = view.state.field(json5ParseCache, false); + if (parseCache?.err) return []; + const tree = ensureSyntaxTree(view.state, view.state.doc.length, timeBudgetMs); + if (!tree) return []; + const deadlineMs = nowMs() + timeBudgetMs; + return collectDuplicateKeys(view.state, tree.cursor(), deadlineMs); + }, + { delay: options.delay } + ); +} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/extensions/highlighting.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/extensions/highlighting.ts new file mode 100644 index 0000000000..b0ae5e32e6 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/extensions/highlighting.ts @@ -0,0 +1,111 @@ +import { + Decoration, + type DecorationSet, + EditorView, + type ViewUpdate, + ViewPlugin +} from '@codemirror/view'; +import { Range, type Extension } from '@codemirror/state'; +import { NESTED_KEY_REGEX } from '../helpers/constants'; + +// ViewPlugin to highlight nested keys (4+ spaces) only in visible ranges +export function createNestedKeyPlugin(): Extension { + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = this.compute(view); + } + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = this.compute(update.view); + } + } + compute(view: EditorView): DecorationSet { + const decos: Range[] = []; + for (const { from, to } of view.visibleRanges) { + let line = view.state.doc.lineAt(from); + while (line.from <= to) { + const text = line.text; + const m = text.match(NESTED_KEY_REGEX); + if (m) { + const leading = m[1]; + const key = m[2]; + const start = line.from + leading.length; + const end = start + key.length; + decos.push( + Decoration.mark({ class: 'cm-nestedPropertyName' }).range( + start, + end + ) + ); + } + if (line.to >= to) break; + line = view.state.doc.line(line.number + 1); + } + } + return Decoration.set(decos); + } + }, + { decorations: (v) => v.decorations } + ); +} + +// ViewPlugin to apply muted styling to system fields ($id, $createdAt, $updatedAt) +export function createSystemFieldStylePlugin(getShouldStyle: () => boolean): Extension { + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = this.compute(view); + } + update(update: ViewUpdate) { + // Only recompute when document changes + if (update.docChanged) { + this.decorations = this.compute(update.view); + } + } + compute(view: EditorView): DecorationSet { + const shouldStyle = getShouldStyle(); + + if (!shouldStyle) { + return Decoration.none; + } + + const doc = view.state.doc; + const text = doc.toString(); + const systemFields = ['$id', '$createdAt', '$updatedAt']; + const decos: Range[] = []; + + // Find all occurrences of system field keys + for (const field of systemFields) { + // Match the key in format: "$id": or $id: (with or without quotes) + const quotedPattern = new RegExp(`"${field.replace('$', '\\$')}"\\s*:`, 'g'); + const unquotedPattern = new RegExp(`${field.replace('$', '\\$')}\\s*:`, 'g'); + + let match: RegExpExecArray; + // Check quoted format + while ((match = quotedPattern.exec(text)) !== null) { + const from = match.index; + const to = from + field.length + 2; // +2 for quotes + decos.push( + Decoration.mark({ class: 'cm-system-field-muted' }).range(from, to) + ); + } + + // Check unquoted format + while ((match = unquotedPattern.exec(text)) !== null) { + const from = match.index; + const to = from + field.length; + decos.push( + Decoration.mark({ class: 'cm-system-field-muted' }).range(from, to) + ); + } + } + + return Decoration.set(decos); + } + }, + { decorations: (v) => v.decorations } + ); +} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/extensions/index.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/extensions/index.ts new file mode 100644 index 0000000000..f92fe28e5d --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/extensions/index.ts @@ -0,0 +1,9 @@ +export { + findReadOnlyRanges, + createReadOnlyRangesField, + createReadOnlyLineField, + createReadOnlyRangesFilter +} from './readonly'; + +export { createDuplicateKeyLinter } from './duplicates'; +export { createNestedKeyPlugin, createSystemFieldStylePlugin } from './highlighting'; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/extensions/readonly.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/extensions/readonly.ts new file mode 100644 index 0000000000..3ddb717bd2 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/extensions/readonly.ts @@ -0,0 +1,123 @@ +import { Decoration, type DecorationSet, EditorView } from '@codemirror/view'; +import { StateField, Transaction, Range } from '@codemirror/state'; +import type { Text } from '@codemirror/state'; +import { SYSTEM_KEYS } from '../helpers/constants'; + +// Find ranges of system keys (lines starting with $id, $createdAt, $updatedAt) +// When isNew=true, skip all readonly range detection since we don't have timestamps yet +export function findReadOnlyRanges(doc: Text, isNew: boolean): Array<{ from: number; to: number }> { + // When creating a new document, allow editing everything + if (isNew) return []; + + const ranges: Array<{ from: number; to: number }> = []; + let found = 0; + + for (let i = 1; i <= doc.lines; i++) { + const line = doc.line(i); + const lineText = line.text.trim(); + for (const key of SYSTEM_KEYS) { + if (lineText.startsWith(key)) { + ranges.push({ from: line.from, to: line.to }); + found++; + break; + } + } + if (found === SYSTEM_KEYS.size) break; + } + + return ranges; +} + +// Ranges field for read-only system lines (single source of truth) +export const createReadOnlyRangesField = (isNew: boolean) => + StateField.define>({ + create(state) { + return findReadOnlyRanges(state.doc, isNew); + }, + update(value, tr) { + if (!tr.docChanged) return value; + return findReadOnlyRanges(tr.state.doc, isNew); + } + }); + +// State field to add decorations to read-only lines +export const createReadOnlyLineField = ( + readOnlyRangesField: StateField> +) => + StateField.define({ + create(state) { + const decorations: Range[] = []; + const readOnlyRanges = state.field(readOnlyRangesField); + + for (const range of readOnlyRanges) { + decorations.push( + Decoration.line({ + class: 'cm-readOnlyLine' + }).range(range.from) + ); + } + + return Decoration.set(decorations); + }, + update(decorations, tr) { + if (!tr.docChanged) return decorations; + + const newDecorations: Range[] = []; + const readOnlyRanges = tr.state.field(readOnlyRangesField); + + for (const range of readOnlyRanges) { + newDecorations.push( + Decoration.line({ + class: 'cm-readOnlyLine' + }).range(range.from) + ); + } + + return Decoration.set(newDecorations); + }, + provide: (f) => EditorView.decorations.from(f) + }); + +// Transaction filter to prevent edits on system key lines +export const createReadOnlyRangesFilter = ( + readOnlyRangesField: StateField>, + readonly: boolean +) => { + return (tr: Transaction) => { + if (readonly || !tr.docChanged) return tr; + const ue = tr.annotation(Transaction.userEvent); + if (typeof ue === 'string' && ue.startsWith('appwrite:')) { + return tr; + } + + const startDoc = tr.startState.doc; + const readOnlyRanges = tr.startState.field(readOnlyRangesField); + let blocked = false; + let fullReplace = false; + + tr.changes.iterChanges((fromA: number, toA: number) => { + // Allow full-document replacement (Select All → Paste) + if (fromA === 0 && toA === startDoc.length) { + fullReplace = true; + return; + } + + // Check if change overlaps with any read-only range + for (const range of readOnlyRanges) { + if ( + // treat line ranges as half-open [from, to) + (fromA >= range.from && fromA < range.to) || + (toA > range.from && toA < range.to) || + (fromA < range.from && toA > range.to) + ) { + blocked = true; + break; + } + } + }); + + if (fullReplace) return tr; + // Block the transaction if it tries to edit a read-only range + return blocked ? [] : tr; + }; +}; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/helpers/constants.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/helpers/constants.ts new file mode 100644 index 0000000000..2d38cf3b66 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/helpers/constants.ts @@ -0,0 +1,39 @@ +// system configuration constants +export const ALLOWED_DOLLAR_PROPS = ['$id', '$createdAt', '$updatedAt'] as const; +export const SYSTEM_KEYS = new Set(['$id:', '$createdAt:', '$updatedAt:']); + +// timing constants +export const LINTER_DELAY = 250; +export const DEBOUNCE_DELAY = 200; +export const AUTOSAVE_DELAY = 2000; +export const SUGGESTIONS_HIDE_DELAY = 3000; + +// regex patterns +/* export const UNQUOTED_KEY_REGEX = /([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g; */ +export const INDENT_REGEX = /^[\t ]*/; +export const SCALAR_VALUE_REGEX = + /:\s*(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*$/; +export const TRAILING_COMMA_REGEX = /,\s*$/; +export const WHITESPACE_REGEX = /^\s*/; +export const WHITESPACE_ONLY_REGEX = /^\s+$/; +export const NESTED_KEY_REGEX = /^(\s{4,})([A-Za-z_$][A-Za-z0-9_$]*)\s*:/; + +// pre-computed indent strings for performance (0-20 levels of nesting) +export const INDENT_CACHE = Array.from({ length: 21 }, (_, i) => ' '.repeat(i)); + +// helper to get cached indent string +export function getIndent(level: number): string { + return level < INDENT_CACHE.length ? INDENT_CACHE[level] : ' '.repeat(level); +} + +// skeleton line configuration for loading state +const buildLines = (indent: number, ...widths: number[]) => + widths.map((w) => ({ indent, width: w })); + +export const SKELETON_LINES = [ + ...buildLines(0, 8), // opening bracket + ...buildLines(16, 172, 150, 193, 100, 287, 186, 64), // outer items + ...buildLines(32, 141, 91, 105, 112), // nested items + ...buildLines(16, 8, 236, 236), // more outer items + ...buildLines(0, 8) // closing bracket +]; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/helpers/keymaps.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/helpers/keymaps.ts new file mode 100644 index 0000000000..8cc7695ae1 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/helpers/keymaps.ts @@ -0,0 +1,93 @@ +/*import { searchKeymap } from '@codemirror/search';*/ +import { closeBracketsKeymap } from '@codemirror/autocomplete'; +import type { EditorView, KeyBinding } from '@codemirror/view'; +import { EditorSelection, Transaction } from '@codemirror/state'; +import { defaultKeymap, historyKeymap, indentLess, indentMore } from '@codemirror/commands'; + +function duplicateSelectionOrLine(view: EditorView): boolean { + const state = view.state; + + const transaction = state.changeByRange((range) => { + if (range.empty) { + const line = state.doc.lineAt(range.head); + const column = range.head - line.from; + const insertText = `\n${line.text}`; + const insertPos = line.to; + const newPos = insertPos + 1 + column; + + return { + changes: { from: insertPos, insert: insertText }, + range: EditorSelection.cursor(newPos) + }; + } + + const insertText = state.doc.sliceString(range.from, range.to); + const insertPos = range.to; + const newFrom = range.from + insertText.length; + const newTo = range.to + insertText.length; + + return { + changes: { from: insertPos, insert: insertText }, + range: EditorSelection.range(newFrom, newTo) + }; + }); + + view.dispatch({ + ...transaction, + annotations: Transaction.userEvent.of('input') + }); + return true; +} + +function createDuplicateLineKeymap(): KeyBinding { + return { + key: 'Mod-d', + preventDefault: true, + run: duplicateSelectionOrLine + }; +} + +// main editor keymaps, +// these require functions from the component +export function createEditorKeymaps( + insertNewlineKeepIndent: (view: EditorView) => boolean, + onSave?: () => Promise | void +): KeyBinding[] { + const keymaps: KeyBinding[] = [ + { key: 'Tab', run: indentMore }, + { key: 'Enter', run: insertNewlineKeepIndent }, + { key: 'Shift-Enter', run: insertNewlineKeepIndent }, + { key: 'Shift-Tab', run: indentLess } + ]; + + // Add Cmd/Ctrl+S save shortcut if save handler is provided + if (onSave) { + keymaps.push({ + key: 'Mod-s', + preventDefault: true, + run: () => { + onSave(); + return true; + } + }); + } + + // Disable search/replace for now! + keymaps.push({ + key: 'Mod-f', + preventDefault: true, + run: () => true + }); + + keymaps.push(createDuplicateLineKeymap()); + + return keymaps; +} + +// Secondary keymaps - these are standard CodeMirror keymaps +export const secondaryKeymaps = [ + ...closeBracketsKeymap, + ...defaultKeymap, + /*...searchKeymap,*/ + ...historyKeymap +]; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/helpers/theme.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/helpers/theme.ts new file mode 100644 index 0000000000..533dcca5a2 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/helpers/theme.ts @@ -0,0 +1,46 @@ +import { EditorView } from '@codemirror/view'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { tags } from '@lezer/highlight'; + +// custom theme for layout only (colors handled by SCSS) +export const customTheme = EditorView.theme({ + '&': { + height: '100%', + fontFamily: 'var(--font-family-code)', + fontSize: '14px', + lineHeight: '1.6' + }, + '.cm-content': { + fontFamily: 'var(--font-family-code)', + padding: 'var(--space-4) 0' + }, + '.cm-gutters': { + border: 'none', + minWidth: '40px' + }, + '.cm-lineNumbers .cm-gutterElement': { + textAlign: 'right', + minWidth: '40px', + paddingRight: 'var(--space-4)' + }, + '.cm-line': { + padding: '0', + lineHeight: '1.6' + } +}); + +// syntax highlighting style (colors applied via SCSS) +export const customHighlight = HighlightStyle.define([ + { tag: tags.propertyName, class: 'cm-propertyName' }, + { tag: tags.string, class: 'cm-string' }, + { tag: tags.special(tags.string), class: 'cm-string' }, + { tag: tags.escape, class: 'cm-string' }, + { tag: tags.number, class: 'cm-number' }, + { tag: tags.bool, class: 'cm-bool' }, + { tag: tags.null, class: 'cm-null' }, + { tag: tags.punctuation, class: 'cm-punctuation' }, + { tag: tags.bracket, class: 'cm-bracket' } +]); + +// pre-configured syntax highlighting extension +export const customSyntaxHighlighting = syntaxHighlighting(customHighlight); diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/index.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/index.ts new file mode 100644 index 0000000000..488726fdb3 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/index.ts @@ -0,0 +1,6 @@ +export { + default as NoSqlEditor, + type JsonValue, + type JsonArray, + type JsonObject +} from './view.svelte'; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/view.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/view.svelte new file mode 100644 index 0000000000..4d6c689806 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/view.svelte @@ -0,0 +1,1661 @@ + + + + +
+ {#if showHeaderActions} +
+ {#if loading} + + {/if} + + + {#if documentId && !loading} +
+ {truncateId(documentId)} +
+ {/if} +
+ + {#if documentId} + + {#if isNew && onCancel} + + + + Cancel + + {/if} + + + + + {tooltipMessage} + + + {/if} +
+ {/if} + + {#if loading} +
+
+ {#each Array.from({ length: $isSmallViewport ? 14 : 16 }) as _, index (index)} +
{index + 1}
+ {/each} +
+
+ {#each SKELETON_LINES as line} +
0 ? `margin-inline-start: ${line.indent}px;` : ''}> + +
+ {/each} +
+
+ {/if} + +
+ + {#if showSuggestions && hasStartedEditing} +
+ +
+ {/if} +
+ +{#if !loading && (errorMessage || warningMessage)} + +{/if} + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/inputs/displayName.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/inputs/displayName.svelte new file mode 100644 index 0000000000..fed8c0c6ae --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/inputs/displayName.svelte @@ -0,0 +1,76 @@ + + + + + {#key names.length} + + {/key} + + + ID, createdAt, and updatedAt are always included and cannot be modified + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/sonners/error-bar.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/sonners/error-bar.svelte new file mode 100644 index 0000000000..fee93e586a --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/sonners/error-bar.svelte @@ -0,0 +1,63 @@ + + +{#if message} +
+ + + + + +
+ {message} +
+
+
+
+
+{/if} + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/sonners/icons/CheckCircleDuotone.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/sonners/icons/CheckCircleDuotone.svelte new file mode 100644 index 0000000000..c70f7f4178 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/sonners/icons/CheckCircleDuotone.svelte @@ -0,0 +1,19 @@ + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/sonners/index.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/sonners/index.ts new file mode 100644 index 0000000000..138a51a54d --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/sonners/index.ts @@ -0,0 +1,3 @@ +export { default as Save } from './save.svelte'; +export { default as Error } from './error-bar.svelte'; +export { default as Suggestions } from './suggestions.svelte'; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/sonners/save.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/sonners/save.svelte new file mode 100644 index 0000000000..b1094ade64 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/sonners/save.svelte @@ -0,0 +1,96 @@ + + +{#if state} +
+ + + + {#if state === 'saving'} + + + Saving changes... + {:else} + + + Changes saved + {/if} + + + + + {#if state === 'saved' && !$isSmallViewport} + + {/if} + + +
+{/if} + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/sonners/suggestions.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/sonners/suggestions.svelte new file mode 100644 index 0000000000..db2ecadc3d --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/sonners/suggestions.svelte @@ -0,0 +1,53 @@ + + +{#if show} +
+ + + + Press + + + + + + + + for suggestions + + + +
+{/if} + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+layout.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+layout.svelte new file mode 100644 index 0000000000..7c37712025 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+layout.svelte @@ -0,0 +1,352 @@ + + + + + + {collection?.name ?? 'Collection'} - Appwrite + + + + + editRecordPermissions?.updatePermissions() + }}> + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+layout.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+layout.ts new file mode 100644 index 0000000000..fefafdd8c9 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+layout.ts @@ -0,0 +1,26 @@ +import Header from './header.svelte'; +import type { LayoutLoad } from './$types'; +import { Dependencies } from '$lib/constants'; +import { Breadcrumbs, useDatabaseSdk, type DatabaseType } from '$database/(entity)'; + +export const load: LayoutLoad = async ({ params, depends, parent }) => { + const { database } = await parent(); + depends(Dependencies.COLLECTION); + + const databaseSdk = useDatabaseSdk( + params.region, + params.project, + database.type as DatabaseType + ); + + const collection = await databaseSdk.getEntity({ + databaseId: params.database, + entityId: params.collection + }); + + return { + collection, + header: Header, + breadcrumbs: Breadcrumbs + }; +}; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+page.svelte new file mode 100644 index 0000000000..de07b6af88 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+page.svelte @@ -0,0 +1,260 @@ + + +{#key page.params.collection} + + + + + +
+ (showCustomColumnsModal = true)} /> +
+ + Columns +
+
+ + + + {#if !$isSmallViewport} + + + + {/if} + + +
+ {#if $isSmallViewport} + + {/if} +
+
+ +
+ {#if data.documents.total || $noSqlDocument.isDirty} + + + + {:else if $hasPageQueries} + + {#snippet actions()} + + {/snippet} + + {:else} + + {#snippet actions()} + { + noSqlDocument.create(buildInitDoc()); + }} /> + + { + $randomDataModalState.show = true; + }} /> + {/snippet} + + {/if} +
+{/key} + +{#if showImportJson} + +{/if} + + { + await columnDisplayNameInput?.updateDisplayNames(); + }}> + + Add up to 5 document fields to display as columns in the table view for easy identification. + + + { + columnsError = null; + showCustomColumnsModal = false; + spreadsheet?.refreshColumns?.(); + }} + onFailure={(error) => { + columnsError = error.message; + }} /> + + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+page.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+page.ts new file mode 100644 index 0000000000..c51e119451 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+page.ts @@ -0,0 +1,37 @@ +import { Dependencies, SPREADSHEET_PAGE_LIMIT } from '$lib/constants'; +import { getLimit, getPage, getQuery, getView, pageToOffset, View } from '$lib/helpers/load'; +import { sdk } from '$lib/stores/sdk'; +import type { PageLoad } from './$types'; +import { queries, queryParamToMap } from '$lib/components/filters'; +import { buildGridQueries, extractSortFromQueries } from '$database/store'; + +export const load: PageLoad = async ({ params, depends, url, route, parent }) => { + const { collection } = await parent(); + depends(Dependencies.DOCUMENTS); + + const page = getPage(url); + const limit = getLimit(url, route, SPREADSHEET_PAGE_LIMIT); + const view = getView(url, route, View.Grid); + const offset = pageToOffset(page, limit); + const query = getQuery(url); + + const paramQueries = url.searchParams.get('query'); + const parsedQueries = queryParamToMap(paramQueries || '[]'); + queries.set(parsedQueries); + + const currentSort = extractSortFromQueries(parsedQueries); + + return { + offset, + limit, + view, + query, + currentSort, + parsedQueries, + documents: await sdk.forProject(params.region, params.project).documentsDB.listDocuments({ + databaseId: params.database, + collectionId: params.collection, + queries: buildGridQueries(limit, offset, parsedQueries, collection) + }) + }; +}; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/activity/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/activity/+page.svelte new file mode 100644 index 0000000000..849d38953b --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/activity/+page.svelte @@ -0,0 +1,14 @@ + + +
+ +
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/activity/+page.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/activity/+page.ts new file mode 100644 index 0000000000..c7187a963f --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/activity/+page.ts @@ -0,0 +1,21 @@ +import { sdk } from '$lib/stores/sdk'; +import type { PageLoad } from './$types'; +import { PAGE_LIMIT } from '$lib/constants'; +import { Query } from '@appwrite.io/console'; +import { getLimit, getPage, pageToOffset } from '$lib/helpers/load'; + +export const load: PageLoad = async ({ params, url, route }) => { + const page = getPage(url); + const limit = getLimit(url, route, PAGE_LIMIT); + const offset = pageToOffset(page, limit); + + return { + offset, + limit, + logs: await sdk.forProject(params.region, params.project).documentsDB.listCollectionLogs({ + databaseId: params.database, + collectionId: params.collection, + queries: [Query.limit(limit), Query.offset(offset)] + }) + }; +}; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/header.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/header.svelte new file mode 100644 index 0000000000..bfeef04331 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/header.svelte @@ -0,0 +1,54 @@ + + +{#if collection} +
+{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/indexes/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/indexes/+page.svelte new file mode 100644 index 0000000000..4e88363711 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/indexes/+page.svelte @@ -0,0 +1,61 @@ + + + + {#snippet emptyIndexesSheetView(toggle)} + + {#snippet actions()} + + {/snippet} + + {/snippet} + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/settings/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/settings/+page.svelte new file mode 100644 index 0000000000..0b5f628e87 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/settings/+page.svelte @@ -0,0 +1,68 @@ + + +
+ + updateCollection({ enabled })} /> + + updateCollection({ name })} /> + + + + updateCollection({ permissions })} /> + + updateCollection({ documentSecurity })} /> + + + +
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/settings/displayName.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/settings/displayName.svelte new file mode 100644 index 0000000000..d6a66baaa2 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/settings/displayName.svelte @@ -0,0 +1,52 @@ + + +
{ + await columnDisplayNameInput?.updateDisplayNames(); + }}> + + Custom columns + Add up to 5 document fields to display as columns in the collection view. + + + { + await invalidate(Dependencies.TEAM); + addNotification({ + message: 'Display names have been updated', + type: 'success' + }); + trackEvent(Submit.CollectionUpdateDisplayNames); + }} + onFailure={(error) => { + addNotification({ + message: error.message, + type: 'error' + }); + trackError(error, Submit.CollectionUpdateDisplayNames); + }} /> + + + + + + +
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/spreadsheet.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/spreadsheet.svelte new file mode 100644 index 0000000000..907d719426 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/spreadsheet.svelte @@ -0,0 +1,920 @@ + + + + + { + await createOrUpdateDocument($noSqlDocument.document); + return true; + } + } + }} + sideSheetStateCallbacks={{ + onClose() { + noSqlDocument.reset(); + } + }}> + {#key $spreadsheetRenderKey} + { + // (showDocumentCreateSheet.show = true) + }} + bind:currentPage + nextPageTriggerOffset={2} + jumpToPageNumber={jumpToPageReactive} + loadingMore={$paginatedDocumentsLoading} + itemsPerPage={SPREADSHEET_PAGE_LIMIT} + loadNextPage={loadPage} + loadPreviousPage={loadPage} + goToPage={handleGoToPage} + bottomActionTooltip={{ + text: 'Create row', + placement: 'top-end' + }}> + + {#each $collectionColumns as column (column.id)} + + {#if !column.isAction} + + + {column.title} + + + + + {/if} + + {/each} + + + + {@const document = $paginatedDocuments.getItemAtVirtualIndex(index)} + {#if document === null} + + {#each $collectionColumns as col} + + {/each} + + {:else} + {@const selection = + $noSqlDocument.isDirty && document.$id === $noSqlDocument.document?.$id + ? 'disabled' + : rowSelection} + {@const isUnsavedRow = + $noSqlDocument.isNew && + $noSqlDocument.isDirty && + document.$id === $noSqlDocument.document?.$id} + + {/if} + + + + + + + + {selectedDocuments.length + ? `${selectedDocuments.length} document${selectedDocuments.length === 1 ? '' : 's'} selected` + : `${formatNumberWithCommas($documents.total)} document${$documents.total === 1 ? '' : 's'}`} + + + +
+
+ + + Page + + ({ + label: `${i + 1}`, + value: i + 1 + }))} + on:change={(e) => (jumpToPageReactive = Number(e.detail))} /> + + + out of {totalPages} + + +
+ + {#if !$isSmallViewport} +
+ { + $randomDataModalState.show = true; + }}>Generate sample data +
+ {/if} +
+
+
+ {/key} + + {#snippet noSqlEditor()} + { + const firstDocument = $documents?.documents?.[0]; + if (firstDocument) { + noSqlDocument.edit(firstDocument); + } else { + noSqlDocument.reset({ show: false }); + } + }} + onSave={async (document) => await createOrUpdateDocument(document)} + onChange={(_, hasDataChanged) => noSqlDocument.update({ hasDataChanged })} /> + {/snippet} + + {#snippet sideSheetHeaderAction()} + { + await copy(JSON.stringify($noSqlDocument.document, null, 2)); + addNotification({ + type: 'success', + message: 'Document copied', + timeout: 1250 + }); + }}> + + + {/snippet} + + {#if selectedDocuments.length > 0} +
+ + +
+ + + + {selectedDocuments.length > 1 ? 'documents' : 'document'} + selected + + +
+
+ + (selectedDocuments = [])} + >Cancel + (showDelete = true)} + >Delete + +
+
+ {/if} +
+ + + {@const isSingle = selectedDocumentForDelete !== null} + +

+ {#if isSingle} + Are you sure you want to delete this document from {collection.name}? + {:else} + Are you sure you want to delete {selectedDocuments.length} + {selectedDocuments.length > 1 ? 'documents' : 'document'} from {collection.name}? + {/if} +

+ +

This action is irreversible.

+
+ + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/store.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/store.ts new file mode 100644 index 0000000000..ba5b65feff --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/store.ts @@ -0,0 +1,101 @@ +import { page } from '$app/stores'; +import type { Column } from '$lib/helpers/types'; +import type { SortState } from '$database/store'; +import { derived, writable } from 'svelte/store'; +import type { Models } from '@appwrite.io/console'; +import { SPREADSHEET_PAGE_LIMIT } from '$lib/constants'; +import { createSparsePagedDataStore } from '@appwrite.io/pink-svelte'; + +export const indexes = derived(page, ($page) => $page.data.collection.indexes as Models.Index[]); + +export const collectionColumns = writable([]); +export const isCollectionsJsonImportInProgress = writable(false); + +export const paginatedDocumentsLoading = writable(false); +export const paginatedDocuments = + createSparsePagedDataStore(SPREADSHEET_PAGE_LIMIT); + +export const sortState = writable({ + column: null, + direction: 'default' +}); + +export type NoSqlDocumentState = { + show: boolean; + document?: Models.Document | (object & { $id?: string }); + isNew?: boolean; + loading?: boolean; + documentId?: string /* for loading from a given id */; + hasDataChanged?: boolean; + isDirty?: boolean; + isSaving?: boolean; +}; + +const createNoSqlDocumentStore = () => { + const { + subscribe, + set, + update: baseUpdate + } = writable({ + show: false, + document: null, + isNew: false, + loading: false, + documentId: null, + hasDataChanged: false, + isDirty: false, + isSaving: false + }); + + return { + subscribe, + set, + reset: (config?: { show?: boolean }) => + set({ + show: config?.show ?? false, + document: null, + isNew: false, + loading: false, + documentId: null, + hasDataChanged: false, + isDirty: false + // isSaving: false + }), + create: (document: Models.Document | (object & { $id?: string })) => + set({ + show: true, + document, + isNew: true, + loading: false, + documentId: null, + hasDataChanged: false, + isDirty: true, + isSaving: false + }), + edit: (document: Models.Document, documentId?: string) => + set({ + show: true, + document, + isNew: false, + loading: false, + documentId: documentId ?? null, + hasDataChanged: false, + isDirty: false, + isSaving: false + }), + update: (partial: Partial) => + baseUpdate((state) => ({ ...state, ...partial })) + }; +}; + +export const noSqlDocument = createNoSqlDocumentStore(); + +export const documentPermissionSheet = writable({ + show: false, + document: null as Models.Document +}); + +export const documentActivitySheet = writable({ + show: false, + document: null as Models.Document +}); diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/usage/[[period]]/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/usage/[[period]]/+page.svelte new file mode 100644 index 0000000000..472f1e4f77 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/usage/[[period]]/+page.svelte @@ -0,0 +1,11 @@ + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/usage/[[period]]/+page.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/usage/[[period]]/+page.ts new file mode 100644 index 0000000000..6b9caf5ba9 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/usage/[[period]]/+page.ts @@ -0,0 +1,16 @@ +import { isValueOfStringEnum } from '$lib/helpers/types'; +import { sdk } from '$lib/stores/sdk'; +import { UsageRange } from '@appwrite.io/console'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ params }) => { + const period = isValueOfStringEnum(UsageRange, params.period) + ? params.period + : UsageRange.ThirtyDays; + + return sdk.forProject(params.region, params.project).documentsDB.getCollectionUsage({ + databaseId: params.database, + collectionId: params.collection, + range: period + }); +}; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/connectModal.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/connectModal.svelte new file mode 100644 index 0000000000..72894a0b6f --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/connectModal.svelte @@ -0,0 +1,176 @@ + + + + + + Choose how you want to connect to your {getEngineDisplayName(database.engine)} database. + + + + + Connection String + + Use this URI to connect from your application or database client. + + {#if database.connectionString} + + {/if} + + + + + Terminal Command + + Run this command in your terminal to connect using {getEngineCliName( + database.engine + )}. + + +
+ +
+
+ + + + Quick Reference +
+
+ Host + {database.hostname || '-'} +
+
+ Port + {database.connectionPort || '-'} +
+
+ Database + postgres +
+
+ Username + {database.connectionUser || '-'} +
+
+
+
+ + + + + + + + + + +
+ + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/dedicatedOverview.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/dedicatedOverview.svelte new file mode 100644 index 0000000000..8b8035c846 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/dedicatedOverview.svelte @@ -0,0 +1,1015 @@ + + + + + + Status + + + + + {capitalizeFirst(database.status)} + + + {#if database.containerStatus && !isPrisma} + + Container: {capitalizeFirst(database.containerStatus)} + + {/if} + + + {database.region.toUpperCase()} + + + + {#if database.error} + + {database.error} + + {/if} + + + + Created {toLocaleDateTime(database.$createdAt)} + + + Updated {toLocaleDateTime(database.$updatedAt)} + + + + + + {#if database.containerStatus === 'inactive' && !isPrisma} + + {/if} + {#if isDedicated && isActive && !isPrisma} + + {/if} + {#if isPaused} + + {/if} + {#if isShared && isActive && containerIsRunning && !isPrisma} + + {/if} + + + + + + {#if database.status === 'ready' && hasConnectionDetails} + + Connection + Use these credentials to connect to your database. + + +
+ + (connectionTab = 'direct')} + active={connectionTab === 'direct'}> + Direct Connection + + (connectionTab = 'string')} + active={connectionTab === 'string'}> + Connection String + + + +
+ + {#if connectionTab === 'direct'} + + + + +
+ +
+ + + +
+
+
+ + {#if database.externalIP || database.internalIP} + + {#if database.externalIP} + + {/if} + {#if database.internalIP} + + {/if} + + {/if} + {:else} + + + + + Terminal Command + + + + + {/if} +
+
+
+ {:else if database.status === 'provisioning'} + + Connection + + + + Your database is being set up. Connection details will be available once + provisioning is complete. + + + + + + + + + + {/if} + + + {#if database.type === 'shared'} + + Free Tier Limits + Your shared database runs within the free tier. Resources are constrained to the + limits below. Upgrade to a dedicated database for higher limits. + + + + + Storage + + 1 GB + + + + Max Connections + + 10 + + + + Query Timeout + + 15s + + + + Idle Timeout + + + 15 min + + (scales to zero) + + + + + + + {/if} + + + + Resources + Your database configuration and allocated resources. + + + + + Engine + + + {getEngineDisplayName(database.engine)} {database.version} + + + + + Tier + + + {capitalizeFirst(database.tier)} + + + + + Backend + + + {capitalizeFirst(database.backend)} + + + + + CPU + + {cpuDisplay} + + + + Memory + + {memoryDisplay} + + + + Storage + + {storageDisplay} + + {#if database.storageClass} + + + Storage Class + + + {formatStorageClass(database.storageClass)} + + + {/if} + + + + + + {#if !isPrisma} + + High Availability + Configure replicas and failover settings for your database. + + + + + Status + + + + {#if database.highAvailability} + + + Replicas + + + {database.haReplicaCount} + + + {#if database.haSyncMode} + + + Sync Mode + + + {capitalizeFirst(database.haSyncMode)} + + + {/if} + {/if} + + + + {/if} + + + {#if !isPrisma} + + Network + Connection limits and network configuration. + + + + + + Max Connections + + + {database.networkMaxConnections}{#if tierMaxConnections} + + / {tierMaxConnections.toLocaleString()} (tier limit) + + {/if} + + + + + Idle Timeout + + + {database.networkIdleTimeoutSeconds}s + + + {#if database.idleTimeoutMinutes} + + + Sleep After Idle + + + {database.idleTimeoutMinutes} min + + + {/if} + + + {#if database.networkIPAllowlist?.length > 0} + + + IP Allowlist + + + {#each database.networkIPAllowlist as ip} + + {/each} + + + {/if} + + + + {/if} + + + + Backups + Automatic backup and point-in-time recovery settings. + + + + + Automatic Backups + + + + {#if database.backupEnabled} + + + Point-in-Time Recovery + + + + {#if database.backupPitr && database.pitrRetentionDays} + + ({database.pitrRetentionDays} day window) + + {/if} + + + + + Schedule + + {database.backupCron} + + + + Retention + + + {database.backupRetentionDays} days + + + {/if} + + + + + + + Storage Autoscaling + Automatically expand storage when usage reaches the configured threshold. + + + + + Status + + + + {#if database.storageAutoscaling} + + + Threshold + + + {database.storageAutoscalingThresholdPercent}% + + + + + Max Storage + + + {formatStorage(database.storageAutoscalingMaxGb)} + + + {/if} + + + + + + + Security + Encryption, key management, and audit logging configuration. + + + + + Encryption at Rest + + + + + + Key Management + + + {formatKeyManagement(database.securityKeyManagement)} + + + + + Key Rotation + + + {database.securityKeyRotationDays} days + + + + + Audit Log + + + + {#if database.securityAuditLogEnabled} + + + Log Retention + + + {database.securityLogRetentionDays} days + + + {/if} + + + Data Residency + + + {formatDataResidency(database.securityDataResidency)} + + + + + + + + + Maintenance Window + Scheduled maintenance window and upgrade policy for your database. + + + + + Day + + + {formatMaintenanceDay(database.maintenanceWindowDay)} + + + + + Time + + + {formatHourUtc(database.maintenanceWindowHourUtc)} + + + + + Duration + + + {database.maintenanceWindowDurationMinutes} minutes + + + + + Upgrade Policy + + + {formatUpgradePolicy(database.maintenanceUpgradePolicy)} + + + + + + + + + SQL API + Execute SQL statements directly through the Appwrite API. + + + + + + Status + + + + {#if database.sqlApiEnabled} + + + Max Response Size + + + {formatBytes(database.sqlApiMaxBytes)} + + + + + Max Rows + + + {database.sqlApiMaxRows.toLocaleString()} + + + + + Timeout + + + {database.sqlApiTimeoutSeconds}s + + + {/if} + + + {#if database.sqlApiEnabled && database.sqlApiAllowedStatements?.length > 0} + + + Allowed Statements + + + {#each database.sqlApiAllowedStatements as statement} + + {/each} + + + {/if} + + + + + + {#if database.metricsEnabled} + + Monitoring + Performance monitoring and slow query detection settings. + + + + + Slow Query Threshold + + + {database.metricsSlowQueryLogThresholdMs.toLocaleString()} ms + + + + + Trace Sample Rate + + + {(database.metricsTraceSampleRate * 100).toFixed(0)}% + + + + + + {/if} +
+ + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/header.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/header.svelte index 37671efad9..ce2a7a1092 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/header.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/header.svelte @@ -5,7 +5,8 @@ import { isTabSelected } from '$lib/helpers/load'; import { canWriteDatabases } from '$lib/stores/roles'; import { resolveRoute, withPath } from '$lib/stores/navigation'; - import { useTerminology } from '$database/(entity)'; + import { useTerminology, type DatabaseType } from '$database/(entity)'; + import { isSmallViewport } from '$lib/stores/viewport'; const terminology = useTerminology(page); const baseDatabasePath = resolveRoute( @@ -19,13 +20,21 @@ page.params ); + // Check if this is a dedicated database type + const isDedicatedType = $derived( + (database?.type as DatabaseType) === 'prisma' || + (database?.type as DatabaseType) === 'dedicated' || + (database?.type as DatabaseType) === 'shared' + ); + const tabs = $derived( [ { href: baseDatabasePath, - title: terminology.entity.title.plural, - event: terminology.entity.lower.plural, - hasChildren: true + // For dedicated DBs, show "Overview" instead of Tables/Collections + title: isDedicatedType ? 'Overview' : terminology.entity.title.plural, + event: isDedicatedType ? 'overview' : terminology.entity.lower.plural, + hasChildren: !isDedicatedType }, { href: withPath(baseDatabasePath, '/backups'), @@ -39,6 +48,12 @@ event: 'usage', hasChildren: true }, + { + href: withPath(baseDatabasePath, '/monitoring'), + title: 'Monitoring', + event: 'monitoring', + disabled: !isDedicatedType + }, { href: withPath(baseDatabasePath, '/settings'), event: 'settings', @@ -47,11 +62,13 @@ } ].filter((tab) => !tab.disabled) ); + + const responsiveInlineStart = $derived($isSmallViewport ? '0' : '-2.5rem'); - + {database?.name} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/monitoring/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/monitoring/+page.svelte new file mode 100644 index 0000000000..74517369e5 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/monitoring/+page.svelte @@ -0,0 +1,761 @@ + + +{#if !database} + + + Monitoring is only available for dedicated databases. + + +{:else} + + + + + + (activeSection = 'metrics')} + active={activeSection === 'metrics'}> + Metrics + + (activeSection = 'connections')} + active={activeSection === 'connections'}> + Connections + + (activeSection = 'slowQueries')} + active={activeSection === 'slowQueries'}> + Slow Queries + + (activeSection = 'insights')} + active={activeSection === 'insights'}> + Performance + + (activeSection = 'auditLogs')} + active={activeSection === 'auditLogs'}> + Audit Logs + + + + + + + + {#if activeSection === 'metrics'} + + + + (metricsPeriod = '1h')} + active={metricsPeriod === '1h'}> + 1 Hour + + (metricsPeriod = '24h')} + active={metricsPeriod === '24h'}> + 24 Hours + + (metricsPeriod = '7d')} + active={metricsPeriod === '7d'}> + 7 Days + + (metricsPeriod = '30d')} + active={metricsPeriod === '30d'}> + 30 Days + + + + {#if isLoadingMetrics} + + {#each Array(6) as _} + + + + + {/each} + + {:else if metrics} + + + Resource Utilization + CPU, memory, and storage usage for the selected period. + + + + + CPU Usage + + + {formatPercent(metrics.cpuPercent)} + + + + + Memory Usage + + + {formatPercent(metrics.memoryPercent)} + + {#if metrics.memoryUsedBytes && metrics.memoryMaxBytes} + + {calculateSize(metrics.memoryUsedBytes)} / + {calculateSize(metrics.memoryMaxBytes)} + + {/if} + + + + Storage Used + + + {metrics.storageUsedBytes + ? calculateSize(metrics.storageUsedBytes) + : '-'} + + + + + + + + + Database Activity + Connection count, IOPS, and queries per second. + + + + + Active Connections + + + {formatNumber(metrics.connectionsActive)} + {#if metrics.connectionsMax} + + / {formatNumber(metrics.connectionsMax)} + + {/if} + + + + + IOPS (Read) + + + {formatNumber(metrics.iopsRead)} + + + + + IOPS (Write) + + + {formatNumber(metrics.iopsWrite)} + + + + + Queries per Second + + + {formatNumber(metrics.qps)} + + + + + + {:else} + + Metrics data is not available for this database. Ensure metrics + collection is enabled in the database settings. + + {/if} + + {/if} + + + {#if activeSection === 'connections'} + + + Currently active database connections. + + + {#if isLoadingConnections} + + {#each Array(3) as _} + + {/each} + + {:else if activeConnections.total === 0} +
+ No active connections. +
+ {:else} + + + PID + User + Database + State + Query + Connected + Wait Event + + {#each activeConnections.activeConnections as conn} + + + {conn.pid} + + + {conn.user} + + + {conn.database} + + + + + + + {truncateQuery(conn.query, 80)} + + + + {conn.connectedAt + ? toLocaleDateTime(conn.connectedAt) + : '-'} + + + {conn.waitEvent || '-'} + + + {/each} + + {/if} +
+ {/if} + + + {#if activeSection === 'slowQueries'} + + + Queries that exceeded the slow query threshold + ({database.metricsSlowQueryLogThresholdMs}ms). + + + {#if isLoadingSlowQueries} + + {#each Array(3) as _} + + {/each} + + {:else if slowQueries.total === 0} +
+ No slow queries recorded. +
+ {:else} + + + Query + Duration + Calls + User + Database + + {#each slowQueries.slowQueries as sq, i} + + + + {truncateQuery(sq.query)} + + + + {formatDurationMs(sq.durationMs)} + + + {formatNumber(sq.calls)} + + + {sq.user} + + + {sq.database} + + + {/each} + + {/if} +
+ {/if} + + + {#if activeSection === 'insights'} + + {#if isLoadingInsights} + + {#each Array(3) as _} + + {/each} + + {:else if performanceInsights} + + + Query Summary + Aggregated query performance statistics. + + + + + Total Calls + + + {formatNumber(performanceInsights.totalCalls)} + + + + + Total Time + + + {formatDurationMs(performanceInsights.totalTimeMs)} + + + + + Average Time + + + {formatDurationMs(performanceInsights.avgTimeMs)} + + + + + + + + {#if performanceInsights.topQueries.length > 0} + + + Top Queries by Execution Time + + + + Query + Calls + Total Time + Mean Time + Rows + + {#each performanceInsights.topQueries as tq, i} + + + + {truncateQuery(tq.query)} + + + + {formatNumber(tq.calls)} + + + {formatDurationMs(tq.totalTimeMs)} + + + {formatDurationMs(tq.meanTimeMs)} + + + {formatNumber(tq.rows)} + + + {/each} + + + {/if} + + + {#if performanceInsights.waitEvents.length > 0} + + + Wait Events Analysis + + + + Event + Type + Count + Total Wait + + {#each performanceInsights.waitEvents as we, i} + + + {we.event} + + + {we.type} + + + {formatNumber(we.count)} + + + {formatDurationMs(we.totalWaitMs)} + + + {/each} + + + {/if} + {:else} + + Performance insights data is not available. Ensure metrics collection + is enabled and the database has been active. + + {/if} + + {/if} + + + {#if activeSection === 'auditLogs'} + + + Database audit log entries. + + + {#if !database.securityAuditLogEnabled} + + Audit logging is not enabled for this database. Enable it in the + database settings to start recording audit events. + + {:else if isLoadingAuditLogs} + + {#each Array(3) as _} + + {/each} + + {:else if auditLogs.total === 0} +
+ No audit log entries recorded. +
+ {:else} + + + Timestamp + User + Action + Object + Statement + Client + + {#each auditLogs.auditLogs as log, i} + + + {log.timestamp + ? toLocaleDateTime(log.timestamp) + : '-'} + + + {log.user} + + + + + + {log.object || '-'} + + + + {truncateQuery(log.statement, 80)} + + + + {log.clientAddress || '-'} + + + {/each} + + {/if} +
+ {/if} +
+
+{/if} + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/monitoring/+page.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/monitoring/+page.ts new file mode 100644 index 0000000000..9511c54243 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/monitoring/+page.ts @@ -0,0 +1,13 @@ +import type { PageLoad } from './$types'; +import { Dependencies } from '$lib/constants'; + +export const load: PageLoad = async ({ depends, parent }) => { + depends(Dependencies.DATABASE); + + const { database, dedicatedDatabase } = await parent(); + + return { + database, + dedicatedDatabase + }; +}; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/+page.svelte index 9a09143455..f5e687804e 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/+page.svelte @@ -13,16 +13,48 @@ import Delete from '../delete.svelte'; import { Query } from '@appwrite.io/console'; import { Layout, Skeleton } from '@appwrite.io/pink-svelte'; - import type { PageProps } from './$types'; + import type { DedicatedDatabase } from '$lib/sdk/dedicatedDatabases'; import { getTerminologies } from '$database/(entity)'; + import UpdateName from './updateName.svelte'; + import UpdateTier from './updateTier.svelte'; + import UpdateStorage from './updateStorage.svelte'; + import UpdateNetwork from './updateNetwork.svelte'; + import UpdateMaintenance from './updateMaintenance.svelte'; + import UpdateBackups from './updateBackups.svelte'; + import UpdateAutoscaling from './updateAutoscaling.svelte'; + import UpdatePooler from './updatePooler.svelte'; + import UpdateExtensions from './updateExtensions.svelte'; + import UpdateConnections from './updateConnections.svelte'; + import RotateCredentials from './rotateCredentials.svelte'; + import UpgradeVersion from './upgradeVersion.svelte'; + import UpdateReadReplicas from './updateReadReplicas.svelte'; + import UpdateCrossRegion from './updateCrossRegion.svelte'; + import UpdateHAStatus from './updateHAStatus.svelte'; + import UpdateBackupStorage from './updateBackupStorage.svelte'; + import UpdateSecurity from './updateSecurity.svelte'; + import UpdateSqlApi from './updateSqlApi.svelte'; + import DangerZone from './dangerZone.svelte'; - const { data }: PageProps = $props(); + const data = page.data; const database = $derived(data.database); + const dedicatedDatabase = $derived(data.dedicatedDatabase as DedicatedDatabase | null); + const isDedicatedType = $derived( + dedicatedDatabase !== null && + (database.type === 'prisma' || + database.type === 'dedicated' || + database.type === 'shared') + ); + + const isDedicated = $derived(dedicatedDatabase?.type === 'dedicated'); + const isShared = $derived(dedicatedDatabase?.type === 'shared'); + const isPrisma = $derived(dedicatedDatabase?.backend === 'prisma'); + const isPostgres = $derived(dedicatedDatabase?.engine === 'postgres'); + + // Legacy database fallback state let showDelete = $state(false); let databaseName: string | null = $state(null); - let errorMessage: string = $state('Something went wrong'); let errorType: 'error' | 'warning' | 'success' = $state('error'); let showError: false | 'name' | 'email' | 'password' = $state(false); @@ -35,7 +67,7 @@ async function loadEntityCount() { const { total } = await databaseSdk.listEntities({ - databaseId: database.$id, + databaseId: page.params.database, queries: [Query.limit(1)] }); @@ -70,7 +102,108 @@ } -{#if database} +{#if isDedicatedType && dedicatedDatabase} + + + + {dedicatedDatabase.name} + +
+

Created: {toLocaleDateTime(dedicatedDatabase.$createdAt)}

+

Last updated: {toLocaleDateTime(dedicatedDatabase.$updatedAt)}

+
+
+
+ + + + + + {#if isDedicated} + + {/if} + + + {#if isDedicated || isShared} + + {/if} + + + {#if !isPrisma} + + {/if} + + + {#if isDedicated || isShared} + + {/if} + + + + + + {#if isDedicated || isShared} + + {/if} + + + {#if isPostgres && !isPrisma} + + {/if} + + + {#if isPostgres && !isPrisma} + + {/if} + + + {#if !isPrisma} + + {/if} + + + {#if !isPrisma} + + {/if} + + + {#if isDedicated || isShared} + + {/if} + + + {#if isDedicated} + + {/if} + + + {#if isDedicated} + + {/if} + + + {#if !isPrisma} + + {/if} + + + {#if isDedicated} + + {/if} + + + + + + {#if !isPrisma} + + {/if} + + + +
+{:else if database} + {database.name} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/dangerZone.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/dangerZone.svelte new file mode 100644 index 0000000000..21a21b37c2 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/dangerZone.svelte @@ -0,0 +1,66 @@ + + + + Delete database + The database will be permanently deleted, including all data and backups. This action is + irreversible. + + + + +
{database.name}
+ + + {getEngineDisplayName(database.engine)} {database.version} + + +
+
+

Last updated: {toLocaleDateTime(database.$updatedAt)}

+
+
+ + + + +
+ + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/rotateCredentials.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/rotateCredentials.svelte new file mode 100644 index 0000000000..e4d799f956 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/rotateCredentials.svelte @@ -0,0 +1,90 @@ + + + + Credential rotation + Generate new database credentials. Existing connections using the old credentials will be + terminated. + + + Rotating credentials will invalidate the current username and password. All active + connections will be dropped. Make sure to update your application configuration + immediately after rotation. + + + + + + + + + +

+ Are you sure you want to rotate the credentials for {database.name}? This will + generate a new username and password, and all existing connections will be terminated. +

+ + + + +
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateAutoscaling.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateAutoscaling.svelte new file mode 100644 index 0000000000..79360cc2fc --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateAutoscaling.svelte @@ -0,0 +1,92 @@ + + +
+ + Storage autoscaling + Automatically increase storage when disk usage reaches a threshold. Storage will never + exceed the configured maximum. + +
    + + {#if autoscaling} + + + {/if} +
+
+ + + + +
+
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateBackupStorage.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateBackupStorage.svelte new file mode 100644 index 0000000000..35b733314b --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateBackupStorage.svelte @@ -0,0 +1,277 @@ + + +{#if !isLoading} + {#if isConfigured && config} + + Backup storage + Your database backups are stored on an external storage provider for added durability + and disaster recovery. + +
    +
  • +
    + + + Provider: + + {config.provider === 's3' + ? 'Amazon S3' + : config.provider === 'gcs' + ? 'Google Cloud Storage' + : 'Azure Blob Storage'} + + + + Bucket: + {config.bucket} + + + Region: + {config.region} + + {#if config.prefix} + + Prefix: + {config.prefix} + + {/if} + {#if config.endpoint} + + Endpoint: + {config.endpoint} + + {/if} + +
    +
  • +
+
+ + + + +
+ {:else} +
+ + Backup storage + Configure off-cluster backup storage to store backups on an external cloud provider + for added durability and disaster recovery. + +
    + + + + + + + +
+
+ + + + +
+
+ {/if} + + +

+ Are you sure you want to remove the off-cluster backup storage configuration for + {database.name}? Existing backups in the external storage will not be deleted, + but new backups will no longer be stored externally. +

+ + + + +
+{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateBackups.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateBackups.svelte new file mode 100644 index 0000000000..a385f703c9 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateBackups.svelte @@ -0,0 +1,100 @@ + + +
+ + Backups + Configure automatic backups and point-in-time recovery for your database. + +
    + + {#if backupEnabled} + + + + {/if} +
+
+ + + + +
+
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateConnections.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateConnections.svelte new file mode 100644 index 0000000000..7b0b8d9c1e --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateConnections.svelte @@ -0,0 +1,227 @@ + + +{#if !isLoading} +
+ + Database users + Create and manage database users with specific roles. Each user receives unique credentials + for connecting to the database. + +
    + {#if connections.length > 0} +
  • + + + {#each connections as connection} + + + +
    + {connection.username} +
    + + + {connection.database} + + + + + Created: {toLocaleDateTime( + connection.$createdAt + )} + +
    + +
    +
    + {/each} +
    +
  • + {:else} +
  • +

    No database users created.

    +
  • + {/if} + + + +
+
+ + + + +
+
+ + +

+ Are you sure you want to delete the database user + {connectionToDelete?.username}? Any active connections using this user will be + terminated. +

+ + + + +
+{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateCrossRegion.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateCrossRegion.svelte new file mode 100644 index 0000000000..f885f535f5 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateCrossRegion.svelte @@ -0,0 +1,291 @@ + + +{#if !isLoading} + {#if isEnabled && crossRegionStatus} + + Cross-region failover + Your database has a standby replica in another region for disaster recovery. In the event + of a regional outage, you can trigger a failover to promote the standby to primary. + +
    +
  • + +
    + + + Standby status + + + + Primary: {crossRegionStatus.primaryRegion} + • Standby: {crossRegionStatus.standbyRegion} + + + Lag: {crossRegionStatus.lagSeconds}s + • Last synced: {toLocaleDateTime(crossRegionStatus.lastSyncedAt)} + + +
    +
    +
  • +
+
+ + + + + + + +
+ {:else} +
+ + Cross-region failover + Enable cross-region failover to maintain a standby replica in a different region for + disaster recovery. + +
    + +
+
+ + + + +
+
+ {/if} + + +

+ Are you sure you want to disable cross-region failover for {database.name}? + The standby replica will be removed and your database will no longer have + disaster recovery across regions. +

+ + + + +
+ + +

+ Are you sure you want to trigger a cross-region failover for {database.name}? + This will promote the standby replica in {crossRegionStatus?.standbyRegion} + to primary. The current primary in {crossRegionStatus?.primaryRegion} will + become the new standby. This operation may cause brief downtime. +

+ + + + +
+{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateExtensions.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateExtensions.svelte new file mode 100644 index 0000000000..d90d60b92b --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateExtensions.svelte @@ -0,0 +1,185 @@ + + +{#if !isLoading && extensions} +
+ + Extensions + Manage PostgreSQL extensions for your database. Extensions add additional functionality such + as full-text search, geospatial queries, and more. + +
    + {#if extensions.installed.length > 0} +
  • + + + {#each extensions.installed as ext} + { + extensionToUninstall = ext; + showUninstallConfirm = true; + }} /> + {/each} + +
  • + {:else} +
  • +

    No extensions installed.

    +
  • + {/if} + + {#if availableOptions.length > 0} + + {/if} +
+
+ + + + +
+
+ + +

+ Are you sure you want to uninstall the extension {extensionToUninstall} from + {database.name}? Any database objects that depend on this extension may stop + working. +

+ + + + +
+{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateHAStatus.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateHAStatus.svelte new file mode 100644 index 0000000000..f7f3bfa0bd --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateHAStatus.svelte @@ -0,0 +1,251 @@ + + +{#if !isLoading} +
+ + High availability + High availability maintains replicas of your database that automatically take over if the + primary instance fails, minimizing downtime. + +
    + + {#if haEnabled} + + + {/if} + + {#if haStatus && haStatus.replicas.length > 0} +
  • + + + {#each haStatus.replicas as replica} +
    + + {replica.$id} + + + + Lag: {replica.lagSeconds}s + + +
    + {/each} +
    +
  • + {/if} +
+
+ + + + {#if haEnabled && haStatus?.enabled} + + {/if} + + + +
+
+ + +

+ Are you sure you want to trigger a manual failover for {database.name}? + This will promote a replica to primary. The operation may cause brief downtime + while the roles are switched. +

+ + + + +
+{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateMaintenance.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateMaintenance.svelte new file mode 100644 index 0000000000..75fa757761 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateMaintenance.svelte @@ -0,0 +1,102 @@ + + +
+ + Maintenance window + Schedule a preferred time window for automatic maintenance operations such as minor + version upgrades and patches. + +
    + + + +
+
+ + + + +
+
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateName.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateName.svelte new file mode 100644 index 0000000000..c5dea38b09 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateName.svelte @@ -0,0 +1,63 @@ + + +
+ + Name + + + + + + + + +
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateNetwork.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateNetwork.svelte new file mode 100644 index 0000000000..0ed2ae3e9b --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateNetwork.svelte @@ -0,0 +1,88 @@ + + +
+ + Network + Configure connection limits and network access controls for your database. + +
    + + + +
+
+ + + + +
+
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updatePooler.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updatePooler.svelte new file mode 100644 index 0000000000..e5278cf7e8 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updatePooler.svelte @@ -0,0 +1,130 @@ + + +{#if !isLoading} +
+ + Connection pooler + A connection pooler sits between your application and the database, reusing connections + to reduce overhead. Transaction mode is recommended for serverless workloads. + +
    + + {#if poolerEnabled} + + + {/if} +
+
+ + + + +
+
+{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateReadReplicas.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateReadReplicas.svelte new file mode 100644 index 0000000000..315ef1236a --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateReadReplicas.svelte @@ -0,0 +1,246 @@ + + +{#if !isLoading} +
+ + Read replicas + Deploy read-only replicas of your database to other regions to reduce read latency for + geographically distributed workloads. + +
    + {#if replicas.length > 0} +
  • + + + {#each replicas as replica} +
    + + + + {replica.$id} + + + + {replica.sourceRegion} → {replica.targetRegion} + • Lag: {replica.lagSeconds}s + • {replica.hostname} + + + + +
    + {/each} +
    +
  • + {:else} +
  • +

    No read replicas configured.

    +
  • + {/if} + + {#if availableRegionOptions.length > 0} + + + {/if} +
+
+ + + + +
+
+ + +

+ Are you sure you want to delete the read replica + {replicaToDelete?.$id} in region {replicaToDelete?.targetRegion}? + This action cannot be undone. +

+ + + + +
+{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateSecurity.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateSecurity.svelte new file mode 100644 index 0000000000..ffee6225a7 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateSecurity.svelte @@ -0,0 +1,131 @@ + + +
+ + Security + Manage encryption, key management, data residency, and audit logging for your database. + +
    +
  • +
    + + + Encryption at rest: + {database.securityEncryptionAtRest ? 'Enabled' : 'Disabled'} + + + Key management: + {getKeyManagementLabel(database.securityKeyManagement)} + + + Data residency: + {getResidencyLabel(database.securityDataResidency)} + + +
    +
  • + + + {#if auditLogEnabled} + + {/if} +
+
+ + + + +
+
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateSqlApi.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateSqlApi.svelte new file mode 100644 index 0000000000..437b303559 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateSqlApi.svelte @@ -0,0 +1,146 @@ + + +
+ + SQL API + The SQL API allows direct SQL query execution against your database through the Appwrite + API. Configure which statements are permitted and set resource limits. + +
    + + {#if sqlApiEnabled} + + + +
  • + + {#each allStatements as statement} + toggleStatement(statement)} /> + {/each} +
  • + {/if} +
+
+ + + + +
+
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateStorage.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateStorage.svelte new file mode 100644 index 0000000000..9428085403 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateStorage.svelte @@ -0,0 +1,68 @@ + + +
+ + Storage + Resize the storage allocated to your database. Storage can only be increased, not + decreased. + + + {#if storageGb < database.storage} + Storage can only be increased, not decreased. + {/if} + + + + + + +
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateTier.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateTier.svelte new file mode 100644 index 0000000000..b4cce42c0c --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateTier.svelte @@ -0,0 +1,91 @@ + + +
+ + Resource scaling + Change the compute resources allocated to your database. Scaling may cause a brief + interruption while the database restarts. + + + + + + + + +
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/upgradeVersion.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/upgradeVersion.svelte new file mode 100644 index 0000000000..d5985424d9 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/upgradeVersion.svelte @@ -0,0 +1,121 @@ + + +{#if !isLoading} + + Version + Upgrade your database engine to a newer version. This operation may cause a brief + interruption. + + + + + Current version + + + {currentVersion} + + + + + + + + + + + + +

+ Are you sure you want to upgrade {database.name} from version + {currentVersion} to {targetVersion}? The database may be briefly + unavailable during the upgrade. +

+ + + + +
+{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/store.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/store.ts index d5e75fb54e..5051ae412c 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/store.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/store.ts @@ -1,8 +1,67 @@ import { writable } from 'svelte/store'; import type { Column } from '$lib/helpers/types'; -import { IconChartBar, IconCloudUpload, IconCog } from '@appwrite.io/pink-icons-svelte'; +import { + IconChartBar, + IconChartSquareBar, + IconCloudUpload, + IconCog +} from '@appwrite.io/pink-icons-svelte'; import { resolveRoute, withPath } from '$lib/stores/navigation'; import type { Page } from '@sveltejs/kit'; +import { type Models, Query } from '@appwrite.io/console'; +import type { Entity, Field } from '$database/(entity)'; +import { isRelationship } from '$database/table-[table]/rows/store'; +import type { TagValue } from '$lib/components/filters/store'; +import type { SortDirection } from '$lib/components'; + +export type Columns = + | Models.ColumnBoolean + | Models.ColumnEmail + | Models.ColumnEnum + | Models.ColumnFloat + | Models.ColumnInteger + | Models.ColumnIp + | Models.ColumnString + | Models.ColumnUrl + | Models.ColumnPoint + | Models.ColumnLine + | Models.ColumnPolygon + | (Models.ColumnRelationship & { default?: never }); + +export type Attributes = + | Models.AttributeBoolean + | Models.AttributeEmail + | Models.AttributeEnum + | Models.AttributeFloat + | Models.AttributeInteger + | Models.AttributeIp + | Models.AttributeString + | Models.AttributeUrl + | Models.AttributePoint + | Models.AttributeLine + | Models.AttributePolygon + | (Models.AttributeRelationship & { default?: never }); + +export type Collection = Omit & { + attributes: Array; +}; + +export type Table = Omit & { + columns: Array; +}; + +export type SortState = { + column?: string; + direction: SortDirection; +}; + +export type RandomDataSchema = { + show: boolean; + value: number; + onSubmit?: () => Promise | void; +}; + +export const expandTabs = writable(null); export const showCreateEntity = writable(false); @@ -36,6 +95,22 @@ export const databaseSubNavigationItems = [ { title: 'Settings', href: 'settings', icon: IconCog } ]; +export const dedicatedDatabaseSubNavigationItems = [ + { title: 'Backups', href: 'backups', icon: IconCloudUpload }, + { title: 'Monitoring', href: 'monitoring', icon: IconChartSquareBar }, + { title: 'Usage', href: 'usage', icon: IconChartBar }, + { title: 'Settings', href: 'settings', icon: IconCog } +]; + +export const randomDataModalState = writable({ + show: false, + value: 25 // initial value! +}); + +export const spreadsheetLoading = writable(false); + +export const spreadsheetRenderKey = writable('initial'); + export function buildEntityRoute(page: Page, entityType: string, entityId: string): string { return withPath( resolveRoute( @@ -45,3 +120,52 @@ export function buildEntityRoute(page: Page, entityType: string, entityId: strin `/${entityType}-${entityId}` ); } + +/** + * Returns select queries for all main and related fields in an `Entity`. + */ +export function buildWildcardEntitiesQuery(entity: Entity | null = null): string[] { + return [ + ...(entity?.fields + ?.filter((field: Field) => field.status === 'available' && isRelationship(field)) + ?.map((field: Field) => Query.select([`${field.key}.*`])) ?? []), + + Query.select(['*']) + ]; +} + +export function extractSortFromQueries(parsedQueries: Map) { + for (const [tagValue, queryString] of parsedQueries.entries()) { + if (queryString.includes('orderAsc') || queryString.includes('orderDesc')) { + const isAsc = queryString.includes('orderAsc'); + return { + column: tagValue.value, + direction: isAsc ? 'asc' : 'desc' + }; + } + } + + return { column: null, direction: 'default' }; +} + +export function buildGridQueries( + limit: number, + offset: number, + parsedQueries: Map, + table: Entity +) { + const hasOrderQuery = Array.from(parsedQueries.values()).some( + (q) => q.includes('orderAsc') || q.includes('orderDesc') + ); + + const queryArray = [Query.limit(limit), Query.offset(offset)]; + + // don't override if there's a user created sort! + if (!hasOrderQuery) { + queryArray.push(Query.orderDesc('')); + } + + queryArray.push(...parsedQueries.values(), ...buildWildcardEntitiesQuery(table)); + + return queryArray; +} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/subNavigation.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/subNavigation.svelte index 011dc9a88a..77f1b472c7 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/subNavigation.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/subNavigation.svelte @@ -3,7 +3,12 @@ import type { PageData } from './$types'; import { showSubNavigation } from '$lib/stores/layout'; import { bannerSpacing } from '$lib/layout/headerAlert.svelte'; - import { showCreateEntity, databaseSubNavigationItems, buildEntityRoute } from './store'; + import { + showCreateEntity, + databaseSubNavigationItems, + dedicatedDatabaseSubNavigationItems, + buildEntityRoute + } from './store'; import { Icon, @@ -42,6 +47,11 @@ const terminology = useTerminology(page); const databaseSdk = useDatabaseSdk(page, terminology); + // Check if this is a dedicated database type + const isDedicatedType = $derived( + terminology.type === 'prisma' || terminology.type === 'dedicated' || terminology.type === 'shared' + ); + const entityTypePlural = terminology.entity.lower.plural; const entityTypeSingular = terminology.entity.lower.singular; @@ -78,6 +88,12 @@ ); async function loadEntities() { + // Don't load entities for dedicated databases - they don't have tables/collections + if (isDedicatedType) { + loading = false; + return; + } + try { entities = await databaseSdk.listEntities({ databaseId: page.params.database, @@ -113,83 +129,86 @@ {data.database?.name} -
- {#if loading} -
    - {#each Array(2) as _} - -
  • -
    - -
    -
  • -
    - {/each} -
- {:else if entities?.total} -
    - {#each sortedEntities as entity, index} - {@const isFirst = index === 0} - {@const isSelected = entityId === entity.$id} - {@const isLast = index === sortedEntities.length - 1} - {@const href = withPath( - databaseBaseRoute, - `/${entityTypeSingular}-${entity.$id}` - )} - - -
  • - - - {entity.name} - -
  • + + {#if !isDedicatedType} +
    + {#if loading} +
      + {#each Array(2) as _} + +
    • +
      + +
      +
    • +
      + {/each} +
    + {:else if entities?.total} +
      + {#each sortedEntities as entity, index} + {@const isFirst = index === 0} + {@const isSelected = entityId === entity.$id} + {@const isLast = index === sortedEntities.length - 1} + {@const href = withPath( + databaseBaseRoute, + `/${entityTypeSingular}-${entity.$id}` + )} + + +
    • + + + {entity.name} + +
    • +
      + {/each} +
    + {:else} +
    + +
    +
    + No {entityTypePlural} yet
    - {/each} -
- {:else} -
- -
-
- No {entityTypePlural} yet -
-
- {/if} - - - - - -
+
+ {/if} + + + + + +
+ {/if}
@@ -199,7 +218,7 @@
    - {#each databaseSubNavigationItems as action} + {#each (isDedicatedType ? dedicatedDatabaseSubNavigationItems : databaseSubNavigationItems) as action} {@const href = withPath(databaseBaseRoute, `/${action.href}`)} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.svelte index cd99973a92..90aa223539 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.svelte @@ -1,6 +1,6 @@ -{#if $isCsvImportInProgress} +{#if $isTablesCsvImportInProgress}