Agent Skillsdilolabs/nosia › state-records

state-records

GitHub

指导将业务状态从布尔值转换为独立记录模型,以追踪变更历史、操作人和时间。提供Rails生成命令、模型模板及关注点模块结构。

.claude/skills/state-records/SKILL.md dilolabs/nosia

Trigger Scenarios

需要替代布尔标志来记录状态变更 询问状态记录模式或状态历史追踪 涉及状态变更的审计需求

Install

npx skills add dilolabs/nosia --skill state-records -g -y
More Options

Non-standard path

npx skills add https://github.com/dilolabs/nosia/tree/main/.claude/skills/state-records -g -y

Use without installing

npx skills use dilolabs/nosia@state-records

指定 Agent (Claude Code)

npx skills add dilolabs/nosia --skill state-records -a claude-code -g -y

安装 repo 全部 skill

npx skills add dilolabs/nosia --all -g -y

预览 repo 内 skill

npx skills add dilolabs/nosia --list

SKILL.md

Frontmatter
{
    "name": "state-records",
    "license": "MIT",
    "description": "Implements the state-as-records-not-booleans pattern for rich state tracking. Use when modeling state changes, replacing boolean flags with record-based state, or when user mentions state records, closures, publications, or toggling state. WHEN NOT: Technical flags like cached\/processed (use booleans), concern extraction (use concern-patterns), general model work (use model-patterns).",
    "compatibility": "Ruby 3.3+, Rails 8.0+"
}

State Records (37signals)

State as records, not booleans. Instead of closed: boolean, create a Closure record.

Project knowledge

Tech Stack: Rails 8.2 (edge), UUIDs everywhere, ActiveRecord associations Pattern: One state model per boolean you'd normally add Naming: Noun forms (Closure, Publication, Goldness, NotNow, Archival)

Commands:

bin/rails generate model Closure card:references:uuid user:references:uuid account:references:uuid
bin/rails db:migrate
bin/rails console                              # Test: Card.open.count
bin/rails test test/models/

Why state records over booleans

Boolean columns give you:

  • Current state (open/closed)

State records give you:

  • Current state (closure.present?)
  • When it changed (closure.created_at)
  • Who changed it (closure.user)
  • Why it changed (closure.reason)
  • Change history (via events)

The pattern

Boolean approach (avoid for business state):

# BAD
class Card < ApplicationRecord
  def close
    update!(closed: true, closed_at: Time.current)
  end

  scope :open, -> { where(closed: false) }
end

State record approach:

# GOOD
class Closure < ApplicationRecord
  # touch: true ensures the parent's updated_at changes when state changes,
  # which drives cache invalidation (Russian doll caching, ETags, etc.)
  belongs_to :card, touch: true
  belongs_to :user, optional: true
  belongs_to :account, default: -> { card.account }

  validates :card, uniqueness: true
end

class Card < ApplicationRecord
  has_one :closure, dependent: :destroy

  def close(user: Current.user)
    create_closure!(user: user)
  end

  def reopen
    closure&.destroy!
  end

  def closed?
    closure.present?
  end

  scope :open, -> { where.missing(:closure) }
  scope :closed, -> { joins(:closure) }
end

State record model template

Every state record model follows this structure:

class Closure < ApplicationRecord
  belongs_to :account, default: -> { card.account }
  belongs_to :card, touch: true
  belongs_to :user, optional: true

  validates :card, uniqueness: true

  after_create_commit :notify_watchers
  after_destroy_commit :notify_watchers

  private

  def notify_watchers
    card.notify_watchers_later
  end
end

State concern template

Every state concern follows this structure:

module Card::Closeable
  extend ActiveSupport::Concern

  included do
    has_one :closure, dependent: :destroy

    scope :open, -> { where.missing(:closure) }
    scope :closed, -> { joins(:closure) }
  end

  def close(user: Current.user)
    create_closure!(user: user)
    track_event "card_closed", user: user
  end

  def reopen
    closure&.destroy!
    track_event "card_reopened"
  end

  def closed?
    closure.present?
  end

  def open?
    !closed?
  end

  def closed_at
    closure&.created_at
  end

  def closed_by
    closure&.user
  end
end

State record with metadata

When state needs additional data (secure tokens, descriptions):

class Board::Publication < ApplicationRecord
  belongs_to :account, default: -> { board.account }
  belongs_to :board, touch: true

  has_secure_token :key

  validates :board, uniqueness: true

  def public_url
    Rails.application.routes.url_helpers.public_board_url(key)
  end
end

module Board::Publishable
  extend ActiveSupport::Concern

  included do
    has_one :publication, dependent: :destroy

    scope :published, -> { joins(:publication) }
    scope :private, -> { where.missing(:publication) }
  end

  def publish(description: nil)
    create_publication!(description: description)
    track_event "board_published"
  end

  def unpublish
    publication&.destroy!
    track_event "board_unpublished"
  end

  def published?
    publication.present?
  end

  def public_url
    publication&.public_url
  end
end

Query patterns with state records

# Finding by state: positive uses joins, negative uses where.missing
Card.open                    # where.missing(:closure)
Card.closed                  # joins(:closure)
Board.published              # joins(:publication)
Card.golden                  # joins(:goldness)

# Complex combinations
scope :actionable, -> {
  where.missing(:closure).where.missing(:not_now).where.missing(:archival)
}

# Sorting by state
scope :with_golden_first, -> {
  left_outer_joins(:goldness)
    .select("cards.*", "card_goldnesses.created_at as golden_at")
    .order(Arel.sql("golden_at IS NULL, golden_at DESC"))
}

# Filtering by actor
scope :closed_by, ->(user) { joins(:closure).where(closures: { user: user }) }

Controller patterns

State changes map to singular resources with create/destroy:

# config/routes.rb
resources :cards do
  resource :closure, only: [:create, :destroy], module: :cards
  resource :goldness, only: [:create, :destroy], module: :cards
  resource :not_now, only: [:create, :destroy], module: :cards
end

# app/controllers/cards/closures_controller.rb
class Cards::ClosuresController < ApplicationController
  include CardScoped

  def create
    @card.close(user: Current.user)
    render_card_replacement
  end

  def destroy
    @card.reopen
    render_card_replacement
  end
end

View patterns

<%# Toggle button %>
<%= button_to card_goldness_path(card),
    method: card.golden? ? :delete : :post,
    data: { turbo_frame: dom_id(card) } do %>
  <%= card.golden? ? "Ungild" : "Gild" %>
<% end %>

<%# State badge %>
<% if card.closed? %>
  <span class="badge badge--closed">
    Closed <%= time_ago_in_words(card.closed_at) %> ago
    <% if card.closed_by %>by <%= card.closed_by.name %><% end %>
  </span>
<% end %>

When to use state records vs booleans

Use state records when:

  • You need to know when state changed
  • You need to know who changed it
  • You might store metadata (reason, notes)
  • State changes are important business events
  • You need queries like "recently closed" or "closed by X"

Use booleans when:

  • State is purely technical (cached, processed)
  • Timestamp/actor don't matter
  • Performance is critical (millions of rows, frequent updates)
  • State changes are not business events

Quick reference:

  • State records: closed, published, archived, suspended, verified, pinned, golden, postponed
  • Booleans: admin, cached, processed, visible

See references/state-record-examples.md for complete examples of each state type.

Migration from boolean to state record

  1. Create state record model + migration
  2. Backfill existing data
  3. Update model code to use concern
  4. Remove boolean column (after verification)

Boundaries

  • Always: Create state record for business-meaningful states, track who and when, use where.missing for negative scopes, add unique index on parent_id, touch parent record, write tests for state transitions
  • Ask first: Before using boolean columns for business state, before adding complex metadata (might need separate model)
  • Never: Use booleans for important business state, skip who/when tracking, create multiple state records per parent (use has_one with unique index), skip event tracking for state changes

Version History

  • edd76af Current 2026-08-20 14:24

Same Skill Collection

.claude/skills/api-patterns/SKILL.md
.claude/skills/auth-setup/SKILL.md
.claude/skills/caching-patterns/SKILL.md
.claude/skills/concern-patterns/SKILL.md
.claude/skills/crud-patterns/SKILL.md
.claude/skills/event-tracking/SKILL.md
.claude/skills/job-patterns/SKILL.md
.claude/skills/mailer-patterns/SKILL.md
.claude/skills/migration-patterns/SKILL.md
.claude/skills/model-patterns/SKILL.md
.claude/skills/multi-tenant-setup/SKILL.md
.claude/skills/stimulus-patterns/SKILL.md
.claude/skills/testing-patterns/SKILL.md
.claude/skills/turbo-patterns/SKILL.md
.opencode/skills/api-patterns/SKILL.md
.opencode/skills/auth-setup/SKILL.md
.opencode/skills/caching-patterns/SKILL.md
.opencode/skills/concern-patterns/SKILL.md
.opencode/skills/crud-patterns/SKILL.md
.opencode/skills/event-tracking/SKILL.md
.opencode/skills/job-patterns/SKILL.md
.opencode/skills/mailer-patterns/SKILL.md
.opencode/skills/migration-patterns/SKILL.md
.opencode/skills/model-patterns/SKILL.md
.opencode/skills/multi-tenant-setup/SKILL.md
.opencode/skills/state-records/SKILL.md
.opencode/skills/stimulus-patterns/SKILL.md
.opencode/skills/testing-patterns/SKILL.md
.opencode/skills/turbo-patterns/SKILL.md
.vibe/skills/api-patterns/SKILL.md
.vibe/skills/auth-setup/SKILL.md
.vibe/skills/caching-patterns/SKILL.md
.vibe/skills/concern-patterns/SKILL.md
.vibe/skills/crud-patterns/SKILL.md
.vibe/skills/event-tracking/SKILL.md
.vibe/skills/job-patterns/SKILL.md
.vibe/skills/mailer-patterns/SKILL.md
.vibe/skills/migration-patterns/SKILL.md
.vibe/skills/model-patterns/SKILL.md
.vibe/skills/multi-tenant-setup/SKILL.md
.vibe/skills/state-records/SKILL.md
.vibe/skills/stimulus-patterns/SKILL.md
.vibe/skills/testing-patterns/SKILL.md
.vibe/skills/turbo-patterns/SKILL.md

Metadata

Files
0
Version
edd76af
Hash
5b7daad0
Indexed
2026-08-20 14:24

- 위키
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-08-25 06:24
浙ICP备14020137号-1 $방문자$