コミットを比較
2 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| c1d9e3638b | |||
| f1181e8510 |
@@ -574,8 +574,32 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
||||
wording and placement before implementing it.
|
||||
- Do not invent replacement copy when removing unrequested wording.
|
||||
- Do not create, modify, or run tests unless the user explicitly asks for
|
||||
test work. When the user asks for tests, keep working and rerun them until
|
||||
they pass or the remaining failure is clearly blocked.
|
||||
test work. When the user asks for tests, keep working within the permitted
|
||||
test-file scope and rerun them until they pass or the remaining failure is
|
||||
clearly blocked.
|
||||
- Test-only work includes adding, updating, deleting, reorganising, or fixing
|
||||
SyntaxError in tests. During test-only work, do not modify production code.
|
||||
- During test-only work, do not change production constants, behaviour, API
|
||||
contracts, validation, routes, authentication, permissions, UI, copy,
|
||||
dependencies, limits, thresholds, defaults, migrations, schema, or
|
||||
environment settings to satisfy tests.
|
||||
- Do not make production code match failing tests, mock assumptions, fixtures,
|
||||
snapshots, old expectations, or stale setup. This includes changing
|
||||
production constants merely because a test expects a different value.
|
||||
- If test work reveals a production bug, spec mismatch, or missing behaviour,
|
||||
stop without modifying production code and report: the failing test or
|
||||
discovered issue, the related production file, the actual behaviour, the
|
||||
expected behaviour, and why a production change appears necessary.
|
||||
- Modify production code for test failures only when the user explicitly asks
|
||||
for that production change. Do not expand a test task into a production task
|
||||
on your own authority.
|
||||
- If the user explicitly asks for both production implementation and test
|
||||
updates, implement production code to the confirmed specification first,
|
||||
then add or update tests to verify that specification. Never roll production
|
||||
behaviour back to satisfy old tests.
|
||||
- If it is unclear whether the test or the production implementation is stale,
|
||||
or a test cannot be corrected without changing production code, ask the user
|
||||
instead of guessing.
|
||||
|
||||
## Backend rules
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
class AddTagsJsonToPostVersions < ActiveRecord::Migration[8.0]
|
||||
RESOLUTION_GRACE = 1.second
|
||||
SECTION_LITERAL_PATTERN = /\[[^\[\]\s]*-[^\[\]\s]*\]\z/
|
||||
|
||||
class MigrationPostVersion < ActiveRecord::Base
|
||||
self.table_name = 'post_versions'
|
||||
end
|
||||
|
||||
class MigrationTag < ActiveRecord::Base
|
||||
self.table_name = 'tags'
|
||||
end
|
||||
|
||||
class MigrationTagName < ActiveRecord::Base
|
||||
self.table_name = 'tag_names'
|
||||
end
|
||||
|
||||
class MigrationTagVersion < ActiveRecord::Base
|
||||
self.table_name = 'tag_versions'
|
||||
end
|
||||
|
||||
class MigrationNicoTagVersion < ActiveRecord::Base
|
||||
self.table_name = 'nico_tag_versions'
|
||||
end
|
||||
|
||||
def up
|
||||
add_column :post_versions, :tags_json, :json, after: :tags
|
||||
MigrationPostVersion.reset_column_information
|
||||
|
||||
backfill_missing_initial_tag_versions!
|
||||
intervals_by_name = build_intervals_by_name
|
||||
|
||||
say_with_time 'Backfilling post_versions.tags_json' do
|
||||
MigrationPostVersion
|
||||
.where(tags_json: nil)
|
||||
.find_each(batch_size: 500) do |version|
|
||||
version.update_columns(
|
||||
tags_json: build_tags_json(version, intervals_by_name))
|
||||
end
|
||||
end
|
||||
|
||||
assert_backfill_complete!
|
||||
change_column_null :post_versions, :tags_json, false
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :post_versions, :tags_json
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def backfill_missing_initial_tag_versions!
|
||||
say_with_time 'Backfilling missing initial tag versions' do
|
||||
tag_rows = missing_initial_version_rows(MigrationTagVersion, nico: false)
|
||||
nico_rows = missing_initial_version_rows(MigrationNicoTagVersion, nico: true)
|
||||
|
||||
MigrationTagVersion.insert_all!(tag_rows) if tag_rows.any?
|
||||
MigrationNicoTagVersion.insert_all!(nico_rows) if nico_rows.any?
|
||||
|
||||
tag_rows.length + nico_rows.length
|
||||
end
|
||||
end
|
||||
|
||||
def missing_initial_version_rows version_class, nico:
|
||||
first_versions =
|
||||
version_class
|
||||
.order(:tag_id, :version_no)
|
||||
.to_a
|
||||
.group_by(&:tag_id)
|
||||
.transform_values(&:first)
|
||||
rows = []
|
||||
|
||||
MigrationTag.find_each do |tag|
|
||||
next if (tag.category == 'nico') != nico
|
||||
|
||||
first_version = first_versions[tag.id]
|
||||
next unless first_version
|
||||
next if valid_initial_version?(first_version)
|
||||
|
||||
assert_inferable_initial_version!(tag, first_version)
|
||||
rows << initial_version_row(tag, first_version, nico:)
|
||||
end
|
||||
|
||||
rows
|
||||
end
|
||||
|
||||
def valid_initial_version? version
|
||||
version.version_no == 1 && version.event_type == 'create'
|
||||
end
|
||||
|
||||
def assert_inferable_initial_version! tag, version
|
||||
inferable =
|
||||
version.version_no == 2 &&
|
||||
version.event_type == 'discard' &&
|
||||
tag.created_at < version.created_at
|
||||
return if inferable
|
||||
|
||||
details = [
|
||||
"tag_id=#{ tag.id }",
|
||||
"version_no=#{ version.version_no }",
|
||||
"event_type=#{ version.event_type.inspect }",
|
||||
"tag_created_at=#{ tag.created_at.iso8601(6) }",
|
||||
"version_created_at=#{ version.created_at.iso8601(6) }"]
|
||||
|
||||
raise "Cannot infer initial tag version: #{ details.join(', ') }"
|
||||
end
|
||||
|
||||
def initial_version_row tag, discard_version, nico:
|
||||
row = {
|
||||
tag_id: tag.id,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
name: discard_version.name,
|
||||
created_at: tag.created_at,
|
||||
created_by_user_id: nil }
|
||||
|
||||
if nico
|
||||
return row.merge(linked_tags: discard_version.linked_tags)
|
||||
end
|
||||
|
||||
row.merge(
|
||||
category: discard_version.category,
|
||||
aliases: discard_version.aliases,
|
||||
parent_tag_ids: discard_version.parent_tag_ids,
|
||||
deprecated_at: discard_version.deprecated_at)
|
||||
end
|
||||
|
||||
def build_intervals_by_name
|
||||
intervals_by_name = Hash.new { |hash, name| hash[name] = [] }
|
||||
versions_by_kind = {
|
||||
tag: versions_by_tag_id(MigrationTagVersion),
|
||||
nico: versions_by_tag_id(MigrationNicoTagVersion) }
|
||||
current_names = current_names_by_tag_id
|
||||
|
||||
MigrationTag.find_each do |tag|
|
||||
nico = tag.category == 'nico'
|
||||
kind = nico ? :nico : :tag
|
||||
versions = versions_by_kind.fetch(kind).fetch(tag.id, [])
|
||||
|
||||
intervals_for(
|
||||
tag,
|
||||
versions,
|
||||
current_name: current_names.fetch(tag.id),
|
||||
nico:).each do |interval|
|
||||
name = interval.delete(:name)
|
||||
intervals_by_name[name] << interval
|
||||
end
|
||||
end
|
||||
|
||||
intervals_by_name
|
||||
end
|
||||
|
||||
def versions_by_tag_id version_class
|
||||
version_class
|
||||
.order(:tag_id, :version_no)
|
||||
.to_a
|
||||
.group_by(&:tag_id)
|
||||
end
|
||||
|
||||
def current_names_by_tag_id
|
||||
MigrationTagName
|
||||
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
|
||||
.pluck('tags.id', 'tag_names.name')
|
||||
.to_h
|
||||
end
|
||||
|
||||
def intervals_for tag, versions, current_name:, nico:
|
||||
if versions.empty?
|
||||
return [{
|
||||
name: current_name,
|
||||
tag_id: tag.id,
|
||||
version_no: tag.version_no,
|
||||
category: nico ? 'nico' : tag.category,
|
||||
from: tag.created_at,
|
||||
to: tag.discarded_at }]
|
||||
end
|
||||
|
||||
versions.each_with_index.filter_map do |version, index|
|
||||
next if version.event_type == 'discard'
|
||||
|
||||
{
|
||||
name: version.name,
|
||||
tag_id: tag.id,
|
||||
version_no: version.version_no,
|
||||
category: nico ? 'nico' : version.category,
|
||||
from: version.created_at,
|
||||
to: versions[index + 1]&.created_at || tag.discarded_at }
|
||||
end
|
||||
end
|
||||
|
||||
def build_tags_json version, intervals_by_name
|
||||
entries = version.tags.to_s.split.map do |literal|
|
||||
name = tag_name_from_literal(literal)
|
||||
interval = resolve_tag!(intervals_by_name.fetch(name, []), name:, version:)
|
||||
|
||||
{ 'id' => interval.fetch(:tag_id),
|
||||
'version_no' => interval.fetch(:version_no),
|
||||
'name' => name,
|
||||
'category' => interval.fetch(:category),
|
||||
'sections' => [] }
|
||||
end
|
||||
|
||||
assert_unique_tag_ids!(version, entries)
|
||||
|
||||
entries.sort_by { |entry| entry.fetch('id') }
|
||||
end
|
||||
|
||||
def tag_name_from_literal literal
|
||||
name = literal.dup
|
||||
name.sub!(SECTION_LITERAL_PATTERN, '') while name.match?(
|
||||
SECTION_LITERAL_PATTERN)
|
||||
|
||||
if name.empty? || name.include?('[') || name.include?(']')
|
||||
raise "Invalid legacy tag literal: #{ literal.inspect }"
|
||||
end
|
||||
|
||||
name
|
||||
end
|
||||
|
||||
def resolve_tag! intervals, name:, version:
|
||||
time = version.created_at
|
||||
candidates = intervals.select do |interval|
|
||||
interval.fetch(:from) <= time &&
|
||||
(interval[:to].nil? || time < interval.fetch(:to))
|
||||
end
|
||||
|
||||
candidates = future_candidates(intervals, time) if candidates.empty?
|
||||
|
||||
return candidates.first if candidates.one?
|
||||
|
||||
candidate_versions = candidates.map do |candidate|
|
||||
[candidate.fetch(:tag_id), candidate.fetch(:version_no)]
|
||||
end
|
||||
details = [
|
||||
"post_version_id=#{ version.id }",
|
||||
"post_id=#{ version.post_id }",
|
||||
"name=#{ name.inspect }",
|
||||
"created_at=#{ time.iso8601(6) }",
|
||||
"candidates=#{ candidate_versions.inspect }"].join(', ')
|
||||
|
||||
raise "Could not resolve tag snapshot: #{ details }"
|
||||
end
|
||||
|
||||
def future_candidates intervals, time
|
||||
candidates = intervals.select do |interval|
|
||||
interval.fetch(:from) > time &&
|
||||
interval.fetch(:from) <= time + RESOLUTION_GRACE
|
||||
end
|
||||
return [] if candidates.empty?
|
||||
|
||||
nearest_from = candidates.map { |interval| interval.fetch(:from) }.min
|
||||
|
||||
candidates.select do |interval|
|
||||
interval.fetch(:from) == nearest_from
|
||||
end
|
||||
end
|
||||
|
||||
def assert_unique_tag_ids! version, entries
|
||||
duplicate_tag_ids =
|
||||
entries
|
||||
.map { |entry| entry.fetch('id') }
|
||||
.tally
|
||||
.select { |_tag_id, count| count > 1 }
|
||||
.keys
|
||||
return if duplicate_tag_ids.empty?
|
||||
|
||||
details = [
|
||||
"post_version_id=#{ version.id }",
|
||||
"duplicate_tag_ids=#{ duplicate_tag_ids.inspect }"].join(', ')
|
||||
|
||||
raise "Duplicate tag IDs: #{ details }"
|
||||
end
|
||||
|
||||
def assert_backfill_complete!
|
||||
missing_count = MigrationPostVersion.where(tags_json: nil).count
|
||||
return if missing_count.zero?
|
||||
|
||||
raise "#{ missing_count } post versions were not backfilled"
|
||||
end
|
||||
end
|
||||
生成ファイル
+2
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do
|
||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
@@ -349,6 +349,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
|
||||
t.string "url", limit: 768, null: false
|
||||
t.string "thumbnail_base", limit: 2000
|
||||
t.text "tags", null: false
|
||||
t.json "tags_json", null: false
|
||||
t.text "parent_post_ids", null: false
|
||||
t.datetime "original_created_from"
|
||||
t.datetime "original_created_before"
|
||||
|
||||
@@ -29,15 +29,15 @@ describe ('post new review URL state', () => {
|
||||
.toEqual (['one', 'two'])
|
||||
})
|
||||
|
||||
it ('allows at most a 4095-byte request target', () => {
|
||||
it ('allows at most a 6 143 byte request target', () => {
|
||||
const baseUrl = 'https://example.com/'
|
||||
const baseLength = postNewReviewPathByteLength ([baseUrl])
|
||||
const allowed = `${ baseUrl }${ 'a'.repeat (4_095 - baseLength) }`
|
||||
const allowed = `${ baseUrl }${ 'a'.repeat (6_143 - baseLength) }`
|
||||
const denied = `${ allowed }a`
|
||||
|
||||
expect (postNewReviewPathByteLength ([allowed])).toBe (4_095)
|
||||
expect (postNewReviewPathByteLength ([allowed])).toBe (6_143)
|
||||
expect (isPostNewReviewPathWithinLimit ([allowed])).toBe (true)
|
||||
expect (postNewReviewPathByteLength ([denied])).toBe (4_096)
|
||||
expect (postNewReviewPathByteLength ([denied])).toBe (6_144)
|
||||
expect (isPostNewReviewPathWithinLimit ([denied])).toBe (false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const POST_NEW_REVIEW_PATH_PREFIX = '/posts/new?urls='
|
||||
const MAX_POST_NEW_REVIEW_TARGET_BYTES = 4_096
|
||||
const MAX_POST_NEW_REVIEW_TARGET_BYTES = 6_144
|
||||
|
||||
const textEncoder = new TextEncoder ()
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ describe ('PostImportSourcePage', () => {
|
||||
const input = screen.getByRole ('textbox', { name: '' })
|
||||
|
||||
fireEvent.change (input, {
|
||||
target: { value: `https://example.com/${ 'a'.repeat (4_100) }` } })
|
||||
target: { value: `https://example.com/${ 'a'.repeat (6_200) }` } })
|
||||
|
||||
expect (screen.getByRole ('button', { name: '次へ' })).toBeDisabled ()
|
||||
})
|
||||
|
||||
新しい課題から参照
ユーザをブロックする