diff --git a/docs/pipeline/runtime.md b/docs/pipeline/runtime.md
index 67643ca77..615a4b778 100644
--- a/docs/pipeline/runtime.md
+++ b/docs/pipeline/runtime.md
@@ -207,6 +207,23 @@ undocumented one reads as intent to the next session precisely because
it is applied consistently, and the emit gives no signal that anyone
weighed it.
+### Spinel `Date` is a bounded runtime value
+
+The Spinel target defines a small `Date` class in
+`runtime/spinel/date.rb` for Rails date columns. It stores a Gregorian
+year, month, and day; database storage and JSON use `YYYY-MM-DD`, with
+no clock or zone. Its ISO parser accepts only that exact format and
+validates the calendar date.
+
+This is not Ruby's stdlib `date` package. `DateTime`, Julian/Italian
+calendar modes, natural-language and non-ISO parsing, schema date
+defaults, ActiveSupport date extensions, date picker helpers, and date
+predicates are not included. `strftime` implements the date directives
+used by the admitted runtime contract and raises on other directives.
+The compiler continues diagnosing those unsupported paths. JRuby and
+other targets keep their existing Date boundary until they have their
+own runtime.
+
### `id` is `0` before save, not `nil` (`""` for a string key)
Each model's own `initialize` seeds `self.id = attrs[:id] || 0` — `||
diff --git a/runtime/ruby/active_record/base.rb b/runtime/ruby/active_record/base.rb
index b12096733..4a5067ae7 100644
--- a/runtime/ruby/active_record/base.rb
+++ b/runtime/ruby/active_record/base.rb
@@ -307,6 +307,10 @@ def self.schema_time_columns
[]
end
+ def self.schema_date_columns
+ []
+ end
+
def self.instantiate(_row)
raise NotImplementedError, "#{name}.instantiate must be overridden"
end
diff --git a/runtime/ruby/active_record/base.rbs b/runtime/ruby/active_record/base.rbs
index f45821cca..b5a0b1008 100644
--- a/runtime/ruby/active_record/base.rbs
+++ b/runtime/ruby/active_record/base.rbs
@@ -115,6 +115,7 @@ module ActiveRecord
def self._string_primary_key: () -> bool
def self.schema_columns: () -> Array[Symbol]
def self.schema_time_columns: () -> Array[Symbol]
+ def self.schema_date_columns: () -> Array[Symbol]
def self.instantiate: (Hash[String, untyped] _row) -> Base
# Eager-load hook consumed by `Relation#to_a`; the Ruby emit path
diff --git a/runtime/ruby/active_record/connection.rb b/runtime/ruby/active_record/connection.rb
index f14104786..6de012309 100644
--- a/runtime/ruby/active_record/connection.rb
+++ b/runtime/ruby/active_record/connection.rb
@@ -311,9 +311,12 @@ def _as_json_only(only)
h = {}
columns = self.class.schema_columns
time_columns = self.class.schema_time_columns
+ date_columns = self.class.schema_date_columns
only.each do |k|
next unless columns.include?(k)
- h[k.to_s] = if time_columns.include?(k)
+ h[k.to_s] = if date_columns.include?(k)
+ ActiveSupport.format_db_date(self[k])
+ elsif time_columns.include?(k)
ActiveSupport.json_time(self[k])
else
self[k]
diff --git a/runtime/spinel/active_record_date_serialization.rb b/runtime/spinel/active_record_date_serialization.rb
new file mode 100644
index 000000000..c29abaab7
--- /dev/null
+++ b/runtime/spinel/active_record_date_serialization.rb
@@ -0,0 +1,12 @@
+# Default ActiveRecord JSON shape for the Spinel runtime. This is kept
+# beside the bounded Date rather than in the shared runtime because the
+# Ruby-family overlay supplies its own reflection-aware implementation.
+module ActiveRecord
+ class Base
+ def as_json(options = {})
+ only = options && options[:only]
+ only ||= self.class.schema_columns
+ _as_json_only(only)
+ end
+ end
+end
diff --git a/runtime/spinel/active_support_time_parsing.rb b/runtime/spinel/active_support_time_parsing.rb
index 5137bd4da..5a20f10bf 100644
--- a/runtime/spinel/active_support_time_parsing.rb
+++ b/runtime/spinel/active_support_time_parsing.rb
@@ -20,6 +20,20 @@
# corpus are second-granularity, and JSON serializes from the raw
# string (`
_raw`), not the parsed Time.
module ActiveSupport
+ # SQL DATE has no clock or zone. The empty string is the SQLite
+ # adapter's nil representation for a nullable column.
+ def self.parse_db_date(value)
+ return nil if value.nil? || value == ""
+ Date.iso8601(value)
+ end
+
+ def self.format_db_date(value)
+ return nil if value.nil?
+ return value.iso8601 if value.is_a?(Date)
+ return Date.iso8601(value).iso8601 if value.is_a?(String)
+ raise TypeError, "expected Date, String, or nil"
+ end
+
# Rails zone name → IANA identifier. TWIN of the constant in the
# CRuby/JRuby overlay's sibling file, which shadows this whole file on
# those trees — the two must agree, so extend both together. Names not
diff --git a/runtime/spinel/active_support_time_parsing.rbs b/runtime/spinel/active_support_time_parsing.rbs
index ae6790f0f..a63df0215 100644
--- a/runtime/spinel/active_support_time_parsing.rbs
+++ b/runtime/spinel/active_support_time_parsing.rbs
@@ -2,6 +2,8 @@
# raw TEXT column value the synthesized reader passes — nil/"" for
# absent storage (the nil-guard narrows before the positional parse).
module ActiveSupport
+ def self.parse_db_date: (String? value) -> Date?
+ def self.format_db_date: (untyped value) -> String?
def self.parse_db_time: (String? str) -> Time?
def self.json_time: (String? str) -> String?
def self.db_now: () -> String
diff --git a/runtime/spinel/date.rb b/runtime/spinel/date.rb
new file mode 100644
index 000000000..d8d4ba511
--- /dev/null
+++ b/runtime/spinel/date.rb
@@ -0,0 +1,237 @@
+# A deliberately small, program-defined Date for the Spinel target.
+# This is not Ruby's stdlib `date` package. The supported API is the
+# surface analyzed for Rails date columns; unsupported formats fail
+# loudly instead of being interpreted as timestamps or guessed dates.
+class Date
+ class Error < ArgumentError
+ end
+
+ MONTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
+ WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
+ SHORT_WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
+ MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
+ SHORT_MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
+
+ def initialize(year, month, day)
+ unless year >= 1 && year <= 9999 && month >= 1 && month <= 12 && day >= 1 && day <= Date.month_length(year, month)
+ raise Date::Error, "invalid date"
+ end
+ @year = year
+ @month = month
+ @day = day
+ end
+
+ def self.civil(year, month = 1, day = 1)
+ Date.new(year, month, day)
+ end
+
+ def self.leap_year?(year)
+ year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
+ end
+
+ def self.month_length(year, month)
+ return 29 if month == 2 && Date.leap_year?(year)
+ MONTHS[month - 1]
+ end
+
+ def self.iso8601(value)
+ unless value.is_a?(String) && value.length == 10 && value[4, 1] == "-" && value[7, 1] == "-"
+ raise Date::Error, "invalid date: #{value}"
+ end
+ year = value[0, 4].to_i
+ month = value[5, 2].to_i
+ day = value[8, 2].to_i
+ date = Date.new(year, month, day)
+ raise Date::Error, "invalid date: #{value}" unless date.iso8601 == value
+ date
+ rescue ArgumentError
+ raise Date::Error, "invalid date: #{value}"
+ end
+
+ def self.parse(value, _comp = true)
+ Date.iso8601(value)
+ end
+
+ def self.strptime(value, format)
+ raise Date::Error, "unsupported date format" unless format == "%Y-%m-%d"
+ Date.iso8601(value)
+ end
+
+ def self.today
+ now = Time.now
+ Date.new(now.year, now.month, now.day)
+ end
+
+ def year
+ @year
+ end
+
+ def month
+ @month
+ end
+
+ def mon
+ @month
+ end
+
+ def day
+ @day
+ end
+
+ def mday
+ @day
+ end
+
+ def leap?
+ Date.leap_year?(@year)
+ end
+
+ def wday
+ offsets = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]
+ year = @year
+ year -= 1 if @month < 3
+ (year + year / 4 - year / 100 + year / 400 + offsets[@month - 1] + @day) % 7
+ end
+
+ def yday
+ total = @day
+ month = 1
+ while month < @month
+ total += Date.month_length(@year, month)
+ month += 1
+ end
+ total
+ end
+
+ def sunday?
+ wday == 0
+ end
+
+ def monday?
+ wday == 1
+ end
+
+ def tuesday?
+ wday == 2
+ end
+
+ def wednesday?
+ wday == 3
+ end
+
+ def thursday?
+ wday == 4
+ end
+
+ def friday?
+ wday == 5
+ end
+
+ def saturday?
+ wday == 6
+ end
+
+ def >>(months)
+ shift_months(months)
+ end
+
+ def <<(months)
+ shift_months(-months)
+ end
+
+ def shift_months(amount)
+ index = @year * 12 + @month - 1 + amount
+ year = index / 12
+ month = index % 12 + 1
+ year -= 1 if month == 0
+ month = 12 if month == 0
+ day = @day
+ last = Date.month_length(year, month)
+ day = last if day > last
+ Date.new(year, month, day)
+ end
+
+ def to_date
+ self
+ end
+
+ def to_time
+ Time.local(@year, @month, @day, 0, 0, 0)
+ end
+
+ def iso8601
+ format("%04d-%02d-%02d", @year, @month, @day)
+ end
+
+ def to_s
+ iso8601
+ end
+
+ def as_json(_options = {})
+ iso8601
+ end
+
+ def strftime(pattern)
+ result = ""
+ index = 0
+ while index < pattern.length
+ char = pattern[index, 1]
+ if char == "%"
+ index += 1
+ code = pattern[index, 1]
+ value = case code
+ when "%" then "%"
+ when "Y" then format("%04d", @year)
+ when "m" then format("%02d", @month)
+ when "d" then format("%02d", @day)
+ when "e" then format("%2d", @day)
+ when "F" then iso8601
+ when "j" then format("%03d", yday)
+ when "w" then wday.to_s
+ when "u" then (wday == 0 ? 7 : wday).to_s
+ when "a" then SHORT_WEEKDAYS[wday]
+ when "A" then WEEKDAYS[wday]
+ when "b", "h" then SHORT_MONTH_NAMES[@month - 1]
+ when "B" then MONTH_NAMES[@month - 1]
+ else raise Date::Error, "unsupported strftime directive: %#{code}"
+ end
+ result += value
+ else
+ result += char
+ end
+ index += 1
+ end
+ result
+ end
+
+ def <=>(other)
+ return nil unless other.is_a?(Date)
+ return -1 if @year < other.year
+ return 1 if @year > other.year
+ return -1 if @month < other.month
+ return 1 if @month > other.month
+ return -1 if @day < other.day
+ return 1 if @day > other.day
+ 0
+ end
+
+ def ==(other)
+ (self <=> other) == 0
+ end
+
+ def <(other)
+ (self <=> other) == -1
+ end
+
+ def <=(other)
+ (self <=> other) <= 0
+ end
+
+ def >(other)
+ (self <=> other) == 1
+ end
+
+ def >=(other)
+ (self <=> other) >= 0
+ end
+end
diff --git a/runtime/spinel/date.rbs b/runtime/spinel/date.rbs
new file mode 100644
index 000000000..6185d29a3
--- /dev/null
+++ b/runtime/spinel/date.rbs
@@ -0,0 +1,51 @@
+# Bounded Date value used by the Spinel runtime for Rails date columns.
+class Date
+ class Error < ArgumentError
+ end
+
+ def initialize: (Integer year, Integer month, Integer day) -> void
+ def self.civil: (Integer year, ?Integer month, ?Integer day) -> Date
+ def self.iso8601: (String value) -> Date
+ def self.parse: (String value, ?bool comp) -> Date
+ def self.strptime: (String value, String format) -> Date
+ def self.today: () -> Date
+ def self.leap_year?: (Integer year) -> bool
+ def self.month_length: (Integer year, Integer month) -> Integer
+
+ def year: () -> Integer
+ def month: () -> Integer
+ def mon: () -> Integer
+ def day: () -> Integer
+ def mday: () -> Integer
+ def leap?: () -> bool
+ def wday: () -> Integer
+ def yday: () -> Integer
+ def sunday?: () -> bool
+ def monday?: () -> bool
+ def tuesday?: () -> bool
+ def wednesday?: () -> bool
+ def thursday?: () -> bool
+ def friday?: () -> bool
+ def saturday?: () -> bool
+ def >>: (Integer months) -> Date
+ def <<: (Integer months) -> Date
+ def shift_months: (Integer amount) -> Date
+ def to_date: () -> Date
+ def to_time: () -> Time
+ def iso8601: () -> String
+ def to_s: () -> String
+ def as_json: (?Hash[untyped, untyped] options) -> String
+ def strftime: (String pattern) -> String
+ def <=>: (Date? other) -> Integer?
+ def ==: (Date? other) -> bool
+ def <: (Date? other) -> bool
+ def <=: (Date? other) -> bool
+ def >: (Date? other) -> bool
+ def >=: (Date? other) -> bool
+end
+
+module ActiveRecord
+ class Base
+ def as_json: (?Hash[Symbol, untyped] options) -> Hash[String, untyped]
+ end
+end
diff --git a/runtime/spinel/scaffold/boot.rb b/runtime/spinel/scaffold/boot.rb
index c20a6fdcf..49f9796a1 100644
--- a/runtime/spinel/scaffold/boot.rb
+++ b/runtime/spinel/scaffold/boot.rb
@@ -20,6 +20,9 @@
# Required before active_record so Base.rb's default `_adapter_*`
# helpers and per-model overrides find `Db` at constant-resolution
# time. See project_arel_compile_time_first.md.
+# Program-defined Date is not the unavailable Spinel stdlib package.
+# Load it before Db requires the ActiveSupport temporal seam.
+require_relative "runtime/date"
require_relative "runtime/db"
# Base64 + JSON + Importmap shims. All required before any framework
# Ruby file that references them so spinel-AOT's static resolver
@@ -97,6 +100,9 @@
Rails.application.config_time_zone, Rails.application.config_time_zone
)
require_relative "runtime/active_record"
+# Date-aware default ActiveRecord JSON path — the CRuby/JRuby overlay
+# has its own reflection-aware serializer, while Spinel uses schema facts.
+require_relative "runtime/active_record_date_serialization"
# Record equality (same class + same persisted id) — a reopen of
# ActiveRecord::Base; the CRuby overlay's twin is active_record_bang.rb.
require_relative "runtime/active_record_equality_spinel"
diff --git a/scripts/ci-plan.py b/scripts/ci-plan.py
index 18efa6c95..222c3943a 100755
--- a/scripts/ci-plan.py
+++ b/scripts/ci-plan.py
@@ -38,6 +38,7 @@
PUBLICATION = [*BASE, "compare", "browser-smoke-typescript"]
CORE = ["build-spinel", "toolchain-spinel", "compare-spinel"]
SPINEL_TESTS = [
+ "date_columns_spinel",
"framework_tests_spinel",
"spinel_web_push_crypto",
"spinel_db_lease",
@@ -121,6 +122,8 @@ def native_coverage(path):
owned_tests.update(("spinel_db_lease", "param_binds"))
if any(word in name for word in ("param", "multipart", "request")):
owned_tests.add("spinel_param_builder")
+ if name in {"date.rb", "date.rbs"}:
+ owned_tests.add("date_columns_spinel")
if (
path.startswith("runtime/spinel/")
and not path.startswith("runtime/spinel/scaffold/")
diff --git a/src/lower/model_to_library/schema.rs b/src/lower/model_to_library/schema.rs
index 9ecb204c6..7412ca77f 100644
--- a/src/lower/model_to_library/schema.rs
+++ b/src/lower/model_to_library/schema.rs
@@ -367,6 +367,43 @@ pub(super) fn push_schema_methods(
block_param: None,
});
+ // def self.schema_date_columns — date-only columns need their own
+ // JSON conversion: their stored value is ISO calendar text, never a
+ // timestamp. Kept separate from schema_time_columns so the runtime
+ // can preserve the two domains without inspecting values.
+ let date_column_array = with_ty(
+ Expr::new(
+ Span::synthetic(),
+ ExprNode::Array {
+ elements: table
+ .columns
+ .iter()
+ .filter(|c| matches!(c.col_type, crate::schema::ColumnType::Date))
+ .map(|c| lit_sym(c.name.clone()))
+ .collect(),
+ style: ArrayStyle::Brackets,
+ },
+ ),
+ Ty::Array { elem: Box::new(Ty::Sym) },
+ );
+ methods.push(MethodDef {
+ visibility: crate::dialect::MethodVisibility::Public,
+ unsupported_formals: None,
+ has_anonymous_block: false,
+ name_span: crate::span::Span::synthetic(),
+ name: Symbol::from("schema_date_columns"),
+ receiver: MethodReceiver::Class,
+ params: Vec::new(),
+ body: date_column_array,
+ signature: Some(fn_sig(vec![], Ty::Array { elem: Box::new(Ty::Sym) })),
+ effects: EffectSet::default(),
+ enclosing_class: Some(owner.0.clone()),
+ kind: AccessorKind::Method,
+ is_async: false,
+ mutates_self: false,
+ block_param: None,
+ });
+
// def self.instantiate(row); instance = from_row(Row.from_raw(row)); instance.mark_persisted!; instance; end
//
// The adapter shim returns Hash[Symbol, untyped]; the framework Ruby
diff --git a/src/project.rs b/src/project.rs
index d41952a2a..ab10aedee 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -726,7 +726,7 @@ fn report_unsupported_keys(app: &App, target: BuildTarget) {
/// dynamic backends may never render a type, so a type-position check
/// alone would silently emit a String/Time or call an absent intrinsic.
fn reject_unsupported_dates(app: &App, target: BuildTarget) -> Result<(), String> {
- if matches!(target, BuildTarget::Blog | BuildTarget::Ruby) {
+ if matches!(target, BuildTarget::Blog | BuildTarget::Ruby | BuildTarget::Spinel) {
return Ok(());
}
fn expr_has_date(e: &crate::expr::Expr) -> bool {
@@ -3766,6 +3766,14 @@ fn spinel_files(app: &App, fixture: &Path) -> Result<(Vec<(String, String)>, Vec
));
}
+ // Program-defined Date for the Spinel target; the flat walk picks
+ // up its implementation and this sidecar carries its typed surface.
+ {
+ let rbs = crate::runtime_files::read_to_string("runtime/spinel/date.rbs")
+ .map_err(|e| format!("read runtime/spinel/date.rbs: {e}"))?;
+ files.push(("sig/runtime/date.rbs".to_string(), rbs));
+ }
+
// Schema-less json/jsonb column seam. The flat walk emits the Ruby
// implementation; this sidecar preserves its gradual logical value
// while keeping the database slot String-typed.
diff --git a/tests/cli_allow_unsupported_hint.rs b/tests/cli_allow_unsupported_hint.rs
index 5fbc65468..a2c94d647 100644
--- a/tests/cli_allow_unsupported_hint.rs
+++ b/tests/cli_allow_unsupported_hint.rs
@@ -1,8 +1,7 @@
//! The transpile gate's "rerun with --allow-unsupported" hint at the
//! process boundary (#303). The flag downgrades diagnostics, but a
-//! project-boundary refusal (`target_files` returning `Err`, as the
-//! Date gate does for spinel) fails with or without it, so the hint
-//! must only be printed when the flag would actually write the output.
+//! project-boundary refusals fail with or without it, so the hint must
+//! only be printed when the flag would actually write the output.
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -62,19 +61,14 @@ fn transpile(target: &str, input: &Path, allow_unsupported: bool) -> (i32, Strin
}
#[test]
-fn a_date_boundary_refusal_does_not_suggest_allow_unsupported() {
+fn spinel_date_columns_pass_the_old_boundary_refusal() {
let root = app("date", "", " t.date \"published_on\"\n");
let (code, err) = transpile("spinel", &root, false);
- assert_eq!(code, 1, "{err}");
- assert!(err.contains("Date not supported (spinel)"), "{err}");
- assert!(err.contains("Date-only values are not supported"), "{err}");
- assert!(!err.contains(HINT), "the flag cannot override this refusal: {err}");
-
- // The reason the hint is wrong: the flag still fails the run.
- let (code, err) = transpile("spinel", &root, true);
- assert_eq!(code, 1, "{err}");
- assert!(!root.join("out").exists(), "{err}");
+ assert_eq!(code, 0, "{err}");
+ assert!(!err.contains("Date not supported (spinel)"), "{err}");
+ assert!(!err.contains(HINT), "{err}");
+ assert!(root.join("out").is_dir(), "{err}");
std::fs::remove_dir_all(root).expect("remove temporary app");
}
diff --git a/tests/date_columns.rs b/tests/date_columns.rs
index e5666363e..534bafb29 100644
--- a/tests/date_columns.rs
+++ b/tests/date_columns.rs
@@ -106,7 +106,6 @@ fn typo_and_cross_domain_operations_remain_errors() {
}
const UNSUPPORTED: &[BuildTarget] = &[
- BuildTarget::Spinel,
BuildTarget::Jruby,
BuildTarget::Roda,
BuildTarget::Crystal,
@@ -160,9 +159,37 @@ fn date_target_boundary_rejects_before_reading_or_emitting_files() {
assert!(model.1.contains("ActiveSupport.parse_db_date(@due_on_raw)"));
assert!(model.1.contains("ActiveSupport.format_db_date"));
assert!(!model.1.contains("present_db(@__t_due_on"));
+ assert!(model.1.contains("schema_date_columns"));
}
}
+#[test]
+fn spinel_emits_date_runtime_and_keeps_date_as_a_date() {
+ let mut app = app_with(SCHEMA, include_str!("date_columns_model.rb"));
+ assert!(errors(&mut app).is_empty());
+ let (result, diagnostics) =
+ scope(|| target_files(&app, roundhouse::fixtures::real_blog(), BuildTarget::Spinel));
+ assert!(result.is_ok(), "{diagnostics:?}");
+ let files = result.unwrap();
+ let runtime = files.iter().find(|(path, _)| path == "runtime/date.rb").unwrap();
+ assert!(runtime.1.contains("class Date"));
+ assert!(
+ files.iter().any(|(path, _)| path == "runtime/date.rbs"),
+ "signature files: {:?}",
+ files.iter().map(|(path, _)| path).filter(|path| path.ends_with(".rbs")).collect::>()
+ );
+ let model = files
+ .iter()
+ .find(|(path, _)| path == "app/models/calendar_entry.rb")
+ .unwrap();
+ assert!(model.1.contains("ActiveSupport.parse_db_date(@due_on_raw)"));
+ assert!(model.1.contains("ActiveSupport.format_db_date"));
+ assert!(!diagnostics.iter().any(|d| matches!(
+ &d.kind,
+ DiagnosticKind::Unsupported { construct, .. } if construct.as_str() == "Date"
+ )));
+}
+
#[test]
fn non_column_dates_are_also_rejected_on_unimplemented_targets() {
let mut app = app_with(
diff --git a/tests/date_columns_spinel.rs b/tests/date_columns_spinel.rs
new file mode 100644
index 000000000..dce050bb4
--- /dev/null
+++ b/tests/date_columns_spinel.rs
@@ -0,0 +1,73 @@
+#[path = "support/emit_and_run.rs"]
+mod emit_and_run;
+
+#[test]
+#[ignore = "requires the Spinel toolchain"]
+fn spinel_gate_date_column_crud_and_month_shifts_run() {
+ let overlay = emit_and_run::real_blog()
+ .edit(
+ "db/schema.rb",
+ " create_table \"articles\"",
+ " create_table \"calendar_entries\" do |t|\n t.date \"due_on\"\n end\n\n create_table \"articles\"",
+ )
+ .write("app/models/calendar_entry.rb", include_str!("date_columns_model.rb"));
+ let run = overlay.run_spinel(r#"
+Db.configure(":memory:")
+Schema.statements.each { |sql| Db.exec(sql) }
+ActiveRecord.adapter = SqliteAdapter
+
+entry = CalendarEntry.create!(due_on: Date.new(2024, 1, 31))
+entry.reload
+raise entry.due_on.inspect unless entry.due_on.iso8601 == "2024-01-31"
+raise entry.due_on_raw.inspect unless entry.due_on_raw == "2024-01-31"
+raise entry.shifted(1).iso8601 unless entry.shifted(1).iso8601 == "2024-02-29"
+raise entry.shifted(2).iso8601 unless entry.shifted(2).iso8601 == "2024-03-31"
+raise entry.shifted(-1).iso8601 unless entry.shifted(-1).iso8601 == "2023-12-31"
+raise entry.due_on.iso8601 unless entry.due_on.iso8601 == "2024-01-31"
+puts "before selected date JSON"
+raise entry.as_json(only: [:due_on]).inspect unless entry.as_json(only: [:due_on]) == {"due_on" => "2024-01-31"}
+puts "before default date JSON"
+raise entry.as_json.inspect unless entry.as_json["due_on"] == "2024-01-31"
+raise Date.new(2024, 1, 31).as_json.inspect unless Date.new(2024, 1, 31).as_json == "2024-01-31"
+raise Date.new(2024, 1, 31).wday.to_s unless Date.new(2024, 1, 31).wday == 3
+raise Date.new(2024, 1, 31).yday.to_s unless Date.new(2024, 1, 31).yday == 31
+raise Date.new(2024, 1, 31).monday?.to_s unless Date.new(2024, 1, 31).wednesday?
+raise Date.new(2024, 1, 31).year.to_s unless Date.new(2024, 1, 31).year == 2024
+raise Date.new(2024, 1, 31).month.to_s unless Date.new(2024, 1, 31).mon == 1
+raise Date.new(2024, 1, 31).day.to_s unless Date.new(2024, 1, 31).mday == 31
+raise Date.new(2024, 1, 31).strftime("%A %Y-%m-%d").inspect unless Date.new(2024, 1, 31).strftime("%A %Y-%m-%d") == "Wednesday 2024-01-31"
+raise (Date.new(2024, 1, 31) >> 2).iso8601 unless (Date.new(2024, 1, 31) >> 2).iso8601 == "2024-03-31"
+raise (Date.new(2024, 1, 31) << 1).iso8601 unless (Date.new(2024, 1, 31) << 1).iso8601 == "2023-12-31"
+raise Date.iso8601("2024-01-31").iso8601 unless Date.iso8601("2024-01-31").iso8601 == "2024-01-31"
+raise Date.civil(2024, 2, 29).iso8601 unless Date.civil(2024, 2, 29).iso8601 == "2024-02-29"
+raise Date.parse("2024-02-29", false).iso8601 unless Date.parse("2024-02-29", false).iso8601 == "2024-02-29"
+raise Date.strptime("2024-02-29", "%Y-%m-%d").iso8601 unless Date.strptime("2024-02-29", "%Y-%m-%d").iso8601 == "2024-02-29"
+raise "leap year" unless Date.new(2024, 2, 29).leap?
+raise "date ordering" unless Date.new(2024, 1, 31) < Date.new(2024, 2, 1)
+raise "date equality" unless Date.new(2024, 1, 31) == Date.civil(2024, 1, 31)
+raise Date.new(2024, 1, 31).to_date.iso8601 unless Date.new(2024, 1, 31).to_date.iso8601 == "2024-01-31"
+raise Date.new(2024, 1, 31).to_time.strftime("%Y-%m-%d %H:%M:%S") unless Date.new(2024, 1, 31).to_time.strftime("%Y-%m-%d %H:%M:%S") == "2024-01-31 00:00:00"
+begin
+ Date.iso8601("2023-02-29")
+ raise "invalid date was accepted"
+rescue Date::Error
+end
+entry.update!(due_on: nil)
+entry.reload
+raise entry.due_on.inspect unless entry.due_on.nil?
+raise entry.as_json(only: [:due_on]).inspect unless entry.as_json(only: [:due_on]) == {"due_on" => nil}
+raise ActiveSupport.format_db_date(nil).inspect unless ActiveSupport.format_db_date(nil).nil?
+raise ActiveSupport.parse_db_date("").inspect unless ActiveSupport.parse_db_date("").nil?
+raise ActiveSupport.parse_db_date(nil).inspect unless ActiveSupport.parse_db_date(nil).nil?
+raise ActiveSupport.format_db_date(Date.new(2024, 2, 29)).inspect unless ActiveSupport.format_db_date(Date.new(2024, 2, 29)) == "2024-02-29"
+raise ActiveSupport.format_db_date(nil).inspect unless ActiveSupport.format_db_date(nil).nil?
+begin
+ ActiveSupport.format_db_date(42)
+ raise "invalid DB date value was accepted"
+rescue TypeError
+end
+puts "Spinel Date column contract passed"
+"#);
+ run.assert_passes();
+ assert!(run.stdout.contains("Spinel Date column contract passed"));
+}
diff --git a/tests/inference_on_spinel_blog_runtime_with_rbs.rs b/tests/inference_on_spinel_blog_runtime_with_rbs.rs
index 4fb8872fd..b69ff82c7 100644
--- a/tests/inference_on_spinel_blog_runtime_with_rbs.rs
+++ b/tests/inference_on_spinel_blog_runtime_with_rbs.rs
@@ -935,7 +935,12 @@ fn untyped_subexpressions_with_rbs_baseline() {
// runtime_src_integration counts ONE site (`Time.now + expires_in`).
// What it buys: the Rails 8 authentication generator's password
// reset flow, in Rails' own token format.
- const CEILING: usize = 1209;
+ // 2026-10-04: 1209 -> 1216, +7 for the date-column branch in
+ // `_as_json_only` (schema_date_columns and format_db_date). It
+ // preserves the Date JSON form on the Spinel tree while leaving the
+ // DateTime path on json_time; all seven new residuals are in that
+ // shared runtime method's gradual storage boundary.
+ const CEILING: usize = 1216;
assert!(
all_untyped.len() <= CEILING,
diff --git a/tests/model_lowerer.rs b/tests/model_lowerer.rs
index 01cd1955a..b4d25ee59 100644
--- a/tests/model_lowerer.rs
+++ b/tests/model_lowerer.rs
@@ -133,13 +133,14 @@ fn article_lowers_with_schema_methods() {
);
// The non-attr scaffold: table_name, schema_columns,
- // schema_time_columns, instantiate, initialize, attributes, [], []=,
- // update.
+ // schema_time_columns, schema_date_columns, instantiate, initialize,
+ // attributes, [], []=, update.
for expected in [
"table_name",
"_table_sql",
"schema_columns",
"schema_time_columns",
+ "schema_date_columns",
"instantiate",
"initialize",
"attributes",
@@ -161,6 +162,7 @@ fn article_lowers_with_schema_methods() {
"_table_sql",
"schema_columns",
"schema_time_columns",
+ "schema_date_columns",
"instantiate",
"from_row",
"from_stmt",
diff --git a/tests/runtime_src_integration.rs b/tests/runtime_src_integration.rs
index 2393be465..15c7c230a 100644
--- a/tests/runtime_src_integration.rs
+++ b/tests/runtime_src_integration.rs
@@ -1539,7 +1539,9 @@ fn every_runtime_method_body_concretely_typed() {
// `iso8601_ms`'s `::Time` parameter. What it bought:
// `has_secure_password`'s password-reset token, which the Rails 8
// authentication generator's PasswordsController and mailer use.
- const CEILING: usize = 521;
+ // 521 -> 522: Date JSON conversion in ActiveRecord::Base#_as_json_only
+ // adds one dynamic seam read through ActiveSupport.format_db_date.
+ const CEILING: usize = 522;
assert!(
total_gradual <= CEILING,
"{total_gradual} Ty::Untyped sites exceeds ceiling of {CEILING}",