Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# This migration comes from co_plan (originally 20260821000000)
class AddUrlSegmentsToLibrariesAndFolders < ActiveRecord::Migration[8.1]
# Gives libraries and folders the URL segments that make them browsable:
# /<handle>/<folder-slug>/<folder-slug>. Resolution walks these one
# segment at a time, so nothing stores a joined path and renaming a
# folder touches only its own row.
#
# Slug logic is inlined rather than calling CoPlan::Slug — a migration
# has to keep producing the same backfill years from now, even if the
# app's slug rules move on.
#
# Raw portable SQL throughout (select_all / quote), same reasoning as
# RequirePlanTypeOnCoplanPlans: this runs on MySQL and PostgreSQL hosts.
#
# Note on the unique indexes: MySQL treats NULLs as distinct, so the
# folder index does not actually constrain root folders (parent_id
# NULL). Real enforcement is the Rails validation, which scopes with
# IS NULL correctly — matching how `name` uniqueness already works on
# this table. The index is for lookup speed on the resolver's hot path.
def up
add_column :coplan_libraries, :handle, :string
add_column :coplan_folders, :slug, :string

backfill_library_handles
backfill_folder_slugs

change_column_null :coplan_libraries, :handle, false
change_column_null :coplan_folders, :slug, false
add_index :coplan_libraries, :handle, unique: true, name: "index_coplan_libraries_on_handle"
add_index :coplan_folders, [ :library_id, :parent_id, :slug ],
unique: true, name: "index_coplan_folders_on_library_and_parent_and_slug"
end

def down
remove_index :coplan_folders, name: "index_coplan_folders_on_library_and_parent_and_slug"
remove_index :coplan_libraries, name: "index_coplan_libraries_on_handle"
remove_column :coplan_folders, :slug
remove_column :coplan_libraries, :handle
end

private

# A personal library's handle comes from the owner's username (their
# ldap), falling back to the email local part and then the display
# name. Anything else — a team library — uses the library's own name.
#
# `taken` starts out holding the app's root-level addresses, so a person
# whose ldap is "settings" gets "settings-2" rather than a handle the app
# would refuse to save. Spelled out here for the same reason the slug
# rules are: a migration has to keep producing the same backfill.
def backfill_library_handles
rows = connection.select_all(<<~SQL)
SELECT l.id, l.name, l.owner_type, l.owner_id,
u.username AS owner_username, u.email AS owner_email, u.name AS owner_name
FROM coplan_libraries l
LEFT JOIN coplan_users u
ON l.owner_type = 'CoPlan::User' AND l.owner_id = u.id
ORDER BY l.created_at, l.id
SQL

taken = %w[
_ new edit all
plans people libraries library settings search notifications home welcome
api agent-instructions admin assets rails up sign_in sign_out integrations
]
rows.each do |row|
source = row["owner_username"].presence ||
row["owner_email"].to_s.split("@").first.presence ||
row["owner_name"].presence ||
row["name"].presence ||
"library"
handle = unique_slug(ascii_slugify(source), taken, fallback: "library")
taken << handle
execute "UPDATE coplan_libraries SET handle = #{quote(handle)} WHERE id = #{quote(row['id'])}"
end

create_missing_user_libraries(taken)
end

# Libraries used to be materialized on first touch, so people who never
# loaded a page that needed one have no row. A library is a person's page
# now — /<handle> — so everyone needs theirs to exist, not just everyone
# who has filed something.
def create_missing_user_libraries(taken)
rows = connection.select_all(<<~SQL)
SELECT u.id, u.username, u.email, u.name
FROM coplan_users u
LEFT JOIN coplan_libraries l
ON l.owner_type = 'CoPlan::User' AND l.owner_id = u.id
WHERE l.id IS NULL
ORDER BY u.created_at, u.id
SQL

# Formatted rather than quoted: `quote` on a TimeWithZone writes a zone
# name into the literal, which strict MySQL refuses.
now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S")
rows.each do |row|
source = row["username"].presence ||
row["email"].to_s.split("@").first.presence ||
row["name"].presence ||
"library"
handle = unique_slug(ascii_slugify(source), taken, fallback: "library")
taken << handle
execute <<~SQL
INSERT INTO coplan_libraries (id, name, handle, owner_type, owner_id, created_at, updated_at)
VALUES (#{quote(SecureRandom.uuid_v7)}, 'Library', #{quote(handle)},
'CoPlan::User', #{quote(row['id'])}, #{quote(now)}, #{quote(now)})
SQL
end
end

# Folder slugs only have to be unique among siblings, so uniqueness is
# tracked per (library, parent). Deepest-last ordering isn't needed —
# a folder's slug depends on its own name alone.
def backfill_folder_slugs
rows = connection.select_all(<<~SQL)
SELECT id, library_id, parent_id, name
FROM coplan_folders
ORDER BY library_id, parent_id, created_at, id
SQL

taken = {}
rows.each do |row|
sibling_key = [ row["library_id"], row["parent_id"] ]
taken[sibling_key] ||= []
slug = unique_slug(slugify(row["name"]), taken[sibling_key], fallback: "folder")
taken[sibling_key] << slug
execute "UPDATE coplan_folders SET slug = #{quote(slug)} WHERE id = #{quote(row['id'])}"
end
end

# Mirrors CoPlan::Slug.call / .handle, inlined so this backfill keeps
# producing the same slugs if the app's rules move on. Folders keep
# Unicode letters — a folder named 設計 gets a segment that says so —
# while handles stay ASCII because they're typed and read aloud.
def slugify(text)
trim(text.to_s.unicode_normalize(:nfc).downcase.gsub(/[^[[:alnum:]]]+/, "-"))
end

def ascii_slugify(text)
trim(text.to_s.unicode_normalize(:nfkd).downcase.gsub(/[^a-z0-9]+/, "-"))
end

def trim(hyphenated)
hyphenated.gsub(/-{2,}/, "-").delete_prefix("-").delete_suffix("-")[0, 60].to_s
.delete_suffix("-")
Comment thread
HamptonMakes marked this conversation as resolved.
end

# Existing data predates any slug rule, so collisions are expected —
# "Team EBT" and "team-ebt" both want the same segment. Numeric
# suffixes here are a backfill concession; new records get better
# disambiguation from the app.
def unique_slug(slug, taken, fallback:)
candidate = slug.presence || fallback
return candidate unless taken.include?(candidate)

suffix = 2
suffix += 1 while taken.include?("#{candidate}-#{suffix}")
"#{candidate}-#{suffix}"
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# This migration comes from co_plan (originally 20260821000001)
class AddPlanSlugsAndUrlAliases < ActiveRecord::Migration[8.1]
# The leaf segment of a browsable URL, plus the table that keeps old
# URLs resolving.
#
# `slug` is derived from the plan's title with redundancy stripped — a
# plan titled "LiveOrder Cart Roadmap" filed in "LiveOrder" is just
# "cart-roadmap", because the folder already said the rest.
# `slug_suffix` is set only when two plans in the same folder want the
# same slug, so it appears in a URL only where it earns something.
#
# No unique index on (slug, slug_suffix): a plan's uniqueness scope is
# the folder it's filed in, which lives in coplan_plan_placements, not
# here. Uniqueness is enforced on write against the canonical folder
# (Plans::AssignSlug). A real DB constraint becomes possible once a plan
# is filed in exactly one library — see the placements collapse.
#
# Backfill leaves slugs NULL and lets the app fill them in lazily, so
# this migration stays fast on a large table and doesn't have to
# reimplement the redundancy-stripping rules.
def up
add_column :coplan_plans, :slug, :string
add_column :coplan_plans, :slug_suffix, :string, limit: 8
add_index :coplan_plans, [ :slug, :slug_suffix ], name: "index_coplan_plans_on_slug_and_suffix"

create_table :coplan_url_aliases, id: { type: :string, limit: 36 } do |t|
# The stale path, library handle first and no leading slash:
# "orders/liveorder/cart-roadmap".
t.string :path, null: false, limit: 512
# "exact" matches one URL; "prefix" rewrites everything beneath it,
# which is how one row covers a renamed folder's whole subtree.
t.string :kind, null: false, default: "exact"
t.string :target_path, null: false, limit: 512
# Cheap eviction signal: rows nobody has ever followed are safe to
# drop, because plan_events / library_events can rebuild them.
t.integer :resolve_count, null: false, default: 0
t.timestamp :last_resolved_at
t.timestamps
end
add_index :coplan_url_aliases, [ :path, :kind ], unique: true,
name: "index_coplan_url_aliases_on_path_and_kind"
add_index :coplan_url_aliases, [ :kind, :resolve_count, :created_at ],
name: "index_coplan_url_aliases_for_pruning"
end

def down
drop_table :coplan_url_aliases
remove_index :coplan_plans, name: "index_coplan_plans_on_slug_and_suffix"
remove_column :coplan_plans, :slug_suffix
remove_column :coplan_plans, :slug
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# This migration comes from co_plan (originally 20260821000002)
class CollapsePlanPlacementsToOne < ActiveRecord::Migration[8.1]
# A plan now lives in exactly one library. The old model let the same
# plan sit on many shelves at once — filing someone else's published
# plan into your own library was a "bookmark", separate from the
# author's copy. In practice nobody used it, and it cost us the one
# property a readable URL needs: a document having a single address.
#
# Collapses existing rows, keeping the author's own placement where
# there is one (that's the shelf URLs already resolved to) and
# otherwise the oldest, then makes more than one unrepresentable.
def up
ids = surplus_placement_ids
say "Dropping #{ids.size} non-canonical placement(s)" if ids.any?
ids.each_slice(500) do |batch|
execute <<~SQL.squish
DELETE FROM coplan_plan_placements
WHERE id IN (#{batch.map { |id| quote(id) }.join(",")})
SQL
end

# The new index subsumes index_..._on_plan_id_and_library_id, the old
# "one shelf per library" constraint. Added before the old one is
# dropped: MySQL requires an index on plan_id for its foreign key and
# refuses to drop the last one that satisfies it.
add_index :coplan_plan_placements, :plan_id, unique: true,
name: "index_coplan_plan_placements_on_plan_id"
remove_index :coplan_plan_placements, column: [ :plan_id, :library_id ]
end

def down
add_index :coplan_plan_placements, [ :plan_id, :library_id ], unique: true,
name: "index_coplan_plan_placements_on_plan_id_and_library_id"
remove_index :coplan_plan_placements, column: :plan_id
end

private

# Grouped in Ruby rather than SQL: picking a winner per plan needs a
# correlated "is this the author's library" test, and the portable
# forms of that are worse than reading the table. Placement counts are
# in the thousands at most.
def surplus_placement_ids
rows = connection.select_all(<<~SQL.squish).to_a
SELECT pp.id AS id, pp.plan_id AS plan_id, pp.created_at AS created_at,
lib.owner_type AS owner_type, lib.owner_id AS owner_id,
p.created_by_user_id AS author_id
FROM coplan_plan_placements pp
INNER JOIN coplan_plans p ON p.id = pp.plan_id
INNER JOIN coplan_libraries lib ON lib.id = pp.library_id
SQL

rows.group_by { |row| row["plan_id"] }.flat_map do |_plan_id, group|
next [] if group.size < 2

keeper = group.find { |row| authors_own?(row) } || group.min_by { |row| row["created_at"].to_s }
(group - [ keeper ]).map { |row| row["id"] }
end
end

def authors_own?(row)
row["owner_type"] == "CoPlan::User" && row["owner_id"].to_s == row["author_id"].to_s
end

def quote(value)
connection.quote(value)
end
end
23 changes: 21 additions & 2 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading