diff --git a/src/routes/(console)/+layout.svelte b/src/routes/(console)/+layout.svelte index 8663437fb..139f1883d 100644 --- a/src/routes/(console)/+layout.svelte +++ b/src/routes/(console)/+layout.svelte @@ -8,7 +8,7 @@ import { database, checkForDatabaseBackupPolicies } from '$lib/stores/database'; import { newOrgModal, organization } from '$lib/stores/organization'; import { wizard } from '$lib/stores/wizard'; - import { afterUpdate, onMount } from 'svelte'; + import { onMount } from 'svelte'; import { loading } from '$routes/store'; import { requestedMigration } from '../store'; import Create from './createOrganization.svelte'; @@ -281,10 +281,12 @@ } } - database.subscribe(async (database) => { - if (!database || !page.params.region || !page.params.project) return; - // the component checks `isCloud` internally. - await checkForDatabaseBackupPolicies(page.params.region, page.params.project, database); + onMount(() => { + return database.subscribe(async (database) => { + if (!database || !page.params.region || !page.params.project) return; + // the component checks `isCloud` internally. + await checkForDatabaseBackupPolicies(page.params.region, page.params.project, database); + }); }); let currentOrganizationId = null; @@ -294,19 +296,23 @@ if (isCloud) { currentOrganizationId = org.$id; checkForEnterpriseTrial(org); - await checkForUsageLimit(org); checkForMarkedForDeletion(org); checkForUpgradingStatus(org); await checkForNewDevUpgradePro(org); - if (org?.billingPlanDetails.requiresPaymentMethod) { - await paymentExpired(org); - await checkPaymentAuthorizationRequired(org); + const billingChecks: Promise[] = [ + checkForUsageLimit(org), + checkForNewDevUpgradePro(org) + ]; + if (org?.billingPlanDetails.requiresPaymentMethod) { + billingChecks.push(paymentExpired(org), checkPaymentAuthorizationRequired(org)); if (org?.billingTrialDays) { calculateTrialDay(org); } } + + await Promise.all(billingChecks); $activeHeaderAlert = headerAlert.getExcluding('impersonation'); } } @@ -319,9 +325,7 @@ $registerSearchers(orgSearcher, projectsSearcher); - afterUpdate(() => { - $activeHeaderAlert = headerAlert.getExcluding('impersonation'); - }); + $: (void $headerAlert, ($activeHeaderAlert = headerAlert.getExcluding('impersonation'))); diff --git a/src/routes/(console)/+layout.ts b/src/routes/(console)/+layout.ts index f40123b43..b191a6436 100644 --- a/src/routes/(console)/+layout.ts +++ b/src/routes/(console)/+layout.ts @@ -3,7 +3,7 @@ import { isCloud } from '$lib/system'; import type { LayoutLoad } from './$types'; import type { Account } from '$lib/stores/user'; import { Dependencies } from '$lib/constants'; -import { Platform, Query, type Models } from '@appwrite.io/console'; +import { Platform, type Models } from '@appwrite.io/console'; import { makePlansMap } from '$lib/helpers/billing'; import { plansInfo as plansInfoStore } from '$lib/stores/billing'; import { normalizeConsoleVariables } from '$lib/helpers/domains'; @@ -54,7 +54,6 @@ export const load: LayoutLoad = async ({ depends, parent, url }) => { currentOrgId: undefined, organizations, consoleVariables, - allProjectsCount: 0, plansInfo: plansInfo ?? null, version: versionData?.version ?? null }; @@ -107,28 +106,6 @@ export const load: LayoutLoad = async ({ depends, parent, url }) => { preferences.organization ?? (organizations.teams.length > 0 ? organizations.teams[0].$id : undefined); - // Load projects for the current organization if one is selected - let projectsCount = 0; - if (currentOrgId) { - try { - projectsCount = ( - await sdk.forConsole.projects.list({ - queries: [ - Query.equal('teamId', currentOrgId), - Query.limit(1), - Query.select(['$id']) - ] - }) - ).total; - } catch (e) { - if (shouldRedirectToVerifyEmail(e)) { - redirect(303, verifyEmailUrl); - } - - projectsCount = 0; - } - } - // just in case! plansInfoStore.set(fallbackPlansInfoArray); @@ -139,7 +116,6 @@ export const load: LayoutLoad = async ({ depends, parent, url }) => { currentOrgId, organizations, consoleVariables, - allProjectsCount: projectsCount, plansInfo: fallbackPlansInfoArray, version: versionData?.version ?? null }; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/table.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/table.svelte index c64a53e8e..9170eee9b 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/table.svelte @@ -99,11 +99,22 @@ return 'processing'; case 'failed': return 'failed'; + // pink-svelte's Status union has no 'skipped' — fall back to the + // neutral 'waiting' visual and override the label below. + case 'skipped': + return 'waiting'; default: return 'waiting'; } } + function getBackupStatusLabel(backup: Models.BackupArchive): string { + if (backup.status === 'skipped') { + return 'Skipped'; + } + return capitalize(getBackupStatus(backup)); + } + async function deleteSingleBackup(archiveId: string) { try { await sdk @@ -231,7 +242,7 @@ {@const backupStatus = getBackupStatus(backup)} - + 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 index 1df280dce..017e1a03a 100644 --- 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 @@ -5,7 +5,15 @@ import type { Column, ColumnType } from '$lib/helpers/types'; import { Container } from '$lib/layout'; import { preferences } from '$lib/stores/preferences'; - import { Icon, Layout, Divider, Tooltip } from '@appwrite.io/pink-svelte'; + import { + Icon, + Layout, + Divider, + Tooltip, + Selector, + Typography, + Dialog + } from '@appwrite.io/pink-svelte'; import type { PageProps } from './$types'; import FilePicker from '$lib/components/filePicker.svelte'; import { page } from '$app/state'; @@ -21,7 +29,7 @@ IconUpload, IconDownload } from '@appwrite.io/pink-icons-svelte'; - import { type Models } from '@appwrite.io/console'; + import { OnDuplicate, type Models } from '@appwrite.io/console'; import { sdk } from '$lib/stores/sdk'; import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; @@ -49,7 +57,11 @@ let isRefreshing = $state(false); let showImportJson = $state(false); + let showImportOptions = $state(false); let showCustomColumnsModal = $state(false); + let importOnDuplicate: OnDuplicate = $state(OnDuplicate.Fail); + let pendingFile: Models.File | null = $state(null); + let pendingLocalFile = $state(false); let columnsError: string = $state(null); let spreadsheet: SpreadSheet | null = $state(null); @@ -74,17 +86,28 @@ return queryParam ? `${url}?query=${encodeURIComponent(queryParam)}` : url; } - async function onSelect(file: Models.File, localFile = false) { + function onSelect(file: Models.File, localFile = false) { + pendingFile = file; + pendingLocalFile = localFile; + importOnDuplicate = OnDuplicate.Fail; + showImportOptions = true; + } + + async function startImport() { + if (!pendingFile) return; + + showImportOptions = false; $isCollectionsJsonImportInProgress = true; try { await sdk .forProject(page.params.region, page.params.project) .migrations.createJSONImport({ - bucketId: file.bucketId, - fileId: file.$id, + bucketId: pendingFile.bucketId, + fileId: pendingFile.$id, resourceId: `${page.params.database}:${page.params.collection}`, - internalFile: localFile + internalFile: pendingLocalFile, + onDuplicate: importOnDuplicate }); addNotification({ @@ -101,6 +124,7 @@ }); } finally { $isCollectionsJsonImportInProgress = false; + pendingFile = null; } } @@ -343,6 +367,52 @@ }} /> {/if} + + + + Choose how to handle documents that already exist in this collection. + + + + + Import aborts on the first document with a matching ID. + + + + + Documents with matching IDs will be silently skipped. + + + + + Documents with matching IDs will be updated with the imported data. + + + + + + + + + + + + ([]); @@ -107,17 +120,28 @@ $: disableButton = canShowSuggestionsSheet; - async function onSelect(file: Models.File, localFile = false) { + function onSelect(file: Models.File, localFile = false) { + pendingFile = file; + pendingLocalFile = localFile; + importOnDuplicate = OnDuplicate.Fail; + showImportOptions = true; + } + + async function startImport() { + if (!pendingFile) return; + + showImportOptions = false; $isTablesCsvImportInProgress = true; try { await sdk .forProject(page.params.region, page.params.project) .migrations.createCSVImport({ - bucketId: file.bucketId, - fileId: file.$id, + bucketId: pendingFile.bucketId, + fileId: pendingFile.$id, resourceId: `${page.params.database}:${page.params.table}`, - internalFile: localFile + internalFile: pendingLocalFile, + onDuplicate: importOnDuplicate }); addNotification({ @@ -134,6 +158,7 @@ }); } finally { $isTablesCsvImportInProgress = false; + pendingFile = null; } } @@ -434,6 +459,52 @@ }} /> {/if} + + + + Choose how to handle documents that already exist in this table. + + + + + Migration aborts on the first row with a matching ID. + + + + + Documents with matching IDs will be silently skipped. + + + + + Documents with matching IDs will be updated with the imported data. + + + + + + + + + + + + + sdk.forProject(page.params.region, page.params.project).functions.listVariables({ + functionId: data.function.$id, + queries + }); @@ -105,9 +111,14 @@ {sdkCreateVariable} {sdkUpdateVariable} {sdkDeleteVariable} + {sdkListVariables} isGlobal={false} globalVariableList={data.globalVariables} variableList={data.variables} + backendPagination + reloadPageOnPagination={false} + variablesOffset={data.variablesOffset} + variablesLimit={data.limit} project={data.project} analyticsSource="function_settings" /> diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/settings/+page.ts b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/settings/+page.ts index 113424686..eafbd342a 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/settings/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/settings/+page.ts @@ -1,10 +1,16 @@ import { sdk } from '$lib/stores/sdk'; -import { Dependencies } from '$lib/constants'; +import { Dependencies, PAGE_LIMIT } from '$lib/constants'; +import { Query } from '@appwrite.io/console'; + +const VARIABLES_LIMIT = 100; export const load = async ({ params, depends, parent }) => { depends(Dependencies.VARIABLES); depends(Dependencies.FUNCTION); + const limit = PAGE_LIMIT; + const variablesOffset = 0; + const { runtimesList, specificationsList, function: fn } = await parent(); const enabledSpecs = specificationsList?.specifications?.filter((s) => s.enabled) ?? []; @@ -16,10 +22,13 @@ export const load = async ({ params, depends, parent }) => { } const [globalVariables, variables] = await Promise.all([ - sdk.forProject(params.region, params.project).projectApi.listVariables(), - sdk - .forProject(params.region, params.project) - .functions.listVariables({ functionId: params.function }) + sdk.forProject(params.region, params.project).projectApi.listVariables({ + queries: [Query.limit(VARIABLES_LIMIT)] + }), + sdk.forProject(params.region, params.project).functions.listVariables({ + functionId: params.function, + queries: [Query.limit(limit), Query.offset(variablesOffset)] + }) ]); // Conflicting variables first @@ -43,6 +52,8 @@ export const load = async ({ params, depends, parent }) => { return { variables, globalVariables, + limit, + variablesOffset, runtimesList, specificationsList, function: fn diff --git a/src/routes/(console)/project-[region]-[project]/settings/migrations/(import)/wizard.svelte b/src/routes/(console)/project-[region]-[project]/settings/migrations/(import)/wizard.svelte index 4dfd64719..43d5294b4 100644 --- a/src/routes/(console)/project-[region]-[project]/settings/migrations/(import)/wizard.svelte +++ b/src/routes/(console)/project-[region]-[project]/settings/migrations/(import)/wizard.svelte @@ -11,6 +11,7 @@ AppwriteMigrationResource, FirebaseMigrationResource, NHostMigrationResource, + OnDuplicate, SupabaseMigrationResource } from '@appwrite.io/console'; import { started } from '../stores'; @@ -23,6 +24,7 @@ Fieldset, Icon, Layout, + Selector, Typography } from '@appwrite.io/pink-svelte'; import { Link } from '$lib/elements'; @@ -38,6 +40,8 @@ import { capitalize } from '$lib/helpers/string'; import { page } from '$app/state'; + let importOnDuplicate: OnDuplicate = OnDuplicate.Fail; + const onExit = () => { resetImportStores(); }; @@ -46,6 +50,15 @@ try { const resources = migrationFormToResources($formData, $provider.provider); + // Gate onDuplicate to Fail when databases isn't selected. The radios + // are only shown when databases.root is checked, but the local value + // persists across toggles — without this gate, deselecting databases + // after picking Overwrite/Skip would silently apply that mode to + // other resource types (users, teams, functions, etc.) on submit. + const importOptions = { + onDuplicate: $formData.databases.root ? importOnDuplicate : OnDuplicate.Fail + }; + switch ($provider.provider) { case 'appwrite': { await sdk @@ -54,7 +67,8 @@ resources: resources as AppwriteMigrationResource[], endpoint: $provider.endpoint, projectId: $provider.projectID, - apiKey: $provider.apiKey + apiKey: $provider.apiKey, + ...importOptions }); await invalidate(Dependencies.MIGRATIONS); @@ -70,7 +84,8 @@ databaseHost: $provider.host, username: $provider.username || 'postgres', password: $provider.password, - port: $provider.port || 5432 + port: $provider.port || 5432, + ...importOptions }); await invalidate(Dependencies.MIGRATIONS); break; @@ -80,7 +95,8 @@ .forProject(page.params.region, page.params.project) .migrations.createFirebaseMigration({ resources: resources as FirebaseMigrationResource[], - serviceAccount: $provider.serviceAccount + serviceAccount: $provider.serviceAccount, + ...importOptions }); await invalidate(Dependencies.MIGRATIONS); break; @@ -95,7 +111,8 @@ adminSecret: $provider.adminSecret, database: $provider.database || $provider.subdomain, username: $provider.username || 'postgres', - password: $provider.password + password: $provider.password, + ...importOptions }); await invalidate(Dependencies.MIGRATIONS); @@ -196,6 +213,46 @@ projectSdk={sdk.forProject(page.params.region, page.params.project)} /> + + {#if $formData.databases.root} +
+ + + + Migration aborts on the first existing resource (database, + table, column, index, or row). + + + + + Existing resources are left untouched. Only resources missing on + the destination are created. + + + + + Existing resources are updated to match the source. Schema drift + and row data are both reconciled. + + + +
+ {/if} {/if} diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/+page.svelte b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/+page.svelte index faddf5f36..6362982ab 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/+page.svelte @@ -51,6 +51,12 @@ await Promise.all([invalidate(Dependencies.VARIABLES), invalidate(Dependencies.SITE)]); }; + const sdkListVariables = async (queries: string[]) => + sdk.forProject(page.params.region, page.params.project).sites.listVariables({ + siteId: page.params.site, + queries + }); + onMount(async () => { if ( page.url.searchParams.has('newInstallation') && @@ -75,9 +81,14 @@ {sdkCreateVariable} {sdkUpdateVariable} {sdkDeleteVariable} + {sdkListVariables} isGlobal={false} globalVariableList={data.globalVariables} variableList={data.variables} + backendPagination + reloadPageOnPagination={false} + variablesOffset={data.variablesOffset} + variablesLimit={data.limit} project={data.project} product="site" analyticsSource="site_settings" /> diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/+page.ts b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/+page.ts index a4d6060f8..a99d8bf74 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/+page.ts @@ -1,18 +1,26 @@ import { sdk } from '$lib/stores/sdk'; -import { Dependencies } from '$lib/constants'; +import { Dependencies, PAGE_LIMIT } from '$lib/constants'; import { isCloud } from '$lib/system'; +import { Query } from '@appwrite.io/console'; + +const VARIABLES_LIMIT = 100; export const load = async ({ params, depends, parent }) => { depends(Dependencies.VARIABLES); depends(Dependencies.SITE); + const limit = PAGE_LIMIT; + const variablesOffset = 0; const { site } = await parent(); const [globalVariables, variables, frameworks, installations, specificationsList] = await Promise.all([ - sdk.forProject(params.region, params.project).projectApi.listVariables(), - sdk - .forProject(params.region, params.project) - .sites.listVariables({ siteId: params.site }), + sdk.forProject(params.region, params.project).projectApi.listVariables({ + queries: [Query.limit(VARIABLES_LIMIT)] + }), + sdk.forProject(params.region, params.project).sites.listVariables({ + siteId: params.site, + queries: [Query.limit(limit), Query.offset(variablesOffset)] + }), sdk.forProject(params.region, params.project).sites.listFrameworks(), sdk.forProject(params.region, params.project).vcs.listInstallations(), isCloud @@ -51,6 +59,8 @@ export const load = async ({ params, depends, parent }) => { frameworks, variables, globalVariables, + limit, + variablesOffset, installations, specificationsList }; diff --git a/src/routes/(console)/project-[region]-[project]/updateVariables.svelte b/src/routes/(console)/project-[region]-[project]/updateVariables.svelte index 6e3d38620..0d5fa5295 100644 --- a/src/routes/(console)/project-[region]-[project]/updateVariables.svelte +++ b/src/routes/(console)/project-[region]-[project]/updateVariables.svelte @@ -60,8 +60,13 @@ secret?: boolean ) => Promise; export let sdkDeleteVariable: (variableId: string) => Promise; + export let sdkListVariables: (queries: string[]) => Promise = (queries) => + sdk.forProject(page.params.region, page.params.project).projectApi.listVariables({ + queries + }); export let product: 'function' | 'site' = 'function'; export let backendPagination = false; + export let reloadPageOnPagination = true; export let variablesOffset = 0; export let variablesLimit = 10; export let disabled = false; @@ -76,20 +81,21 @@ let showDeleteModal = false; let deleteError: string; let fullVariableList: Models.VariableList | undefined = allVariableList; + let pagedVariableList: Models.VariableList = variableList; let previousVariableList = variableList; let offset = 0; const limit = 10; async function loadAllVariables() { - const projectSdk = sdk.forProject(page.params.region, page.params.project); - const variables = [...variableList.variables]; - let nextOffset = variables.length; + const variables = backendPagination ? [] : [...variableList.variables]; + let nextOffset = backendPagination ? 0 : variables.length; let total = variableList.total; while (nextOffset < total) { - const response = await projectSdk.projectApi.listVariables({ - queries: [Query.limit(variablesLimit), Query.offset(nextOffset)] - }); + const response = await sdkListVariables([ + Query.limit(variablesLimit), + Query.offset(nextOffset) + ]); total = response.total; @@ -106,7 +112,11 @@ } async function ensureAllVariablesLoaded() { - if (fullVariableList && fullVariableList.total === variableList.total) return; + const total = backendPagination ? variableList.total : variableList.variables.length; + + if (fullVariableList && fullVariableList.variables.length >= total) { + return; + } fullVariableList = await loadAllVariables(); } @@ -321,31 +331,35 @@ } } - $: conflictVariables = globalVariableList - ? globalVariableList.variables.filter((globalVariable) => { - return variableList.variables.find((variable) => { - return variable.key === globalVariable.key; - }); - }) - : []; - $: if (allVariableList && fullVariableList !== allVariableList) { fullVariableList = allVariableList; } $: if (variableList !== previousVariableList) { + pagedVariableList = variableList; + if (backendPagination && !reloadPageOnPagination) { + variablesOffset = 0; + } fullVariableList = undefined; previousVariableList = variableList; } - $: if (fullVariableList && fullVariableList.total !== variableList.total) { + $: if (fullVariableList && fullVariableList.total < variableList.total) { fullVariableList = undefined; } $: editorVariableList = fullVariableList ?? allVariableList ?? variableList; $: displayedVariables = backendPagination - ? variableList.variables + ? pagedVariableList.variables : variableList.variables.slice(offset, offset + limit); + $: variableCount = backendPagination ? pagedVariableList.total : variableList.total; + $: conflictVariables = globalVariableList + ? globalVariableList.variables.filter((globalVariable) => { + return displayedVariables.find((variable) => { + return variable.key === globalVariable.key; + }); + }) + : []; $: hasConflictOnPage = globalVariableList ? displayedVariables.filter((variable) => { @@ -368,6 +382,14 @@ ]; async function handleVariablesPageChange() { + if (!reloadPageOnPagination) { + pagedVariableList = await sdkListVariables([ + Query.limit(variablesLimit), + Query.offset(variablesOffset) + ]); + return; + } + const nextUrl = new URL(page.url); nextUrl.searchParams.set('variablesOffset', String(variablesOffset)); @@ -422,7 +444,7 @@ Import .env - {#if variableList.total} + {#if variableCount}