get_col( 'SHOW GRANTS' ) as $grant ) { $can .= ' ' . strstr( (string) $grant . ' ON ', ' ON ', true ); } return trim( $can ) !== '' && stripos( $can, 'ALL PRIVILEGES' ) === false && (stripos( $can, 'INSERT' ) === false || stripos( $can, 'UPDATE' ) === false); } /* ---------- Working records ---------- */ private static function prune() : void { global $wpdb; $events = $wpdb->prefix . 'storesuit_events'; if ( self::exists( $events ) ) { $before = gmdate( 'Y-m-d H:i:s', time() - self::AUDIT_DAYS * DAY_IN_SECONDS ); // Only entries nothing but the activity log reads. Collections, stock moves, order and customer history stay for good. for ($i = 0; $i < self::BATCHES; $i++) { $gone = $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE event IN ('redirect_saved','quick_edited','readiness_saved','publication_changed','health_template_default','log_trimmed') AND created_at<%s LIMIT %d", $events, $before, self::BATCH ) ); if ( self::batch_ended( $gone ) ) { break; } } } $runs = $wpdb->prefix . 'storesuit_catalog_runs'; $changes = $wpdb->prefix . 'storesuit_catalog_changes'; if ( self::exists( $runs ) && self::exists( $changes ) ) { $ended = (int) $wpdb->get_var( $wpdb->prepare( "SELECT MAX(id) FROM %i WHERE status IN ('done','undone','failed') AND finished<%s", $runs, gmdate( 'Y-m-d H:i:s', time() - self::CATALOG_DAYS * DAY_IN_SECONDS ) ) ); // A run still going below that one keeps its records. $open = (int) $wpdb->get_var( $wpdb->prepare( "SELECT MIN(id) FROM %i WHERE status IN ('queued','running','undoing')", $runs ) ); if ( $open && $open <= $ended ) { $ended = $open - 1; } for ($i = 0; $ended > 0 && $i < self::BATCHES; $i++) { $gone = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE run_id<=%d LIMIT %d', $changes, $ended, self::BATCH ) ); if ( self::batch_ended( $gone ) ) { break; } } } $jobs = $wpdb->prefix . 'storesuit_connection_jobs'; if ( self::exists( $jobs ) ) { $before = gmdate( 'Y-m-d H:i:s', time() - self::JOB_DAYS * DAY_IN_SECONDS ); for ($i = 0; $i < self::BATCHES; $i++) { $gone = $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE status IN ('complete','failed') AND updated_at<%s LIMIT %d", $jobs, $before, self::BATCH ) ); if ( self::batch_ended( $gone ) ) { break; } } } } /** A short delete means the table is done. A refused one ends it too: the rest waits for tomorrow. */ private static function batch_ended( $gone ) : bool { return $gone === false || (int) $gone < self::BATCH; } private static function exists( string $table ) : bool { global $wpdb; return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) === $table; } /* ---------- Size and warnings ---------- */ private static function note_size() : void { global $wpdb; $mb = (int) round( (float) $wpdb->get_var( $wpdb->prepare( 'SELECT SUM(data_length+index_length)/1048576 FROM information_schema.tables WHERE table_schema=%s', DB_NAME ) ) ); if ( $mb <= 0 ) { return; } $days = (array) get_option( self::SIZES, array() ); $days[gmdate( 'Y-m-d' )] = $mb; ksort( $days ); update_option( self::SIZES, array_slice( $days, -15, null, true ), false ); } /** Megabytes a day over the last week of daily sizes, or 0 before two days lie between the first and last. */ public static function pace() : float { $days = array_slice( (array) get_option( self::SIZES, array() ), -8, null, true ); if ( count( $days ) < 2 ) { return 0.0; } $dates = array_keys( $days ); $span = (strtotime( (string) end( $dates ) . ' UTC' ) - strtotime( (string) reset( $dates ) . ' UTC' )) / DAY_IN_SECONDS; return ( $span >= 2 ? (float) (end( $days ) - reset( $days )) / $span : 0.0 ); } public static function limit_mb() : int { return max( 1, (int) apply_filters( 'storeruler_database_limit_mb', self::LIMIT_MB ) ); } /** Days until the size many hosts stop at, at this pace; null when the store is not growing fast enough for that to matter. */ public static function days_left( int $total ) : ?int { $pace = self::pace(); if ( $pace < self::MIN_PACE_MB ) { return null; } $left = (int) ceil( max( 0, self::limit_mb() - $total ) / $pace ); return ( $left <= self::WARN_DAYS ? $left : null ); } private static function tell_growth() : void { $room = Health::database_room(); if ( !$room || $room['status'] !== 'attention' || time() - (int) get_option( self::TOLD, 0 ) < self::TELL_EVERY ) { return; } // Not saved, not sent: a message must not go out again with every run. if ( update_option( self::TOLD, time(), false ) ) { self::mail( (string) $room['summary'], (string) $room['detail'] ); } } /** * The database can't remember a message was sent, so a small file in the temporary folder does. No file, no message. */ private static function tell_refused() : void { $stamp = trailingslashit( get_temp_dir() ) . 'storeruler-db-refused-' . md5( ABSPATH ); if ( file_exists( $stamp ) && time() - (int) filemtime( $stamp ) < DAY_IN_SECONDS ) { return; } // phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_system_operations_touch -- Remembers that the message went out while the database refuses writes; a folder that refuses it just means no message. if ( !@touch( $stamp ) ) { return; } // phpcs:enable WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_system_operations_touch self::mail( I18n::t( 'The database refuses new records.' ), I18n::t( 'New orders, stock changes and settings cannot be saved. This usually means the database reached the hosting limit: ask your host to give the database user its write permissions back.' ) ); } private static function mail( string $summary, string $detail ) : void { if ( !apply_filters( 'storeruler_database_mail', true ) ) { return; } wp_mail( (string) get_option( 'admin_email' ), sprintf( '[%1$s] %2$s', wp_specialchars_decode( (string) get_option( 'blogname' ), ENT_QUOTES ), $summary ), $detail . "\n\n" . admin_url( 'admin.php?page=storesuit-settings#ss-health' ) ); } }