diff --git a/.gitignore b/.gitignore index e890337a2..38bd80d0d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ !/tmp/storage/.keep /data +*.sqlite3 +*.sqlite3_* + /public/assets # Ignore master key for decrypting credentials and more. diff --git a/.rubocop.yml b/.rubocop.yml index 88329d008..16773cd4c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -10,3 +10,4 @@ inherit_gem: { rubocop-rails-omakase: rubocop.yml } AllCops: Exclude: - 'db/migrate/**/*' + - 'db/schema*.rb' diff --git a/app/models/application_record.rb b/app/models/application_record.rb index a043d384f..45e9c2f21 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,7 +1,7 @@ class ApplicationRecord < ActiveRecord::Base primary_abstract_class - connects_to database: { writing: :primary, reading: :replica } + configure_replica_connections attribute :id, :uuid, default: -> { ActiveRecord::Type::Uuid.generate } end diff --git a/app/models/board/accessible.rb b/app/models/board/accessible.rb index c923949d0..2f668ae4f 100644 --- a/app/models/board/accessible.rb +++ b/app/models/board/accessible.rb @@ -66,8 +66,7 @@ module Board::Accessible # # 1. Mention->Card # 2. Mention->Comment->Card - uuid_type = ActiveRecord::Type.lookup(:uuid, adapter: :trilogy) - board_id_binary = uuid_type.serialize(id) + board_id_binary = ActiveRecord::Type::Uuid.new.serialize(id) user.mentions .joins("LEFT JOIN cards ON mentions.source_id = cards.id AND mentions.source_type = 'Card'") diff --git a/app/models/card/entropic.rb b/app/models/card/entropic.rb index 85132ebe7..ebaad78c7 100644 --- a/app/models/card/entropic.rb +++ b/app/models/card/entropic.rb @@ -7,7 +7,7 @@ module Card::Entropic .joins(board: :account) .left_outer_joins(board: :entropy) .joins("LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id") - .where("last_active_at <= DATE_SUB(?, INTERVAL COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period) SECOND)", Time.now) + .where("last_active_at <= #{connection.date_subtract('?', 'COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)')}", Time.now) end scope :postponing_soon, -> do @@ -16,8 +16,8 @@ module Card::Entropic .joins(board: :account) .left_outer_joins(board: :entropy) .joins("LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id") - .where("last_active_at > DATE_SUB(?, INTERVAL COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period) SECOND)", now) - .where("last_active_at <= DATE_SUB(?, INTERVAL CAST(COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period) * 0.75 AS SIGNED) SECOND)", now) + .where("last_active_at > #{connection.date_subtract('?', 'COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)')}", now) + .where("last_active_at <= #{connection.date_subtract('?', 'COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period) * 0.75')}", now) end delegate :auto_postpone_period, to: :board diff --git a/app/models/card/searchable.rb b/app/models/card/searchable.rb index 81e704e43..8cc108b23 100644 --- a/app/models/card/searchable.rb +++ b/app/models/card/searchable.rb @@ -5,10 +5,8 @@ module Card::Searchable include ::Searchable scope :mentioning, ->(query, user:) do - search_records = Search::Record.for_account(user.account_id) - - joins(search_records.card_join) - .merge(search_records.for_query(query: Search::Query.wrap(query), user: user)) + search_record_class = Search::Record.for(user.account_id) + joins(search_record_class.card_join).merge(search_record_class.for_query(query, user: user)) end end diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb index 5b1d5a1bb..8b39504ef 100644 --- a/app/models/concerns/searchable.rb +++ b/app/models/concerns/searchable.rb @@ -13,37 +13,34 @@ module Searchable private def create_in_search_index - Search::Record.for_account(account_id).create!(search_record_attributes) + search_record_class.create!(search_record_attributes) end def update_in_search_index - Search::Record.for_account(account_id).upsert_all( - [ search_record_attributes.merge(id: ActiveRecord::Type::Uuid.generate) ], - update_only: [ :card_id, :board_id, :title, :content, :created_at, :account_key ] - ) + search_record_class.upsert!(search_record_attributes) end def remove_from_search_index - Search::Record.for_account(account_id).where( - searchable_type: self.class.name, - searchable_id: id - ).delete_all + search_record_class.find_by(searchable_type: self.class.name, searchable_id: id)&.destroy end def search_record_attributes { account_id: account_id, - account_key: "account#{account_id}", searchable_type: self.class.name, searchable_id: id, card_id: search_card_id, board_id: search_board_id, - title: Search::Stemmer.stem(search_title), - content: Search::Stemmer.stem(search_content), + title: search_title, + content: search_content, created_at: created_at } end + def search_record_class + Search::Record.for(account_id) + end + # Models must implement these methods: # - account_id: returns the account id # - search_title: returns title string or nil diff --git a/app/models/search.rb b/app/models/search.rb index 264194f03..f81b9a9d8 100644 --- a/app/models/search.rb +++ b/app/models/search.rb @@ -2,8 +2,4 @@ module Search def self.table_name_prefix "search_" end - - def self.results(query:, user:) - Record.for_account(user.account_id).search(query: Query.wrap(query), user: user) - end end diff --git a/app/models/search/query.rb b/app/models/search/query.rb index d54142f9e..5fe0829e4 100644 --- a/app/models/search/query.rb +++ b/app/models/search/query.rb @@ -5,6 +5,8 @@ class Search::Query < ApplicationRecord validates :terms, presence: true before_validation :sanitize_terms + delegate :to_s, to: :terms + class << self def wrap(query) if query.is_a?(self) @@ -15,10 +17,6 @@ class Search::Query < ApplicationRecord end end - def to_s - Search::Stemmer.stem(terms.to_s) - end - private def sanitize_terms self.terms = sanitize(terms) diff --git a/app/models/search/record.rb b/app/models/search/record.rb index 1bf933a53..2aaff7c24 100644 --- a/app/models/search/record.rb +++ b/app/models/search/record.rb @@ -1,33 +1,20 @@ class Search::Record < ApplicationRecord - self.abstract_class = true - - SHARD_COUNT = 16 - - SHARD_CLASSES = SHARD_COUNT.times.map do |shard_id| - Class.new(self) do - self.table_name = "search_records_#{shard_id}" - - def self.name - "Search::Record" - end - end - end.freeze + include const_get(connection.adapter_name) belongs_to :searchable, polymorphic: true belongs_to :card - # Virtual attributes from search query - attribute :query, :string - validates :account_id, :searchable_type, :searchable_id, :card_id, :board_id, :created_at, presence: true class << self - def for_account(account_id) - SHARD_CLASSES[shard_id_for_account(account_id)] - end - - def shard_id_for_account(account_id) - Zlib.crc32(account_id.to_s) % SHARD_COUNT + def upsert!(attributes) + record = find_by(searchable_type: attributes[:searchable_type], searchable_id: attributes[:searchable_id]) + if record + record.update!(attributes) + record + else + create!(attributes) + end end def card_join @@ -35,29 +22,23 @@ class Search::Record < ApplicationRecord end end - scope :for_query, ->(query:, user:) do + scope :for_query, ->(query, user:) do + query = Search::Query.wrap(query) + if query.valid? && user.board_ids.any? - matching(query.to_s, user.account_id).for_user(user) + matching(query.to_s, user.account_id).where(account_id: user.account_id, board_id: user.board_ids) else none end end - scope :matching, ->(query, account_id) do - account_key = "account#{account_id}" - full_query = "+#{account_key} +(#{query})" - where("MATCH(#{table_name}.account_key, #{table_name}.content, #{table_name}.title) AGAINST(? IN BOOLEAN MODE)", full_query) - end + scope :search, ->(query, user:) do + query = Search::Query.wrap(query) - scope :for_user, ->(user) do - where(account_id: user.account_id, board_id: user.board_ids) - end - - scope :search, ->(query:, user:) do - for_query(query: query, user: user) + for_query(query, user: user) .includes(:searchable, card: [ :board, :creator ]) - .select(:id, :searchable_type, :searchable_id, :card_id, :board_id, :account_id, :created_at, "#{connection.quote(query.terms)} AS query") .order(created_at: :desc) + .select(:id, :account_id, :searchable_type, :searchable_id, :card_id, :board_id, :title, :content, :created_at, *search_fields(query)) end def source @@ -67,26 +48,4 @@ class Search::Record < ApplicationRecord def comment searchable if searchable_type == "Comment" end - - def card_title - highlight(card.title, show: :full) if card_id - end - - def card_description - highlight(card.description.to_plain_text, show: :snippet) if card_id - end - - def comment_body - highlight(comment.body.to_plain_text, show: :snippet) if comment - end - - private - def highlight(text, show:) - if text.present? && attribute?(:query) - highlighter = Search::Highlighter.new(query) - show == :snippet ? highlighter.snippet(text) : highlighter.highlight(text) - else - text - end - end end diff --git a/app/models/search/record/sqlite.rb b/app/models/search/record/sqlite.rb new file mode 100644 index 000000000..c3da9131a --- /dev/null +++ b/app/models/search/record/sqlite.rb @@ -0,0 +1,59 @@ +module Search::Record::SQLite + extend ActiveSupport::Concern + + included do + # Override default UUID id attribute, as FTS5 uses rowid integer primary key + attribute :id, :integer, default: nil + attribute :result_title, :string + attribute :result_content, :string + + has_one :search_records_fts, -> { with_rowid }, + class_name: "Search::Record::SQLite::Fts", foreign_key: :rowid, primary_key: :id, dependent: :destroy + + after_save :upsert_to_fts5_table + + scope :matching, ->(query, account_id) { joins(:search_records_fts).where("search_records_fts MATCH ?", query) } + end + + class_methods do + def search_fields(query) + opening_mark = connection.quote(Search::Highlighter::OPENING_MARK) + closing_mark = connection.quote(Search::Highlighter::CLOSING_MARK) + ellipsis = connection.quote(Search::Highlighter::ELIPSIS) + + [ "highlight(search_records_fts, 0, #{opening_mark}, #{closing_mark}) AS result_title", + "snippet(search_records_fts, 1, #{opening_mark}, #{closing_mark}, #{ellipsis}, 20) AS result_content", + "#{connection.quote(query.terms)} AS query" ] + end + + def for(account_id) + self + end + end + + def card_title + escape_fts_highlight(result_title || card.title) + end + + def card_description + escape_fts_highlight(result_content) unless comment + end + + def comment_body + escape_fts_highlight(result_content) if comment + end + + private + def escape_fts_highlight(html) + return nil unless html.present? + + CGI.escapeHTML(html) + .gsub(CGI.escapeHTML(Search::Highlighter::OPENING_MARK), Search::Highlighter::OPENING_MARK) + .gsub(CGI.escapeHTML(Search::Highlighter::CLOSING_MARK), Search::Highlighter::CLOSING_MARK) + .html_safe + end + + def upsert_to_fts5_table + Fts.upsert(id, title, content) + end +end diff --git a/app/models/search/record/sqlite/fts.rb b/app/models/search/record/sqlite/fts.rb new file mode 100644 index 000000000..ed7b378c5 --- /dev/null +++ b/app/models/search/record/sqlite/fts.rb @@ -0,0 +1,21 @@ +class Search::Record::SQLite::Fts < ApplicationRecord + self.table_name = "search_records_fts" + self.primary_key = "rowid" + + # FTS5 virtual table columns + attribute :rowid, :integer + attribute :title, :string + attribute :content, :string + + # FTS5 virtual tables don't expose rowid in the schema by default + # We need to explicitly select it when loading records + scope :with_rowid, -> { select(:rowid, :title, :content) } + + def self.upsert(rowid, title, content) + connection.exec_query( + "INSERT OR REPLACE INTO search_records_fts(rowid, title, content) VALUES (?, ?, ?)", + "Search::Record::SQLite::Fts Upsert", + [ rowid, title, content ] + ) + end +end diff --git a/app/models/search/record/trilogy.rb b/app/models/search/record/trilogy.rb new file mode 100644 index 000000000..7705f3b71 --- /dev/null +++ b/app/models/search/record/trilogy.rb @@ -0,0 +1,70 @@ +module Search::Record::Trilogy + extend ActiveSupport::Concern + + SHARD_COUNT = 16 + + included do + self.abstract_class = true + before_save :set_account_key, :stem_content + + scope :matching, ->(query, account_id) do + full_query = "+account#{account_id} +(#{Search::Stemmer.stem(query)})" + where("MATCH(#{table_name}.account_key, #{table_name}.content, #{table_name}.title) AGAINST(? IN BOOLEAN MODE)", full_query) + end + + SHARD_CLASSES = SHARD_COUNT.times.map do |shard_id| + Class.new(self) do + self.table_name = "search_records_#{shard_id}" + + def self.name + "Search::Record" + end + end + end.freeze + end + + class_methods do + def shard_id_for_account(account_id) + Zlib.crc32(account_id.to_s) % SHARD_COUNT + end + + def search_fields(query) + "#{connection.quote(query.terms)} AS query" + end + + def for(account_id) + SHARD_CLASSES[shard_id_for_account(account_id)] + end + end + + def card_title + highlight(card.title, show: :full) if card_id + end + + def card_description + highlight(card.description.to_plain_text, show: :snippet) if card_id + end + + def comment_body + highlight(comment.body.to_plain_text, show: :snippet) if comment + end + + private + def stem_content + self.title = Search::Stemmer.stem(title) if title_changed? + self.content = Search::Stemmer.stem(content) if content_changed? + end + + def set_account_key + self.account_key = "account#{account_id}" + end + + def highlight(text, show:) + if text.present? && attribute?(:query) + highlighter = Search::Highlighter.new(query) + show == :snippet ? highlighter.snippet(text) : highlighter.highlight(text) + else + text + end + end +end diff --git a/app/models/user/searcher.rb b/app/models/user/searcher.rb index 951717082..090adf4b8 100644 --- a/app/models/user/searcher.rb +++ b/app/models/user/searcher.rb @@ -6,7 +6,7 @@ module User::Searcher end def search(terms) - Search.results(query: terms, user: self) + Search::Record.for(account_id).search(terms, user: self) end def remember_search(terms) diff --git a/config/brakeman.ignore b/config/brakeman.ignore index d51b4a4f3..6c99b817b 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,5 +1,28 @@ { "ignored_warnings": [ + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "4ea2e6d704b817af1d896dcdf148a89da8b9e428b3327497631ef8d9ed587307", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/models/card/entropic.rb", + "line": 19, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "active.joins(:board => :account).left_outer_joins(:board => :entropy).joins(\"LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id\").where(\"last_active_at > #{connection.date_subtract(\"?\", \"COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)\")}\", Time.now)", + "render_path": null, + "location": { + "type": "method", + "class": "Card::Entropic", + "method": null + }, + "user_input": "connection.date_subtract(\"?\", \"COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)\")", + "confidence": "Weak", + "cwe_id": [ + 89 + ], + "note": "No user input allowed" + }, { "warning_type": "Dangerous Send", "warning_code": 23, @@ -68,6 +91,29 @@ 470 ], "note": "" + }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "bbd8fe3cbbc6b393df770d3142d6fa46e2b9441b21f48a52175211acaae4efad", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/models/card/entropic.rb", + "line": 10, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "active.joins(:board => :account).left_outer_joins(:board => :entropy).joins(\"LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id\").where(\"last_active_at <= #{connection.date_subtract(\"?\", \"COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)\")}\", Time.now)", + "render_path": null, + "location": { + "type": "method", + "class": "Card::Entropic", + "method": null + }, + "user_input": "connection.date_subtract(\"?\", \"COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)\")", + "confidence": "Weak", + "cwe_id": [ + 89 + ], + "note": "No user input allowed" } ], "brakeman_version": "7.1.0" diff --git a/config/database.yml b/config/database.yml index fc05ed89c..913ba7cba 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,4 +1,7 @@ <% + database_adapter = ENV.fetch("DATABASE_ADAPTER", "mysql") + use_sqlite = database_adapter == "sqlite" + if ENV["MIGRATE"].present? mysql_app_user_key = "MYSQL_ALTER_USER" mysql_app_password_key = "MYSQL_ALTER_PASSWORD" @@ -14,6 +17,11 @@ %> default: &default + <% if use_sqlite %> + adapter: sqlite3 + pool: 5 + timeout: 5000 + <% else %> adapter: trilogy host: <%= ENV.fetch "FIZZY_DB_HOST", "127.0.0.1" %> port: <%= ENV.fetch "FIZZY_DB_PORT", 3306 %> @@ -22,8 +30,15 @@ default: &default variables: transaction_isolation: READ-COMMITTED max_execution_time: <%= max_execution_time_ms %> + <% end %> development: + <% if use_sqlite %> + primary: + <<: *default + database: storage/development.sqlite3 + schema_dump: schema_sqlite.rb + <% else %> primary: <<: *default database: fizzy_development @@ -31,28 +46,35 @@ development: replica: <<: *default database: fizzy_development - replica: true port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + replica: true cable: <<: *default database: development_cable - migrations_paths: db/cable_migrate port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + migrations_paths: db/cable_migrate cache: <<: *default database: development_cache - migrations_paths: db/cache_migrate port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + migrations_paths: db/cache_migrate queue: <<: *default database: development_queue - migrations_paths: db/queue_migrate port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + migrations_paths: db/queue_migrate + <% end %> # Warning: The database defined as "test" will be erased and # re-generated from your development database when you run "rake". # Do not set this db to the same as development or production. test: + <% if use_sqlite %> + primary: + <<: *default + database: storage/test.sqlite3 + schema_dump: schema_sqlite.rb + <% else %> primary: <<: *default database: fizzy_test @@ -62,6 +84,7 @@ test: database: fizzy_test port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> replica: true + <% end %> production: &production primary: diff --git a/config/initializers/active_storage.rb b/config/initializers/active_storage.rb index de51ae1f1..d00bd74bf 100644 --- a/config/initializers/active_storage.rb +++ b/config/initializers/active_storage.rb @@ -4,9 +4,8 @@ ActiveSupport.on_load(:active_storage_blob) do end end -# Use DB read/write splitting for Active Storage models ActiveSupport.on_load(:active_storage_record) do - connects_to database: { writing: :primary, reading: :replica } + configure_replica_connections end module ActiveStorageControllerExtensions diff --git a/config/initializers/database_role_logging.rb b/config/initializers/database_role_logging.rb index 698d52080..e2b8779b8 100644 --- a/config/initializers/database_role_logging.rb +++ b/config/initializers/database_role_logging.rb @@ -1,3 +1,5 @@ +require_relative "extensions" + class DatabaseRoleLogger def initialize(app) @app = app @@ -10,4 +12,6 @@ class DatabaseRoleLogger end end -Rails.application.config.middleware.insert_after ActiveRecord::Middleware::DatabaseSelector, DatabaseRoleLogger +if ActiveRecord::Base.replica_configured? + Rails.application.config.middleware.insert_after ActiveRecord::Middleware::DatabaseSelector, DatabaseRoleLogger +end diff --git a/config/initializers/multi_db.rb b/config/initializers/multi_db.rb index 55c776c92..f71495004 100644 --- a/config/initializers/multi_db.rb +++ b/config/initializers/multi_db.rb @@ -1,7 +1,10 @@ require "deployment" +require_relative "extensions" +if ActiveRecord::Base.replica_configured? Rails.application.configure do config.active_record.database_selector = { delay: 0.seconds } config.active_record.database_resolver = Deployment::DatabaseResolver config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session + end end diff --git a/config/initializers/sqlite_schema_dumper.rb b/config/initializers/sqlite_schema_dumper.rb new file mode 100644 index 000000000..782c0b6b2 --- /dev/null +++ b/config/initializers/sqlite_schema_dumper.rb @@ -0,0 +1,22 @@ +# Fix for SQLite FTS5 virtual table schema dumping +# Rails has a bug where it doesn't handle FTS5 content= and content_rowid= options + +module SQLiteFTS5SchemaDumperFix + # Override the virtual_tables method to handle FTS5 syntax properly + def virtual_tables(stream) + # Query sqlite_master for all virtual tables + virtual_table_sqls = @connection.select_rows( + "SELECT name, sql FROM sqlite_master WHERE type='table' AND sql LIKE 'CREATE VIRTUAL TABLE%'" + ) + + virtual_table_sqls.each do |table_name, sql| + # Just output the raw SQL since create_virtual_table doesn't handle our syntax + stream.puts " execute #{sql.inspect}" + stream.puts + end + end +end + +ActiveSupport.on_load(:active_record_sqlite3adapter) do + ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.prepend(SQLiteFTS5SchemaDumperFix) +end diff --git a/config/initializers/table_definition_column_limits.rb b/config/initializers/table_definition_column_limits.rb new file mode 100644 index 000000000..ff01c5e3a --- /dev/null +++ b/config/initializers/table_definition_column_limits.rb @@ -0,0 +1,70 @@ +# Apply MySQL-compatible column limits when defining tables. +# +# For string columns: defaults to 255 (MySQL's VARCHAR default) +# +# For text columns: converts MySQL's `size:` option to equivalent limits: +# - (blank/default): 65,535 (TEXT) +# - size: :tiny: 255 (TINYTEXT) +# - size: :medium: 16,777,215 (MEDIUMTEXT) +# - size: :long: 4,294,967,295 (LONGTEXT) +# + +module TableDefinitionColumnLimits + TEXT_SIZE_TO_LIMIT = { + tiny: 255, + medium: 16_777_215, + long: 4_294_967_295 + }.freeze + + TEXT_DEFAULT_LIMIT = 65_535 + STRING_DEFAULT_LIMIT = 255 + + def column(name, type, **options) + if type == :string + options[:limit] ||= STRING_DEFAULT_LIMIT + end + + if type == :text + if options.key?(:size) + size = options.delete(:size) + options[:limit] ||= TEXT_SIZE_TO_LIMIT.fetch(size) do + raise ArgumentError, "Unknown text size: #{size.inspect}. Use :tiny, :medium, or :long" + end + else + options[:limit] ||= TEXT_DEFAULT_LIMIT + end + end + + super + end +end + +# For SQLite: append inline CHECK constraints to enforce string/text length limits. +# since SQLite doesn't natively enforce VARCHAR/TEXT length limits. +module SQLiteColumnLimitCheckConstraints + def add_column_options!(sql, options) + super + + column = options[:column] + if column && column.limit && %i[string text].include?(column.type) + check_expr = if column.type == :string + # VARCHAR limits are in characters + %(length("#{column.name}") <= #{column.limit}) + else + # TEXT limits are in bytes + %(length(CAST("#{column.name}" AS BLOB)) <= #{column.limit}) + end + sql << " CHECK(#{check_expr})" + end + + sql + end +end + +ActiveSupport.on_load(:active_record) do + ActiveRecord::ConnectionAdapters::TableDefinition.prepend(TableDefinitionColumnLimits) +end + +ActiveSupport.on_load(:active_record_sqlite3adapter) do + ActiveRecord::ConnectionAdapters::SQLite3::SchemaCreation.prepend(SQLiteColumnLimitCheckConstraints) +end diff --git a/config/initializers/tenanting/account_slug.rb b/config/initializers/tenanting/account_slug.rb index dffbb6bf8..9d6d83ca7 100644 --- a/config/initializers/tenanting/account_slug.rb +++ b/config/initializers/tenanting/account_slug.rb @@ -43,6 +43,4 @@ module AccountSlug def self.encode(id) FORMAT % id end end -Rails.application.config.middleware.tap do |stack| - stack.insert_before ActiveRecord::Middleware::DatabaseSelector, AccountSlug::Extractor -end +Rails.application.config.middleware.insert_after Rack::TempfileReaper, AccountSlug::Extractor diff --git a/config/initializers/uuid_primary_keys.rb b/config/initializers/uuid_primary_keys.rb index 560f5b384..56ff7f3af 100644 --- a/config/initializers/uuid_primary_keys.rb +++ b/config/initializers/uuid_primary_keys.rb @@ -1,41 +1,83 @@ # Automatically use UUID type for all binary(16) columns -ActiveSupport.on_load(:active_record) do - module MysqlUuidAdapter - # Add UUID to MySQL's native database types + +module MysqlUuidAdapter + extend ActiveSupport::Concern + + # Override lookup_cast_type to recognize binary(16) as UUID type + def lookup_cast_type(sql_type) + if sql_type == "binary(16)" + ActiveRecord::Type.lookup(:uuid, adapter: :trilogy) + else + super + end + end + + class_methods do def native_database_types @native_database_types_with_uuid ||= super.merge(uuid: { name: "binary", limit: 16 }) end + end +end - # Override lookup_cast_type to recognize binary(16) as UUID type - def lookup_cast_type(sql_type) - if sql_type == "binary(16)" - ActiveRecord::Type.lookup(:uuid, adapter: :trilogy) - else - super - end +module SqliteUuidAdapter + extend ActiveSupport::Concern + + # Override lookup_cast_type to recognize BLOB as UUID type + def lookup_cast_type(sql_type) + if sql_type == "blob(16)" + ActiveRecord::Type.lookup(:uuid, adapter: :sqlite3) + else + super end end - ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter.prepend(MysqlUuidAdapter) - - module SchemaDumperUuidType - # Map binary(16) columns to :uuid type in schema.rb - def schema_type(column) - if column.sql_type == "binary(16)" - :uuid - else - super - end + # Override fetch_type_metadata to preserve UUID type and limit + def fetch_type_metadata(sql_type) + if sql_type == "blob(16)" + ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new( + sql_type: sql_type, + type: :uuid, + limit: 16 + ) + else + super end end - ActiveRecord::ConnectionAdapters::MySQL::SchemaDumper.prepend(SchemaDumperUuidType) - - module TableDefinitionUuidSupport - def uuid(name, **options) - column(name, :uuid, **options) + class_methods do + def native_database_types + @native_database_types_with_uuid ||= super.merge(uuid: { name: "blob", limit: 16 }) end end +end +module SchemaDumperUuidType + # Map binary(16) and blob(16) columns to :uuid type in schema.rb + def schema_type(column) + if column.sql_type == "binary(16)" || column.sql_type == "blob(16)" + :uuid + else + super + end + end +end + +module TableDefinitionUuidSupport + def uuid(name, **options) + column(name, :uuid, **options) + end +end + +ActiveSupport.on_load(:active_record) do ActiveRecord::ConnectionAdapters::TableDefinition.prepend(TableDefinitionUuidSupport) end + +ActiveSupport.on_load(:active_record_trilogyadapter) do + ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter.prepend(MysqlUuidAdapter) + ActiveRecord::ConnectionAdapters::MySQL::SchemaDumper.prepend(SchemaDumperUuidType) +end + +ActiveSupport.on_load(:active_record_sqlite3adapter) do + ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend(SqliteUuidAdapter) + ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.prepend(SchemaDumperUuidType) +end diff --git a/db/migrate/20251111122540_initial_schema.rb b/db/migrate/20251111122540_initial_schema.rb index 4b736f712..c7afd79ef 100644 --- a/db/migrate/20251111122540_initial_schema.rb +++ b/db/migrate/20251111122540_initial_schema.rb @@ -4,7 +4,7 @@ class InitialSchema < ActiveRecord::Migration[8.2] t.datetime "accessed_at" t.uuid "board_id", null: false t.datetime "created_at", null: false - t.string "involvement", default: "access_only", null: false + t.string "involvement", limit: 255, default: "access_only", null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["accessed_at"], name: "index_accesses_on_accessed_at", order: :desc @@ -15,7 +15,7 @@ class InitialSchema < ActiveRecord::Migration[8.2] create_table "account_join_codes", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" - t.string "code", null: false + t.string "code", limit: 255, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "usage_count", default: 0, null: false @@ -26,7 +26,7 @@ class InitialSchema < ActiveRecord::Migration[8.2] create_table "accounts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.datetime "created_at", null: false t.integer "external_account_id" - t.string "name", null: false + t.string "name", limit: 255, null: false t.datetime "updated_at", null: false t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true end @@ -34,9 +34,9 @@ class InitialSchema < ActiveRecord::Migration[8.2] create_table "action_text_rich_texts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.text "body", size: :long t.datetime "created_at", null: false - t.string "name", null: false + t.string "name", limit: 255, null: false t.uuid "record_id", null: false - t.string "record_type", null: false + t.string "record_type", limit: 255, null: false t.datetime "updated_at", null: false t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true end @@ -44,28 +44,28 @@ class InitialSchema < ActiveRecord::Migration[8.2] create_table "active_storage_attachments", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "blob_id", null: false t.datetime "created_at", null: false - t.string "name", null: false + t.string "name", limit: 255, null: false t.uuid "record_id", null: false - t.string "record_type", null: false + t.string "record_type", limit: 255, null: false t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end create_table "active_storage_blobs", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.bigint "byte_size", null: false - t.string "checksum" - t.string "content_type" + t.string "checksum", limit: 255 + t.string "content_type", limit: 255 t.datetime "created_at", null: false - t.string "filename", null: false - t.string "key", null: false + t.string "filename", limit: 255, null: false + t.string "key", limit: 255, null: false t.text "metadata" - t.string "service_name", null: false + t.string "service_name", limit: 255, null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end create_table "active_storage_variant_records", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "blob_id", null: false - t.string "variation_digest", null: false + t.string "variation_digest", limit: 255, null: false t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end @@ -96,7 +96,7 @@ class InitialSchema < ActiveRecord::Migration[8.2] create_table "board_publications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "board_id", null: false t.datetime "created_at", null: false - t.string "key" + t.string "key", limit: 255 t.datetime "updated_at", null: false t.index ["board_id"], name: "index_board_publications_on_board_id" t.index ["key"], name: "index_board_publications_on_key", unique: true @@ -107,7 +107,7 @@ class InitialSchema < ActiveRecord::Migration[8.2] t.boolean "all_access", default: false, null: false t.datetime "created_at", null: false t.uuid "creator_id", null: false - t.string "name", null: false + t.string "name", limit: 255, null: false t.datetime "updated_at", null: false t.index ["creator_id"], name: "index_boards_on_creator_id" end @@ -129,7 +129,7 @@ class InitialSchema < ActiveRecord::Migration[8.2] create_table "card_engagements", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "card_id" t.datetime "created_at", null: false - t.string "status", default: "doing", null: false + t.string "status", limit: 255, default: "doing", null: false t.datetime "updated_at", null: false t.index ["card_id"], name: "index_card_engagements_on_card_id" t.index ["status"], name: "index_card_engagements_on_status" @@ -159,8 +159,8 @@ class InitialSchema < ActiveRecord::Migration[8.2] t.uuid "creator_id", null: false t.date "due_on" t.datetime "last_active_at", null: false - t.string "status", default: "drafted", null: false - t.string "title" + t.string "status", limit: 255, default: "drafted", null: false + t.string "title", limit: 255 t.datetime "updated_at", null: false t.index ["board_id"], name: "index_cards_on_board_id" t.index ["column_id"], name: "index_cards_on_column_id" @@ -187,9 +187,9 @@ class InitialSchema < ActiveRecord::Migration[8.2] create_table "columns", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" t.uuid "board_id", null: false - t.string "color", null: false + t.string "color", limit: 255, null: false t.datetime "created_at", null: false - t.string "name", null: false + t.string "name", limit: 255, null: false t.integer "position", default: 0, null: false t.datetime "updated_at", null: false t.index ["board_id", "position"], name: "index_columns_on_board_id_and_position" @@ -215,7 +215,7 @@ class InitialSchema < ActiveRecord::Migration[8.2] create_table "entropies", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.bigint "auto_postpone_period", default: 2592000, null: false t.uuid "container_id", null: false - t.string "container_type", null: false + t.string "container_type", limit: 255, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["container_type", "container_id", "auto_postpone_period"], name: "idx_on_container_type_container_id_auto_postpone_pe_3d79b50517" @@ -224,12 +224,12 @@ class InitialSchema < ActiveRecord::Migration[8.2] create_table "events", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" - t.string "action", null: false + t.string "action", limit: 255, null: false t.uuid "board_id", null: false t.datetime "created_at", null: false t.uuid "creator_id", null: false t.uuid "eventable_id", null: false - t.string "eventable_type", null: false + t.string "eventable_type", limit: 255, null: false t.json "particulars", default: -> { "(json_object())" } t.datetime "updated_at", null: false t.index ["action"], name: "index_events_on_summary_id_and_action" @@ -244,7 +244,7 @@ class InitialSchema < ActiveRecord::Migration[8.2] t.datetime "created_at", null: false t.uuid "creator_id", null: false t.json "fields", default: -> { "(json_object())" }, null: false - t.string "params_digest", null: false + t.string "params_digest", limit: 255, null: false t.datetime "updated_at", null: false t.index ["creator_id", "params_digest"], name: "index_filters_on_creator_id_and_params_digest", unique: true end @@ -258,13 +258,13 @@ class InitialSchema < ActiveRecord::Migration[8.2] create_table "identities", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.datetime "created_at", null: false - t.string "email_address", null: false + t.string "email_address", limit: 255, null: false t.datetime "updated_at", null: false t.index ["email_address"], name: "index_identities_on_email_address", unique: true end create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| - t.string "code", null: false + t.string "code", limit: 255, null: false t.datetime "created_at", null: false t.datetime "expires_at", null: false t.uuid "identity_id" @@ -277,8 +277,8 @@ class InitialSchema < ActiveRecord::Migration[8.2] create_table "memberships", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.datetime "created_at", null: false t.uuid "identity_id", null: false - t.string "join_code" - t.string "tenant", null: false + t.string "join_code", limit: 255 + t.string "tenant", limit: 255, null: false t.datetime "updated_at", null: false t.index ["identity_id"], name: "index_memberships_on_identity_id" t.index ["tenant", "identity_id"], name: "index_memberships_on_tenant_and_identity_id", unique: true @@ -290,7 +290,7 @@ class InitialSchema < ActiveRecord::Migration[8.2] t.uuid "mentionee_id", null: false t.uuid "mentioner_id", null: false t.uuid "source_id", null: false - t.string "source_type", null: false + t.string "source_type", limit: 255, null: false t.datetime "updated_at", null: false t.index ["mentionee_id"], name: "index_mentions_on_mentionee_id" t.index ["mentioner_id"], name: "index_mentions_on_mentioner_id" @@ -316,7 +316,7 @@ class InitialSchema < ActiveRecord::Migration[8.2] t.uuid "creator_id" t.datetime "read_at" t.uuid "source_id", null: false - t.string "source_type", null: false + t.string "source_type", limit: 255, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["creator_id"], name: "index_notifications_on_creator_id" @@ -337,12 +337,12 @@ class InitialSchema < ActiveRecord::Migration[8.2] create_table "push_subscriptions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" - t.string "auth_key" + t.string "auth_key", limit: 255 t.datetime "created_at", null: false - t.string "endpoint" - t.string "p256dh_key" + t.text "endpoint" + t.string "p256dh_key", limit: 255 t.datetime "updated_at", null: false - t.string "user_agent" + t.string "user_agent", limit: 255 t.uuid "user_id", null: false t.index ["endpoint", "p256dh_key", "auth_key"], name: "idx_on_endpoint_p256dh_key_auth_key_7553014576" t.index ["endpoint"], name: "index_push_subscriptions_on_endpoint" @@ -379,9 +379,9 @@ class InitialSchema < ActiveRecord::Migration[8.2] create_table "sessions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.datetime "created_at", null: false t.uuid "identity_id", null: false - t.string "ip_address" + t.string "ip_address", limit: 255 t.datetime "updated_at", null: false - t.string "user_agent" + t.string "user_agent", limit: 255 t.index ["identity_id"], name: "index_sessions_on_identity_id" end @@ -408,7 +408,7 @@ class InitialSchema < ActiveRecord::Migration[8.2] create_table "tags", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" t.datetime "created_at", null: false - t.string "title" + t.string "title", limit: 255 t.datetime "updated_at", null: false t.index ["title"], name: "index_tags_on_title", unique: true end @@ -416,7 +416,7 @@ class InitialSchema < ActiveRecord::Migration[8.2] create_table "user_settings", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.integer "bundle_email_frequency", default: 0, null: false t.datetime "created_at", null: false - t.string "timezone_name" + t.string "timezone_name", limit: 255 t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["user_id", "bundle_email_frequency"], name: "index_user_settings_on_user_id_and_bundle_email_frequency" @@ -428,8 +428,8 @@ class InitialSchema < ActiveRecord::Migration[8.2] t.boolean "active", default: true, null: false t.datetime "created_at", null: false t.uuid "membership_id" - t.string "name", null: false - t.string "role", default: "member", null: false + t.string "name", limit: 255, null: false + t.string "role", limit: 255, default: "member", null: false t.datetime "updated_at", null: false t.index ["membership_id"], name: "index_users_on_membership_id" t.index ["role"], name: "index_users_on_role" @@ -460,7 +460,7 @@ class InitialSchema < ActiveRecord::Migration[8.2] t.uuid "event_id", null: false t.text "request" t.text "response" - t.string "state", null: false + t.string "state", limit: 255, null: false t.datetime "updated_at", null: false t.uuid "webhook_id", null: false t.index ["event_id"], name: "index_webhook_deliveries_on_event_id" @@ -472,8 +472,8 @@ class InitialSchema < ActiveRecord::Migration[8.2] t.boolean "active", default: true, null: false t.uuid "board_id", null: false t.datetime "created_at", null: false - t.string "name" - t.string "signing_secret", null: false + t.string "name", limit: 255 + t.string "signing_secret", limit: 255, null: false t.text "subscribed_actions" t.datetime "updated_at", null: false t.text "url", null: false diff --git a/db/migrate/20251112093037_create_search_indices.rb b/db/migrate/20251112093037_create_search_indices.rb index 441284590..26ca71ee5 100644 --- a/db/migrate/20251112093037_create_search_indices.rb +++ b/db/migrate/20251112093037_create_search_indices.rb @@ -1,5 +1,8 @@ class CreateSearchIndices < ActiveRecord::Migration[8.2] def up + # Skip for SQLite - it doesn't use these tables + return if connection.adapter_name == "SQLite" + 16.times do |i| create_table "search_index_#{i}".to_sym, id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.string :searchable_type, null: false @@ -17,6 +20,9 @@ class CreateSearchIndices < ActiveRecord::Migration[8.2] end def down + # Skip for SQLite - it doesn't use these tables + return if connection.adapter_name == "SQLite" + 16.times do |i| drop_table "search_index_#{i}".to_sym end diff --git a/db/migrate/20251113190256_create_search_record_shards.rb b/db/migrate/20251113190256_create_search_record_shards.rb index 5afb2ab8e..83f8f626e 100644 --- a/db/migrate/20251113190256_create_search_record_shards.rb +++ b/db/migrate/20251113190256_create_search_record_shards.rb @@ -2,6 +2,9 @@ class CreateSearchRecordShards < ActiveRecord::Migration[8.2] SHARD_COUNT = 16 def change + # Skip for SQLite - it uses a single search_records table instead + return if connection.adapter_name == "SQLite" + # Create 16 sharded search_records tables SHARD_COUNT.times do |shard_id| create_table "search_records_#{shard_id}", id: :uuid do |t| diff --git a/db/migrate/20251120110206_add_search_records.rb b/db/migrate/20251120110206_add_search_records.rb new file mode 100644 index 000000000..638128ec9 --- /dev/null +++ b/db/migrate/20251120110206_add_search_records.rb @@ -0,0 +1,37 @@ +class AddSearchRecords < ActiveRecord::Migration[8.2] + def up + return unless connection.adapter_name == "SQLite" + + # Create regular table with integer primary key for FTS5 rowid compatibility + create_table :search_records do |t| + t.uuid :account_id, null: false + t.string :searchable_type, limit: 255, null: false + t.uuid :searchable_id, null: false + t.uuid :card_id, null: false + t.uuid :board_id, null: false + t.string :title, limit: 255 + t.text :content + t.datetime :created_at, null: false + + t.index [:searchable_type, :searchable_id], unique: true + t.index :account_id + end + + # Create FTS5 virtual table using Porter stemmer + # No triggers needed - Searchable concern handles sync via callbacks + execute <<-SQL + CREATE VIRTUAL TABLE search_records_fts USING fts5( + title, + content, + tokenize='porter' + ) + SQL + end + + def down + return unless connection.adapter_name == "SQLite" + + execute "DROP TABLE IF EXISTS search_records_fts" + drop_table :search_records, if_exists: true + end +end diff --git a/db/migrate/20251120203100_add_unique_index_to_card_activity_spikes_on_card_id.rb b/db/migrate/20251120203100_add_unique_index_to_card_activity_spikes_on_card_id.rb index 850c8e604..b9f6ab3d4 100644 --- a/db/migrate/20251120203100_add_unique_index_to_card_activity_spikes_on_card_id.rb +++ b/db/migrate/20251120203100_add_unique_index_to_card_activity_spikes_on_card_id.rb @@ -1,13 +1,15 @@ class AddUniqueIndexToCardActivitySpikesOnCardId < ActiveRecord::Migration[8.2] def change - reversible do |dir| - dir.up do - execute <<-SQL - DELETE s1 FROM card_activity_spikes s1 - INNER JOIN card_activity_spikes s2 - WHERE s1.card_id = s2.card_id - AND s1.updated_at < s2.updated_at - SQL + if ActiveRecord::Base.connection.adapter_name != "SQLite" + reversible do |dir| + dir.up do + execute <<-SQL + DELETE s1 FROM card_activity_spikes s1 + INNER JOIN card_activity_spikes s2 + WHERE s1.card_id = s2.card_id + AND s1.updated_at < s2.updated_at + SQL + end end end diff --git a/db/migrate/20251121092508_add_account_key_to_search_records.rb b/db/migrate/20251121092508_add_account_key_to_search_records.rb index 8960f56bc..83aaf1988 100644 --- a/db/migrate/20251121092508_add_account_key_to_search_records.rb +++ b/db/migrate/20251121092508_add_account_key_to_search_records.rb @@ -1,5 +1,7 @@ class AddAccountKeyToSearchRecords < ActiveRecord::Migration[8.2] def up + return if ActiveRecord::Base.connection.adapter_name == "SQLite" + 16.times do |shard_id| table_name = "search_records_#{shard_id}" @@ -9,6 +11,8 @@ class AddAccountKeyToSearchRecords < ActiveRecord::Migration[8.2] end def down + return if ActiveRecord::Base.connection.adapter_name == "SQLite" + 16.times do |shard_id| table_name = "search_records_#{shard_id}" diff --git a/db/migrate/20251121112416_remove_old_fulltext_indexes_from_search_records.rb b/db/migrate/20251121112416_remove_old_fulltext_indexes_from_search_records.rb index f9d38dadf..1fbd67a28 100644 --- a/db/migrate/20251121112416_remove_old_fulltext_indexes_from_search_records.rb +++ b/db/migrate/20251121112416_remove_old_fulltext_indexes_from_search_records.rb @@ -1,14 +1,15 @@ class RemoveOldFulltextIndexesFromSearchRecords < ActiveRecord::Migration[8.2] def up - # Remove the old fulltext indexes (content, title) from all 16 search_records shards - # We're keeping the new indexes (account_key, content, title) for tenant-aware searching + return if ActiveRecord::Base.connection.adapter_name == "SQLite" + (0..15).each do |shard| remove_index "search_records_#{shard}", name: "index_search_records_#{shard}_on_content_and_title" end end def down - # Re-create the old fulltext indexes in case we need to rollback + return if ActiveRecord::Base.connection.adapter_name == "SQLite" + (0..15).each do |shard| add_index "search_records_#{shard}", [ :content, :title ], type: :fulltext, name: "index_search_records_#{shard}_on_content_and_title" end diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb new file mode 100644 index 000000000..fe2aad115 --- /dev/null +++ b/db/schema_sqlite.rb @@ -0,0 +1,543 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.2].define(version: 2025_11_25_130010) do + create_table "accesses", id: :uuid, force: :cascade do |t| + t.datetime "accessed_at" + t.uuid "account_id", null: false + t.uuid "board_id", null: false + t.datetime "created_at", null: false + t.string "involvement", limit: 255, default: "access_only", null: false + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id", "accessed_at"], name: "index_accesses_on_account_id_and_accessed_at" + t.index ["board_id", "user_id"], name: "index_accesses_on_board_id_and_user_id", unique: true + t.index ["board_id"], name: "index_accesses_on_board_id" + t.index ["user_id"], name: "index_accesses_on_user_id" + end + + create_table "account_join_codes", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.string "code", limit: 255, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "usage_count", default: 0, null: false + t.bigint "usage_limit", default: 10, null: false + t.index ["account_id", "code"], name: "index_account_join_codes_on_account_id_and_code", unique: true + end + + create_table "accounts", id: :uuid, force: :cascade do |t| + t.bigint "cards_count", default: 0, null: false + t.datetime "created_at", null: false + t.bigint "external_account_id" + t.string "name", limit: 255, null: false + t.datetime "updated_at", null: false + t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true + end + + create_table "action_text_rich_texts", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.text "body", limit: 4294967295 + t.datetime "created_at", null: false + t.string "name", limit: 255, null: false + t.uuid "record_id", null: false + t.string "record_type", limit: 255, null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_action_text_rich_texts_on_account_id" + t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true + end + + create_table "active_storage_attachments", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "blob_id", null: false + t.datetime "created_at", null: false + t.string "name", limit: 255, null: false + t.uuid "record_id", null: false + t.string "record_type", limit: 255, null: false + t.index ["account_id"], name: "index_active_storage_attachments_on_account_id" + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.bigint "byte_size", null: false + t.string "checksum", limit: 255 + t.string "content_type", limit: 255 + t.datetime "created_at", null: false + t.string "filename", limit: 255, null: false + t.string "key", limit: 255, null: false + t.text "metadata", limit: 65535 + t.string "service_name", limit: 255, null: false + t.index ["account_id"], name: "index_active_storage_blobs_on_account_id" + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "blob_id", null: false + t.string "variation_digest", limit: 255, null: false + t.index ["account_id"], name: "index_active_storage_variant_records_on_account_id" + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + + create_table "assignees_filters", id: false, force: :cascade do |t| + t.uuid "assignee_id", null: false + t.uuid "filter_id", null: false + t.index ["assignee_id"], name: "index_assignees_filters_on_assignee_id" + t.index ["filter_id"], name: "index_assignees_filters_on_filter_id" + end + + create_table "assigners_filters", id: false, force: :cascade do |t| + t.uuid "assigner_id", null: false + t.uuid "filter_id", null: false + t.index ["assigner_id"], name: "index_assigners_filters_on_assigner_id" + t.index ["filter_id"], name: "index_assigners_filters_on_filter_id" + end + + create_table "assignments", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "assignee_id", null: false + t.uuid "assigner_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_assignments_on_account_id" + t.index ["assignee_id", "card_id"], name: "index_assignments_on_assignee_id_and_card_id", unique: true + t.index ["card_id"], name: "index_assignments_on_card_id" + end + + create_table "board_publications", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "board_id", null: false + t.datetime "created_at", null: false + t.string "key", limit: 255 + t.datetime "updated_at", null: false + t.index ["account_id", "key"], name: "index_board_publications_on_account_id_and_key" + t.index ["board_id"], name: "index_board_publications_on_board_id" + end + + create_table "boards", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.boolean "all_access", default: false, null: false + t.datetime "created_at", null: false + t.uuid "creator_id", null: false + t.string "name", limit: 255, null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_boards_on_account_id" + t.index ["creator_id"], name: "index_boards_on_creator_id" + end + + create_table "boards_filters", id: false, force: :cascade do |t| + t.uuid "board_id", null: false + t.uuid "filter_id", null: false + t.index ["board_id"], name: "index_boards_filters_on_board_id" + t.index ["filter_id"], name: "index_boards_filters_on_filter_id" + end + + create_table "card_activity_spikes", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_card_activity_spikes_on_account_id" + t.index ["card_id"], name: "index_card_activity_spikes_on_card_id", unique: true + end + + create_table "card_engagements", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id" + t.datetime "created_at", null: false + t.string "status", limit: 255, default: "doing", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "status"], name: "index_card_engagements_on_account_id_and_status" + t.index ["card_id"], name: "index_card_engagements_on_card_id" + end + + create_table "card_goldnesses", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_card_goldnesses_on_account_id" + t.index ["card_id"], name: "index_card_goldnesses_on_card_id", unique: true + end + + create_table "card_not_nows", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "user_id" + t.index ["account_id"], name: "index_card_not_nows_on_account_id" + t.index ["card_id"], name: "index_card_not_nows_on_card_id", unique: true + t.index ["user_id"], name: "index_card_not_nows_on_user_id" + end + + create_table "cards", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "board_id", null: false + t.uuid "column_id" + t.datetime "created_at", null: false + t.uuid "creator_id", null: false + t.date "due_on" + t.datetime "last_active_at", null: false + t.bigint "number", null: false + t.string "status", limit: 255, default: "drafted", null: false + t.string "title", limit: 255 + t.datetime "updated_at", null: false + t.index ["account_id", "last_active_at", "status"], name: "index_cards_on_account_id_and_last_active_at_and_status" + t.index ["account_id", "number"], name: "index_cards_on_account_id_and_number", unique: true + t.index ["board_id"], name: "index_cards_on_board_id" + t.index ["column_id"], name: "index_cards_on_column_id" + end + + create_table "closers_filters", id: false, force: :cascade do |t| + t.uuid "closer_id", null: false + t.uuid "filter_id", null: false + t.index ["closer_id"], name: "index_closers_filters_on_closer_id" + t.index ["filter_id"], name: "index_closers_filters_on_filter_id" + end + + create_table "closures", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "user_id" + t.index ["account_id"], name: "index_closures_on_account_id" + t.index ["card_id", "created_at"], name: "index_closures_on_card_id_and_created_at" + t.index ["card_id"], name: "index_closures_on_card_id", unique: true + t.index ["user_id"], name: "index_closures_on_user_id" + end + + create_table "columns", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "board_id", null: false + t.string "color", limit: 255, null: false + t.datetime "created_at", null: false + t.string "name", limit: 255, null: false + t.integer "position", default: 0, null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_columns_on_account_id" + t.index ["board_id", "position"], name: "index_columns_on_board_id_and_position" + t.index ["board_id"], name: "index_columns_on_board_id" + end + + create_table "comments", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.uuid "creator_id", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_comments_on_account_id" + t.index ["card_id"], name: "index_comments_on_card_id" + end + + create_table "creators_filters", id: false, force: :cascade do |t| + t.uuid "creator_id", null: false + t.uuid "filter_id", null: false + t.index ["creator_id"], name: "index_creators_filters_on_creator_id" + t.index ["filter_id"], name: "index_creators_filters_on_filter_id" + end + + create_table "entropies", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.bigint "auto_postpone_period", default: 2592000, null: false + t.uuid "container_id", null: false + t.string "container_type", limit: 255, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_entropies_on_account_id" + t.index ["container_type", "container_id", "auto_postpone_period"], name: "idx_on_container_type_container_id_auto_postpone_pe_3d79b50517" + t.index ["container_type", "container_id"], name: "index_entropy_configurations_on_container", unique: true + end + + create_table "events", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.string "action", limit: 255, null: false + t.uuid "board_id", null: false + t.datetime "created_at", null: false + t.uuid "creator_id", null: false + t.uuid "eventable_id", null: false + t.string "eventable_type", limit: 255, null: false + t.json "particulars", default: -> { "json_object()" } + t.datetime "updated_at", null: false + t.index ["account_id", "action"], name: "index_events_on_account_id_and_action" + t.index ["board_id", "action", "created_at"], name: "index_events_on_board_id_and_action_and_created_at" + t.index ["board_id"], name: "index_events_on_board_id" + t.index ["creator_id"], name: "index_events_on_creator_id" + t.index ["eventable_type", "eventable_id"], name: "index_events_on_eventable" + end + + create_table "filters", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.uuid "creator_id", null: false + t.json "fields", default: -> { "json_object()" }, null: false + t.string "params_digest", limit: 255, null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_filters_on_account_id" + t.index ["creator_id", "params_digest"], name: "index_filters_on_creator_id_and_params_digest", unique: true + end + + create_table "filters_tags", id: false, force: :cascade do |t| + t.uuid "filter_id", null: false + t.uuid "tag_id", null: false + t.index ["filter_id"], name: "index_filters_tags_on_filter_id" + t.index ["tag_id"], name: "index_filters_tags_on_tag_id" + end + + create_table "identities", id: :uuid, force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email_address", limit: 255, null: false + t.boolean "staff", default: false, null: false + t.datetime "updated_at", null: false + t.index ["email_address"], name: "index_identities_on_email_address", unique: true + end + + create_table "magic_links", id: :uuid, force: :cascade do |t| + t.string "code", limit: 255, null: false + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.uuid "identity_id" + t.datetime "updated_at", null: false + t.index ["code"], name: "index_magic_links_on_code", unique: true + t.index ["expires_at"], name: "index_magic_links_on_expires_at" + t.index ["identity_id"], name: "index_magic_links_on_identity_id" + end + + create_table "mentions", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.uuid "mentionee_id", null: false + t.uuid "mentioner_id", null: false + t.uuid "source_id", null: false + t.string "source_type", limit: 255, null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_mentions_on_account_id" + t.index ["mentionee_id"], name: "index_mentions_on_mentionee_id" + t.index ["mentioner_id"], name: "index_mentions_on_mentioner_id" + t.index ["source_type", "source_id"], name: "index_mentions_on_source" + end + + create_table "notification_bundles", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.datetime "ends_at", null: false + t.datetime "starts_at", null: false + t.integer "status", default: 0, null: false + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_notification_bundles_on_account_id" + t.index ["ends_at", "status"], name: "index_notification_bundles_on_ends_at_and_status" + t.index ["user_id", "starts_at", "ends_at"], name: "idx_on_user_id_starts_at_ends_at_7eae5d3ac5" + t.index ["user_id", "status"], name: "index_notification_bundles_on_user_id_and_status" + end + + create_table "notifications", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.uuid "creator_id" + t.datetime "read_at" + t.uuid "source_id", null: false + t.string "source_type", limit: 255, null: false + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_notifications_on_account_id" + t.index ["creator_id"], name: "index_notifications_on_creator_id" + t.index ["source_type", "source_id"], name: "index_notifications_on_source" + t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc } + t.index ["user_id"], name: "index_notifications_on_user_id" + end + + create_table "pins", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_pins_on_account_id" + t.index ["card_id", "user_id"], name: "index_pins_on_card_id_and_user_id", unique: true + t.index ["card_id"], name: "index_pins_on_card_id" + t.index ["user_id"], name: "index_pins_on_user_id" + end + + create_table "push_subscriptions", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.string "auth_key", limit: 255 + t.datetime "created_at", null: false + t.text "endpoint", limit: 65535 + t.string "p256dh_key", limit: 255 + t.datetime "updated_at", null: false + t.string "user_agent", limit: 4096 + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_push_subscriptions_on_account_id" + t.index ["user_id", "endpoint"], name: "index_push_subscriptions_on_user_id_and_endpoint", unique: true + end + + create_table "reactions", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "comment_id", null: false + t.string "content", limit: 16, null: false + t.datetime "created_at", null: false + t.uuid "reacter_id", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_reactions_on_account_id" + t.index ["comment_id"], name: "index_reactions_on_comment_id" + t.index ["reacter_id"], name: "index_reactions_on_reacter_id" + end + + create_table "search_queries", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.string "terms", limit: 2000, null: false + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_search_queries_on_account_id" + t.index ["user_id", "terms"], name: "index_search_queries_on_user_id_and_terms" + t.index ["user_id", "updated_at"], name: "index_search_queries_on_user_id_and_updated_at", unique: true + t.index ["user_id"], name: "index_search_queries_on_user_id" + end + + create_table "search_records", force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "board_id", null: false + t.uuid "card_id", null: false + t.text "content", limit: 65535 + t.datetime "created_at", null: false + t.uuid "searchable_id", null: false + t.string "searchable_type", limit: 255, null: false + t.string "title", limit: 255 + t.index ["account_id"], name: "index_search_records_on_account_id" + t.index ["searchable_type", "searchable_id"], name: "index_search_records_on_searchable_type_and_searchable_id", unique: true + end + + create_table "sessions", id: :uuid, force: :cascade do |t| + t.datetime "created_at", null: false + t.uuid "identity_id", null: false + t.string "ip_address", limit: 255 + t.datetime "updated_at", null: false + t.string "user_agent", limit: 4096 + t.index ["identity_id"], name: "index_sessions_on_identity_id" + end + + create_table "steps", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.boolean "completed", default: false, null: false + t.text "content", limit: 65535, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_steps_on_account_id" + t.index ["card_id", "completed"], name: "index_steps_on_card_id_and_completed" + t.index ["card_id"], name: "index_steps_on_card_id" + end + + create_table "taggings", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.uuid "tag_id", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_taggings_on_account_id" + t.index ["card_id", "tag_id"], name: "index_taggings_on_card_id_and_tag_id", unique: true + t.index ["tag_id"], name: "index_taggings_on_tag_id" + end + + create_table "tags", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.string "title", limit: 255 + t.datetime "updated_at", null: false + t.index ["account_id", "title"], name: "index_tags_on_account_id_and_title", unique: true + end + + create_table "user_settings", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.integer "bundle_email_frequency", default: 0, null: false + t.datetime "created_at", null: false + t.string "timezone_name", limit: 255 + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_user_settings_on_account_id" + t.index ["user_id", "bundle_email_frequency"], name: "index_user_settings_on_user_id_and_bundle_email_frequency" + t.index ["user_id"], name: "index_user_settings_on_user_id" + end + + create_table "users", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.boolean "active", default: true, null: false + t.datetime "created_at", null: false + t.uuid "identity_id" + t.string "name", limit: 255, null: false + t.string "role", limit: 255, default: "member", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "identity_id"], name: "index_users_on_account_id_and_identity_id", unique: true + t.index ["account_id", "role"], name: "index_users_on_account_id_and_role" + t.index ["identity_id"], name: "index_users_on_identity_id" + end + + create_table "watches", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.boolean "watching", default: true, null: false + t.index ["account_id"], name: "index_watches_on_account_id" + t.index ["card_id"], name: "index_watches_on_card_id" + t.index ["user_id", "card_id"], name: "index_watches_on_user_id_and_card_id" + t.index ["user_id"], name: "index_watches_on_user_id" + end + + create_table "webhook_delinquency_trackers", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.integer "consecutive_failures_count", default: 0 + t.datetime "created_at", null: false + t.datetime "first_failure_at" + t.datetime "updated_at", null: false + t.uuid "webhook_id", null: false + t.index ["account_id"], name: "index_webhook_delinquency_trackers_on_account_id" + t.index ["webhook_id"], name: "index_webhook_delinquency_trackers_on_webhook_id" + end + + create_table "webhook_deliveries", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.uuid "event_id", null: false + t.text "request", limit: 65535 + t.text "response", limit: 65535 + t.string "state", limit: 255, null: false + t.datetime "updated_at", null: false + t.uuid "webhook_id", null: false + t.index ["account_id"], name: "index_webhook_deliveries_on_account_id" + t.index ["event_id"], name: "index_webhook_deliveries_on_event_id" + t.index ["webhook_id"], name: "index_webhook_deliveries_on_webhook_id" + end + + create_table "webhooks", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.boolean "active", default: true, null: false + t.uuid "board_id", null: false + t.datetime "created_at", null: false + t.string "name", limit: 255 + t.string "signing_secret", limit: 255, null: false + t.text "subscribed_actions", limit: 65535 + t.datetime "updated_at", null: false + t.text "url", limit: 65535, null: false + t.index ["account_id"], name: "index_webhooks_on_account_id" + t.index ["board_id", "subscribed_actions"], name: "index_webhooks_on_board_id_and_subscribed_actions" + end + execute "CREATE VIRTUAL TABLE search_records_fts USING fts5(\n title,\n content,\n tokenize='porter'\n )" + +end diff --git a/gems/fizzy-saas/lib/fizzy/saas/engine.rb b/gems/fizzy-saas/lib/fizzy/saas/engine.rb index 8db0f40bf..14ce03759 100644 --- a/gems/fizzy-saas/lib/fizzy/saas/engine.rb +++ b/gems/fizzy-saas/lib/fizzy/saas/engine.rb @@ -8,10 +8,12 @@ module Fizzy Queenbee.host_app = Fizzy initializer "fizzy_saas.transaction_pinning" do |app| - app.config.middleware.insert_after( - ActiveRecord::Middleware::DatabaseSelector, - TransactionPinning::Middleware - ) + if ActiveRecord::Base.replica_configured? + app.config.middleware.insert_after( + ActiveRecord::Middleware::DatabaseSelector, + TransactionPinning::Middleware + ) + end end config.to_prepare do diff --git a/lib/rails_ext/active_record_date_arithmetic.rb b/lib/rails_ext/active_record_date_arithmetic.rb new file mode 100644 index 000000000..5121c3f12 --- /dev/null +++ b/lib/rails_ext/active_record_date_arithmetic.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +# Adds database-agnostic date arithmetic methods to ActiveRecord adapters. +# This allows code to perform date calculations without checking which database adapter is in use. +# +# Usage: +# connection.date_subtract("created_at", "3600") +# # MySQL/Trilogy: "DATE_SUB(created_at, INTERVAL 3600 SECOND)" +# # SQLite: "datetime(created_at, '-' || (3600) || ' seconds')" + +# Module for MySQL-based adapters (Trilogy, Mysql2, etc.) +module MysqlDateArithmetic + # Generates SQL for subtracting seconds from a date/time column in MySQL. + # + # @param date_column [String] The date/time column or expression + # @param seconds_expression [String] SQL expression that evaluates to number of seconds + # @return [String] MySQL DATE_SUB expression + # + # Example: + # date_subtract("last_active_at", "COALESCE(auto_postpone_period, 3600)") + # # => "DATE_SUB(last_active_at, INTERVAL COALESCE(auto_postpone_period, 3600) SECOND)" + def date_subtract(date_column, seconds_expression) + "DATE_SUB(#{date_column}, INTERVAL #{seconds_expression} SECOND)" + end +end + +# Module for SQLite adapter +module SqliteDateArithmetic + # Generates SQL for subtracting seconds from a date/time column in SQLite. + # + # @param date_column [String] The date/time column or expression + # @param seconds_expression [String] SQL expression that evaluates to number of seconds + # @return [String] SQLite datetime expression + # + # Example: + # date_subtract("last_active_at", "COALESCE(auto_postpone_period, 3600)") + # # => "datetime(last_active_at, '-' || (COALESCE(auto_postpone_period, 3600)) || ' seconds')" + def date_subtract(date_column, seconds_expression) + "datetime(#{date_column}, '-' || (#{seconds_expression}) || ' seconds')" + end +end + +ActiveSupport.on_load(:active_record) do + # Prepend MySQL date arithmetic to AbstractMysqlAdapter (covers Trilogy, Mysql2, etc.) + if defined?(ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter) + ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter.prepend(MysqlDateArithmetic) + end + + # Prepend SQLite date arithmetic to SQLite3Adapter + if defined?(ActiveRecord::ConnectionAdapters::SQLite3Adapter) + ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend(SqliteDateArithmetic) + end +end diff --git a/lib/rails_ext/active_record_replica_support.rb b/lib/rails_ext/active_record_replica_support.rb new file mode 100644 index 000000000..2625a1ac9 --- /dev/null +++ b/lib/rails_ext/active_record_replica_support.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Adds a helper method to check if replica database connections are configured +# and automatically configures read/write splitting when replicas are available. +# +# Usage: +# class ApplicationRecord < ActiveRecord::Base +# configure_replica_connections +# end +module ActiveRecordReplicaSupport + extend ActiveSupport::Concern + + class_methods do + # Automatically configures connects_to for read/write splitting if a replica + # database is configured for the current environment. This is a no-op if no + # replica configuration exists. + # + # Example: + # class ApplicationRecord < ActiveRecord::Base + # configure_replica_connections + # end + def configure_replica_connections + if replica_configured? + connects_to database: { writing: :primary, reading: :replica } + end + end + + # Returns true if a replica database configuration exists for the current + # environment. This allows different database adapters to opt in or out of + # read/write splitting based on their database.yml configuration. + # + # Example: + # ApplicationRecord.replica_configured? # => true for MySQL, false for SQLite + def replica_configured? + configurations.find_db_config("replica").present? + end + end +end + +ActiveRecord::Base.include ActiveRecordReplicaSupport diff --git a/lib/rails_ext/active_record_uuid_type.rb b/lib/rails_ext/active_record_uuid_type.rb index 3aca09762..02bdf3ed3 100644 --- a/lib/rails_ext/active_record_uuid_type.rb +++ b/lib/rails_ext/active_record_uuid_type.rb @@ -39,5 +39,6 @@ module ActiveRecord end end -# Register the UUID type for Trilogy adapter +# Register the UUID type for Trilogy (MySQL) and SQLite3 adapters ActiveRecord::Type.register(:uuid, ActiveRecord::Type::Uuid, adapter: :trilogy) +ActiveRecord::Type.register(:uuid, ActiveRecord::Type::Uuid, adapter: :sqlite3) diff --git a/lib/tasks/search.rake b/lib/tasks/search.rake index 0bb3591ee..6b47dbde3 100644 --- a/lib/tasks/search.rake +++ b/lib/tasks/search.rake @@ -2,19 +2,20 @@ namespace :search do desc "Reindex all cards and comments in the search index" task reindex: :environment do puts "Clearing search records..." - Search::Record::SHARD_COUNT.times do |shard_id| - ActiveRecord::Base.connection.execute("DELETE FROM search_records_#{shard_id}") + if ActiveRecord::Base.connection.adapter_name == "SQLite" + ActiveRecord::Base.connection.execute("DELETE FROM search_records") + ActiveRecord::Base.connection.execute("DELETE FROM search_records_fts") + else + Search::Record::Trilogy::SHARD_COUNT.times do |shard_id| + ActiveRecord::Base.connection.execute("DELETE FROM search_records_#{shard_id}") + end end puts "Reindexing cards..." - Card.find_each do |card| - card.reindex - end + Card.includes(:rich_text_description).find_each(&:reindex) puts "Reindexing comments..." - Comment.find_each do |comment| - comment.reindex - end + Comment.includes(:rich_text_body, :card).find_each(&:reindex) puts "Done! Reindexed #{Card.count} cards and #{Comment.count} comments." end diff --git a/test/controllers/searches_controller_test.rb b/test/controllers/searches_controller_test.rb index 12c191567..14bd91e64 100644 --- a/test/controllers/searches_controller_test.rb +++ b/test/controllers/searches_controller_test.rb @@ -30,6 +30,7 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest assert_select "li .search__title", text: /Just haggis/, count: 2 # card title shows up in two entries assert_select "li .search__excerpt", text: /More haggis/ # one entry for the card description assert_select "li .search__excerpt--comment", text: /I love haggis/ # one entry for the comment + assert_match(/<\/span>haggis<\/mark>/, response.body) # Searching by card id get search_path(q: @card.id, script_name: "/#{@account.external_account_id}") @@ -40,4 +41,26 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest assert_select "form[data-controller='auto-submit']", count: 0 assert_select ".search__empty", text: "No matches" end + + test "search highlights matched terms with proper HTML marks" do + @board.cards.create!(title: "Testing search highlighting", creator: @user) + + get search_path(q: "highlighting", script_name: "/#{@account.external_account_id}") + assert_response :success + end + + test "search preserves highlight marks but escapes surrounding HTML" do + @board.cards.create!( + title: "Bold testing content", + creator: @user + ) + + get search_path(q: "testing", script_name: "/#{@account.external_account_id}") + assert_response :success + + # Should escape tags + assert response.body.include?("<b>") + # But should preserve highlight marks around "testing" + assert_match(/<\/span>testing<\/mark>/, response.body) + end end diff --git a/test/models/card/searchable_test.rb b/test/models/card/searchable_test.rb index 18845ab7a..d6dcdf60c 100644 --- a/test/models/card/searchable_test.rb +++ b/test/models/card/searchable_test.rb @@ -31,4 +31,33 @@ class Card::SearchableTest < ActiveSupport::TestCase assert_includes results, card_in_board assert_not_includes results, card_in_other_board end + + test "deleting card removes search record and FTS entry" do + search_record_class = Search::Record.for(@user.account_id) + card = @board.cards.create!(title: "Card to delete", creator: @user) + + # Verify search record exists + search_record = search_record_class.find_by(searchable_type: "Card", searchable_id: card.id) + assert_not_nil search_record, "Search record should exist after card creation" + + # For SQLite, verify FTS entry exists + if search_record_class.connection.adapter_name == "SQLite" + fts_entry = search_record.search_records_fts + assert_not_nil fts_entry, "FTS entry should exist" + assert_equal card.title, fts_entry.title + end + + # Delete the card + card.destroy + + # Verify search record is deleted + search_record = search_record_class.find_by(searchable_type: "Card", searchable_id: card.id) + assert_nil search_record, "Search record should be deleted after card deletion" + + # For SQLite, verify FTS entry is deleted + if search_record_class.connection.adapter_name == "SQLite" + fts_count = Search::Record::SQLite::Fts.where(rowid: card.id).count + assert_equal 0, fts_count, "FTS entry should be deleted" + end + end end diff --git a/test/models/column_limits_test.rb b/test/models/column_limits_test.rb new file mode 100644 index 000000000..ba5f67b92 --- /dev/null +++ b/test/models/column_limits_test.rb @@ -0,0 +1,56 @@ +require "test_helper" + +class ColumnLimitsTest < ActiveSupport::TestCase + # Database errors for exceeding column limits: + # - MySQL: ActiveRecord::ValueTooLong + # - SQLite: ActiveRecord::CheckViolation + COLUMN_LIMIT_ERRORS = [ ActiveRecord::ValueTooLong, ActiveRecord::CheckViolation ] + + test "account name rejects strings over 255 characters" do + account = Account.new(name: "a" * 256) + assert_raises(*COLUMN_LIMIT_ERRORS) { account.save! } + end + + test "account name accepts strings up to 255 characters" do + account = Account.new(name: "a" * 255) + assert account.save + end + + test "account name accepts 255 emoji characters" do + account = Account.new(name: "🎉" * 255) + assert account.save + end + + test "account name rejects 256 emoji characters" do + account = Account.new(name: "🎉" * 256) + assert_raises(*COLUMN_LIMIT_ERRORS) { account.save! } + end + + # Test text column limits (65535 bytes for TEXT) + test "step content rejects text over 65535 bytes" do + step = Step.new(content: "a" * 65536, card: cards(:logo)) + assert_raises(*COLUMN_LIMIT_ERRORS) { step.save! } + end + + test "step content accepts text up to 65535 bytes" do + step = Step.new(content: "a" * 65535, card: cards(:logo)) + assert step.save + end + + test "step content counts bytes not characters for text columns" do + # 20000 emoji = 20000 chars but 80000 bytes (over 65535 limit) + step = Step.new(content: "🎉" * 20000, card: cards(:logo)) + assert_raises(*COLUMN_LIMIT_ERRORS) { step.save! } + end + + test "ActionText::RichText name rejects strings over 255 characters" do + rich_text = ActionText::RichText.new(name: "a" * 256, record: cards(:logo)) + assert_raises(*COLUMN_LIMIT_ERRORS) { rich_text.save! } + end + + test "ActiveStorage::Blob filename rejects strings over 255 characters" do + Current.account = accounts(:"37s") + blob = ActiveStorage::Blob.new(filename: "a" * 256, key: "test-key", byte_size: 0, checksum: "test", service_name: "local") + assert_raises(*COLUMN_LIMIT_ERRORS) { blob.save! } + end +end diff --git a/test/models/comment/searchable_test.rb b/test/models/comment/searchable_test.rb index a8e52bfc2..7ba73847c 100644 --- a/test/models/comment/searchable_test.rb +++ b/test/models/comment/searchable_test.rb @@ -8,22 +8,37 @@ class Comment::SearchableTest < ActiveSupport::TestCase end test "comment search" do + search_record_class = Search::Record.for(@user.account_id) # Comment is indexed on create comment = @card.comments.create!(body: "searchable comment text", creator: @user) - record = Search::Record.for_account(@account.id).find_by(searchable_type: "Comment", searchable_id: comment.id) + record = search_record_class.find_by(searchable_type: "Comment", searchable_id: comment.id) assert_not_nil record # Comment is updated in index comment.update!(body: "updated text") - record = Search::Record.for_account(@account.id).find_by(searchable_type: "Comment", searchable_id: comment.id) + record = search_record_class.find_by(searchable_type: "Comment", searchable_id: comment.id) assert_match /updat/, record.content # Comment is removed from index on destroy comment_id = comment.id + search_record_id = record.id + + # For SQLite, verify FTS entry exists before deletion + if search_record_class.connection.adapter_name == "SQLite" + fts_entry = record.search_records_fts + assert_not_nil fts_entry, "FTS entry should exist before comment deletion" + end + comment.destroy - record = Search::Record.for_account(@account.id).find_by(searchable_type: "Comment", searchable_id: comment_id) + record = search_record_class.find_by(searchable_type: "Comment", searchable_id: comment_id) assert_nil record + # For SQLite, verify FTS entry is also deleted + if search_record_class.connection.adapter_name == "SQLite" + fts_count = Search::Record::SQLite::Fts.where(rowid: search_record_id).count + assert_equal 0, fts_count, "FTS entry should be deleted after comment deletion" + end + # Finding cards via comment search card_with_comment = @board.cards.create!(title: "Card One", creator: @user) card_with_comment.comments.create!(body: "unique searchable phrase", creator: @user) @@ -34,7 +49,7 @@ class Comment::SearchableTest < ActiveSupport::TestCase # Comment stores parent card_id and board_id new_comment = @card.comments.create!(body: "test comment", creator: @user) - record = Search::Record.for_account(@account.id).find_by(searchable_type: "Comment", searchable_id: new_comment.id) + record = search_record_class.find_by(searchable_type: "Comment", searchable_id: new_comment.id) assert_equal @card.id, record.card_id assert_equal @board.id, record.board_id end diff --git a/test/models/search_test.rb b/test/models/search_test.rb index e27bcbdfe..1f4981090 100644 --- a/test/models/search_test.rb +++ b/test/models/search_test.rb @@ -9,10 +9,10 @@ class SearchTest < ActiveSupport::TestCase comment_card = @board.cards.create!(title: "Some card", creator: @user) comment_card.comments.create!(body: "overflowing text", creator: @user) - results = Search.results(query: "layout", user: @user) + results = Search::Record.for(@user.account_id).search("layout", user: @user) assert results.find { |it| it.card_id == card.id } - results = Search.results(query: "overflowing", user: @user) + results = Search::Record.for(@user.account_id).search("overflowing", user: @user) assert results.find { |it| it.card_id == comment_card.id && it.searchable_type == "Comment" } # Don't include inaccessible boards @@ -21,13 +21,13 @@ class SearchTest < ActiveSupport::TestCase accessible_card = @board.cards.create!(title: "searchable content", creator: @user) inaccessible_card = inaccessible_board.cards.create!(title: "searchable content", creator: other_user) - results = Search.results(query: "searchable", user: @user) + results = Search::Record.for(@user.account_id).search("searchable", user: @user) assert results.find { |it| it.card_id == accessible_card.id } assert_not results.find { |it| it.card_id == inaccessible_card.id } # Empty board_ids returns no results user_without_access = User.create!(name: "No Access User", account: @account) - results = Search.results(query: "anything", user: user_without_access) + results = Search::Record.for(user_without_access.account_id).search("anything", user: user_without_access) assert_empty results end end diff --git a/test/test_helpers/search_test_helper.rb b/test/test_helpers/search_test_helper.rb index f35f1c2a8..78b113781 100644 --- a/test/test_helpers/search_test_helper.rb +++ b/test/test_helpers/search_test_helper.rb @@ -28,8 +28,13 @@ module SearchTestHelper private def clear_search_records - Search::Record::SHARD_COUNT.times do |shard_id| - ActiveRecord::Base.connection.execute("DELETE FROM search_records_#{shard_id}") + if ActiveRecord::Base.connection.adapter_name == "SQLite" + ActiveRecord::Base.connection.execute("DELETE FROM search_records") + ActiveRecord::Base.connection.execute("DELETE FROM search_records_fts") + else + Search::Record::Trilogy::SHARD_COUNT.times do |shard_id| + ActiveRecord::Base.connection.execute("DELETE FROM search_records_#{shard_id}") + end end end end