Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5333197
Fix attr_accessor vs def: last definition wins for typed ivars
cursoragent Oct 5, 2026
62f6710
Clear Campfire preview Spinel blockers beside the ivar fix
cursoragent Oct 5, 2026
9b73a1e
Keep model **kwrest beside keywords (valid Ruby/Spinel)
cursoragent Oct 5, 2026
6b53113
Address Thermos code-quality findings on loaded?/attr replace
cursoragent Oct 5, 2026
1a68fc0
Address Thermos branch-audit findings on loaded?/columns
cursoragent Oct 5, 2026
3700e77
Wrap Array subclasses as @elements for Spinel
cursoragent Oct 5, 2026
c738af5
Clear remaining Spinel AOT walls for Campfire preview
cursoragent Oct 5, 2026
65d6908
Fix Campfire sidebar ORDER BY nested hash and Relation scope forks
cursoragent Oct 5, 2026
f2c284b
Address CodeRabbit findings on #462 Spinel/preview fixes
cursoragent Oct 5, 2026
7134b04
Harden Array wrap and association.target scoping
cursoragent Oct 6, 2026
017e33a
Keep association.target rewrite inside concern modules
cursoragent Oct 6, 2026
fdd426b
Fix attr last-wins collapsing duplicate defs
cursoragent Oct 6, 2026
5743b7e
Honest Array wrap: blockless each/all?, size super, empty init
cursoragent Oct 6, 2026
037c571
Drop Array subclass wrap from this PR
cursoragent Oct 6, 2026
42bc366
Keep #451 untyped ceiling of 500
cursoragent Oct 6, 2026
b5b7805
Tighten assoc rewrite owner and skip class-method defaults
cursoragent Oct 6, 2026
ece8055
Unique-name assoc rewrite only outside model methods
cursoragent Oct 6, 2026
2c4b467
Keep spawn/order_term under #451 typing ceilings
cursoragent Oct 6, 2026
3004a69
Treat Symbol order terms as strings, not hashes
cursoragent Oct 6, 2026
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
91 changes: 87 additions & 4 deletions runtime/ruby/active_record/relation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,60 @@ def initialize(model)
@ctes = []
end

# Rails' `Relation#spawn`: a new relation that shares this one's
# query state but not its accumulator arrays. Chain methods still
# mutate in place; scopes call `spawn` on entry so a fork like
# campfire's sidebar (`visible.with_direct_rooms` beside
# `visible.with_ordered_room.without_direct_rooms`) does not let
# one branch's joins/orders/wheres pollute the other. Accumulators
# are copied element-wise (no `Array#dup` — keep the element type
# the typer already knows); `@records` is shared until a chain
# method clears it, matching Rails' loaded-spawn contract.
def spawn
copy = clone
copy.take_query_lists(
@wheres,
@joins,
@orders,
@groups,
@havings,
@ctes,
@includes,
@scope_attributes
)
copy
end

# List accumulators go through copy_* so inference types the
# parameters. Scalars come from `clone` (shallow ivar copy).
def take_query_lists(wheres, joins, orders, groups, havings, ctes, includes, attrs)
@wheres = copy_string_list(wheres)
@joins = copy_string_list(joins)
@orders = copy_string_list(orders)
@groups = copy_string_list(groups)
@havings = copy_string_list(havings)
@ctes = copy_string_list(ctes)
@includes = copy_symbol_list(includes)
@scope_attributes = copy_scope_attributes(attrs)
self
end

def copy_string_list(xs)
out = []
xs.each { |x| out << x }
out
end

def copy_symbol_list(xs)
out = []
xs.each { |x| out << x }
out
end

def copy_scope_attributes(attrs)
attrs
end

# ---- chain methods (return self) --------------------------------

# `with_recursive(parents: [base, step])` — a recursive common table
Expand Down Expand Up @@ -933,6 +987,16 @@ def find_each
self
end

# `find_in_batches` — Rails yields successive Arrays of rows.
# Corpus sizes make one load the same answer as find_each; yield
# the whole page as a single batch. Campfire's unread fanout /
# push paths call this on memberships.
def find_in_batches
records = loaded_records
yield records
self
end

# Via loaded_records (not to_a): no shallow Array copy of the
# memoized rows. to_a keeps its Rails dup contract for callers that
# mutate the returned array.
Expand Down Expand Up @@ -1819,15 +1883,34 @@ def escape_list(vals)
out.join(", ")
end

# `order(:col)` / `order("col DESC")` / `order(col: :desc)`.
# `order(:col)` / `order("col DESC")` / `order(col: :desc)` /
# `order(rooms: { updated_at: :desc })` — Rails' nested-hash form
# for a table-qualified column (campfire's direct-room sidebar).
# A nested value that is itself a Hash is `table.col DIR`, not the
# Hash's `to_s` (which reached SQLite as `rooms {UPDATED_AT: :DESC}`
# and raised `unrecognized token: "{"`).
def order_term(p)
if p.is_a?(Hash)
parts = []
p.each { |col, dir| parts << "#{col} #{dir.to_s.upcase}" }
parts.join(", ")
format_order_hash(p)
else
p.to_s
end
end

# Isolated so `each` sees `Hash[Symbol, untyped]` keys as Symbol.
# `order_term`'s Hash|String union does not narrow, and untyped
# `col` interpolations blow the Bar B ceiling.
def format_order_hash(h)
parts = []
h.each do |col, dir|
if dir.is_a?(Hash)
inner_col = dir.keys[0]
parts << "#{col}.#{inner_col} #{dir[inner_col].to_s.upcase}"
else
parts << "#{col} #{dir.to_s.upcase}"
end
end
parts.join(", ")
end
end
end
21 changes: 20 additions & 1 deletion runtime/ruby/active_record/relation.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,23 @@ module ActiveRecord
# would otherwise fail campfire AOT.
def initialize: (Base model) -> void

# Rails' Relation#spawn — copy query state onto a fresh Relation so
# a scope fork cannot mutate its caller's accumulators.
def spawn: () -> Relation
def take_query_lists: (
Array[String] wheres,
Array[String] joins,
Array[String] orders,
Array[String] groups,
Array[String] havings,
Array[String] ctes,
Array[Symbol] includes,
Hash[Symbol, untyped] attrs
) -> Relation
def copy_string_list: (Array[String] xs) -> Array[String]
def copy_symbol_list: (Array[Symbol] xs) -> Array[Symbol]
def copy_scope_attributes: (Hash[Symbol, untyped] attrs) -> Hash[Symbol, untyped]

def where: (?untyped condition, *untyped args) -> Relation

# Query ivars the body reads after builders write them. Declared so
Expand Down Expand Up @@ -146,6 +163,7 @@ module ActiveRecord
def each: () { (untyped) -> void } -> Relation
def index_by: () { (untyped) -> untyped } -> Hash[untyped, untyped]
def find_each: () { (untyped) -> void } -> Relation
def find_in_batches: () { (Array[untyped]) -> void } -> Relation
# `map`/`collect` return `untyped`, NOT `Array[untyped]`: the result's
# element type is whatever the BLOCK returns, which this RBS has no
# type variable to say. `Array[untyped]` is a seed spinel TRUSTS, so
Expand Down Expand Up @@ -232,6 +250,7 @@ module ActiveRecord
def named_bind_start?: (String ch) -> bool
def named_bind_char?: (String ch) -> bool
def escape_list: (Array[untyped] vals) -> String
def order_term: (untyped p) -> String
def order_term: (Hash[Symbol, untyped] | String p) -> String
def format_order_hash: (Hash[Symbol, untyped] h) -> String
end
end
24 changes: 24 additions & 0 deletions runtime/ruby/test/active_record/base_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,30 @@ def test_relation_last_n_with_offset_falls_back_to_materialize
assert_equal ["T3", "T4"], tail.map(&:title)
end

def test_order_term_nested_hash_qualifies_table_column
rel = ActiveRecord::Relation.new(Item)
assert_equal "rooms.updated_at DESC", rel.order_term({ rooms: { updated_at: :desc } })
assert_equal "updated_at DESC", rel.order_term({ updated_at: :desc })
assert_equal "updated_at", rel.order_term(:updated_at)
assert_equal "id DESC", rel.order_term("id DESC")
end

def test_spawn_copies_state_without_sharing_accumulators
base = ActiveRecord::Relation.new(Item).where(title: "A")
prior = base.to_sql
fork = base.spawn.where(title: "B")
assert_match(/title = 'B'/, fork.to_sql)
assert_equal prior, base.to_sql
end

def test_find_in_batches_yields_loaded_records_once
3.times { |i| it = Item.new; it.title = "T#{i}"; it.save() }
batches = []
ActiveRecord::Relation.new(Item).order("id").find_in_batches { |batch| batches << batch.map(&:title) }
assert_equal 1, batches.length
assert_equal ["T0", "T1", "T2"], batches[0]
end

def test_relation_more_than_probes_without_hydrate_or_mutation
5.times { |i| it = Item.new; it.title = "T#{i}"; it.save() }
rel = ActiveRecord::Relation.new(Item).where("title LIKE 'T%'")
Expand Down
19 changes: 19 additions & 0 deletions runtime/spinel/facades/sqlite_wal_checkpoint.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# SQLite WAL checkpoint background thread — façade for Spinel AOT.
#
# Preview Campfire's real body uses `ActiveRecord::Base.connection_db_config`,
# `File::LOCK_*` / `flock`, and `FileUtils` — none of which Spinel models
# yet. Puma starts this on CRuby; the Spinel HTTP harness does not use
# Puma, so a no-op module keeps `app/models.rb` loading without refusing
# the AOT compile. CRuby restore puts the verbatim body back.

module SqliteWalCheckpoint
INTERVAL = 0.25

def self.start(_interval = INTERVAL)
nil
end

def self.checkpoint
nil
end
end
5 changes: 5 additions & 0 deletions runtime/spinel/facades/sqlite_wal_checkpoint.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module SqliteWalCheckpoint
INTERVAL: Float
def self.start: (?Float interval) -> nil
def self.checkpoint: () -> nil
end
4 changes: 4 additions & 0 deletions src/catalog/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1499,6 +1499,10 @@ pub const AR_CATALOG: &[CatalogedMethod] = &[
chain: ChainKind::Terminal,
return_kind: Some(ReturnKind::Bool),
},
// Rails AssociationProxy `#loaded?` is rewritten by `assoc_loaded`
// onto `<assoc>_loaded?`. Do NOT catalog Relation `#loaded?` as
// Bool: that would silence residual sites with no runtime method
// (invariant 6). Unrewritten `.loaded?` stays a dispatch failure.
CatalogedMethod {
name: "more_than?",
receiver: ReceiverContext::Relation,
Expand Down
1 change: 1 addition & 0 deletions src/emit/ruby/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,7 @@ pub(crate) fn emit_relation_scope_delegates(app: &App) -> Option<EmittedFile> {
"find", "find_by", "first", "last", "all", "each", "map", "to_a", "count",
"exists?", "empty?", "any?", "none?", "sum", "maximum", "minimum", "pluck",
"pick", "destroy_all", "delete_all", "update_all", "klass", "where_clauses",
"spawn", "find_each", "find_in_batches",
];
let scopes = crate::lower::scope_chain::build_scope_registry(&app.models);
// name -> [(model, params)] in app-model order, names sorted — the
Expand Down
12 changes: 12 additions & 0 deletions src/facades.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,18 @@ pub const EXTRAS_FACADES: &[Facade] = &[
lifted_by_sql_functions: &[],
lifted_by_constants: &[],
},
// Preview Campfire's WAL checkpoint thread — File flock /
// connection_db_config / FileUtils, none of which Spinel models.
// Puma starts it on CRuby; Spinel HTTP does not use Puma. Stub
// keeps app/models.rb loading without refusing AOT.
Facade {
stem: "app/models/sqlite_wal_checkpoint",
class_name: "SqliteWalCheckpoint",
rb: include_str!("../runtime/spinel/facades/sqlite_wal_checkpoint.rb"),
rbs: include_str!("../runtime/spinel/facades/sqlite_wal_checkpoint.rbs"),
lifted_by_sql_functions: &[],
lifted_by_constants: &[],
},
];

/// The façade contracts, parsed into analysis signatures, for those
Expand Down
68 changes: 64 additions & 4 deletions src/ingest/library_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,7 @@ fn block_of(param: &str, body: Expr) -> Expr {
)
}


fn self_class() -> Expr {
Expr::new(Span::synthetic(), ExprNode::SelfRef)
}
Expand Down Expand Up @@ -1562,8 +1563,56 @@ fn walk_decl_body_with_visibility<'pr>(
if force_class_receiver || module_function_active || extend_self_active {
m.receiver = MethodReceiver::Class;
}
direct_def_positions.push(out.methods.len());
out.methods.push(m);
// A real `def` replaces a synthesized attr_* half of the
// same name (Ruby last-definition-wins for
// `attr_accessor :x` then `def x; … end`). An earlier real
// `def` is kept as duplicate evidence — `initialize` hooks
// and visibility tests rely on both surviving ingest. Match
// `push_user_methods`: only unsigned bare-ivar attr halves.
if let Some(idx) = out
.methods
.iter()
.position(|e| e.name == m.name && e.receiver == m.receiver)
{
let existing = &out.methods[idx];
let existing_is_attr_half = existing.signature.is_none()
&& match existing.kind {
crate::dialect::AccessorKind::AttributeReader => {
matches!(
&*existing.body.node,
ExprNode::Ivar { name } if name == &existing.name
)
}
crate::dialect::AccessorKind::AttributeWriter => {
let base = existing
.name
.as_str()
.strip_suffix('=')
.unwrap_or(existing.name.as_str());
matches!(
&*existing.body.node,
ExprNode::Assign {
target: LValue::Ivar { name },
..
} if name.as_str() == base
)
}
crate::dialect::AccessorKind::Method => false,
};
if existing_is_attr_half {
out.methods[idx] = m;
if !direct_def_positions.iter().any(|p| *p == idx) {
direct_def_positions.push(idx);
}
} else {
// Duplicate real `def` — keep both.
direct_def_positions.push(out.methods.len());
out.methods.push(m);
}
} else {
direct_def_positions.push(out.methods.len());
out.methods.push(m);
}
continue;
}
// `class << self ... end` — singleton class block. Body
Expand Down Expand Up @@ -1723,12 +1772,23 @@ fn walk_decl_body_with_visibility<'pr>(
if want_reader {
let mut method = synth_attr_reader(owner, name, recv);
visibility.apply(&statement, &mut method);
out.methods.push(method);
// Skip when a `def` of this name already
// walked (unusual order); a later `def`
// replaces via the push path above.
if !out.methods.iter().any(|e| {
e.name == method.name && e.receiver == method.receiver
}) {
out.methods.push(method);
}
}
if want_writer {
let mut method = synth_attr_writer(owner, name, recv);
visibility.apply(&statement, &mut method);
out.methods.push(method);
if !out.methods.iter().any(|e| {
e.name == method.name && e.receiver == method.receiver
}) {
out.methods.push(method);
}
}
}
}
Expand Down
22 changes: 18 additions & 4 deletions src/ingest/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1465,24 +1465,38 @@ pub(super) fn ingest_method(
// Beside a positional `*rest` the flattening does not parse
// (`def both(*args, options = {})`), so there the slot stays a
// real `**kwrest`, as the library-class path keeps it.
//
// Beside an earlier keyword (`def notification(badge: …,
// **params)` — campfire preview) flattening to `params = {}`
// also does not parse: optional positionals cannot follow
// keywords. Keep a real keyword-rest there too (same rule
// `library_class` applies when `keeps_keywords`).
if let Some(krest) = pn.keyword_rest() {
if let Some(krp) = krest.as_keyword_rest_parameter_node() {
if let Some(loc) = krp.name() {
let name = Symbol::from(constant_id_str(&loc));
let mut p = if params.iter().any(|p| p.rest) {
let has_keywords = params.iter().any(|p| p.keyword || p.from_keyword);
// Match `library_class`: `from_kwrest` marks the
// *flattened* `params = {}` form only. A real
// keyword-rest kept beside keywords / `*rest` must
// not carry the marker — forwarding analysis treats
// `from_kwrest` as "flattened keyword ABI" and would
// reject valid `**` / full-arg forwards into it.
let p = if params.iter().any(|p| p.rest) || has_keywords {
let mut p = crate::dialect::Param::keyword(name, None);
p.rest = true;
p
} else {
crate::dialect::Param::with_default(
let mut p = crate::dialect::Param::with_default(
name,
Expr::new(
Span::synthetic(),
ExprNode::Hash { entries: vec![], kwargs: false },
),
)
);
p.from_kwrest = true;
p
};
p.from_kwrest = true;
params.push(p);
}
}
Expand Down
Loading
Loading