diff --git a/app/views/my/_menu.html.erb b/app/views/my/_menu.html.erb
index 2b5c1d0ea..31d3d2551 100644
--- a/app/views/my/_menu.html.erb
+++ b/app/views/my/_menu.html.erb
@@ -14,7 +14,8 @@
controller: "filter navigable-list nav-section-expander",
dialog_target: "dialog",
navigable_list_focus_on_selection_value: false,
- navigable_list_actionable_items_value: true } do %>
+ navigable_list_actionable_items_value: true,
+ turbo_permanent: true } do %>
<%= turbo_frame_tag "my_menu", src: my_menu_path, loading: :lazy, target: "_top" do %>
<% # Passing empty block to avoid double-render %>
<%= render("my/menus/jump") { } %>
diff --git a/bin/gitleaks-audit b/bin/gitleaks-audit
new file mode 100755
index 000000000..39c0a6141
--- /dev/null
+++ b/bin/gitleaks-audit
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+
+if ! which gitleaks > /dev/null 2>&1 ; then
+ echo "gitleaks is not installed, please install it first" 1>&2
+ exit 1
+fi
+
+mkdir -p tmp
+if ! gitleaks dir --redact=50 --report-path tmp/gitleaks-report.json ; then
+ echo "gitleaks found potential secrets, please check tmp/gitleaks-report.json" 1>&2
+ exit 1
+fi
+
+exit 0
diff --git a/bin/setup b/bin/setup
index 1c9c6ac61..f410b2f2c 100755
--- a/bin/setup
+++ b/bin/setup
@@ -59,7 +59,7 @@ step "Installing Ruby" mise install --yes
eval "$(mise hook-env)"
if which pacman >/dev/null 2>&1; then
- packages=(imagemagick mariadb-libs openslide libvips)
+ packages=(imagemagick mariadb-libs openslide libvips gitleaks)
if ! pacman -Q "${packages[@]}" >/dev/null 2>&1; then
step "Installing packages" sudo pacman -S --noconfirm --needed "${packages[@]}"
fi
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/ci.rb b/config/ci.rb
index be81c58c9..f6cbbf953 100644
--- a/config/ci.rb
+++ b/config/ci.rb
@@ -8,6 +8,7 @@ CI.run do
step "Security: Gem audit", "bin/bundler-audit check --update"
step "Security: Importmap audit", "bin/importmap audit"
step "Security: Brakeman audit", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
+ step "Security: Gitleaks audit", "bin/gitleaks-audit"
step "Tests: Rails: SaaS config", "bin/rails test"
step "Tests: Rails: OSS config", "OSS_CONFIG=1 bin/rails test"
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(/