Module: Synthra::TimeTravel

Defined in:
lib/synthra/time_travel.rb

Overview

Time Travel for Data Generation

Generate data as it would have existed at a specific point in time. Perfect for testing time-sensitive features, historical data analysis, and creating realistic time-series datasets.

Examples:

Generate data from the past

Synthra::TimeTravel.at(3.months.ago) do
  user = schema.generate
  user["created_at"] # => 3 months ago
end

Generate data within a range

Synthra::TimeTravel.between(1.year.ago, 1.month.ago) do
  orders = schema.generate_many(100)
end

In DSL

HistoricalOrder:
  @time_travel -30.days..-1.day
  created_at: timestamp

Defined Under Namespace

Classes: TimeContext

Class Method Summary collapse

Class Method Details

.age(record, by:) ⇒ Hash

Generate data that ages over time

Examples:

Age a user by 1 year

user = Synthra[:User]
aged_user = Synthra::TimeTravel.age(user, by: 1.year)

Parameters:

  • schema (Schema, String)

    schema to generate

  • record (Hash)

    existing record to age

  • by (Duration)

    how much to age

Returns:

  • (Hash)

    aged record



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/synthra/time_travel.rb', line 154

def age(record, by:)
  aged = record.dup
  offset = duration_to_seconds(by)

  record.each do |key, value|
    if value.is_a?(Time) || value.is_a?(DateTime)
      aged[key] = value - offset
    elsif value.is_a?(String) && time_string?(value)
      parsed = Time.parse(value)
      aged[key] = (parsed - offset).iso8601
    end
  end

  aged
end

.anchor_dateDate?

The active time-travel anchor, as a Date, for generators that need to re-base a "relative to today" distribution (e.g. "N days in the past").

Returns nil when no time-travel context is active, so callers can use it as a cheap presence check: anchor = TimeTravel.anchor_date; return ... if anchor.

Returns:

  • (Date, nil)


44
45
46
47
48
49
# File 'lib/synthra/time_travel.rb', line 44

def anchor_date
  context = current_time
  return nil unless context

  context.current_time.to_date
end

.at(time) { ... } ⇒ Object

Generate data at a specific point in time

Examples:

Synthra::TimeTravel.at(2.weeks.ago) do
  user = Synthra[:User]
  user["created_at"] # => ~2 weeks ago
end

Parameters:

  • time (Time, DateTime, Date)

    the target time

Yields:

  • block where all generated timestamps will be relative to this time

Returns:

  • (Object)

    result of the block



63
64
65
66
# File 'lib/synthra/time_travel.rb', line 63

def at(time, &block)
  time = normalize_time(time)
  with_time_context(TimeContext.new(time), &block)
end

.between(start_time, end_time) { ... } ⇒ Object

Generate data within a time range

Examples:

Synthra::TimeTravel.between(1.year.ago, Time.now) do
  orders = Synthra.generate_many("Order", 1000)
  # created_at distributed over the year
end

Parameters:

  • start_time (Time, DateTime, Date)

    range start

  • end_time (Time, DateTime, Date)

    range end

Yields:

  • block where timestamps will be randomly distributed in range

Returns:

  • (Object)

    result of the block



81
82
83
84
85
# File 'lib/synthra/time_travel.rb', line 81

def between(start_time, end_time, &block)
  start_time = normalize_time(start_time)
  end_time = normalize_time(end_time)
  with_time_context(TimeContext.new(start_time, end_time), &block)
end

.current_timeObject

Thread-local time context



29
30
31
# File 'lib/synthra/time_travel.rb', line 29

def current_time
  Thread.current[:synthra_time_travel]
end

.current_time=(time) ⇒ Object



33
34
35
# File 'lib/synthra/time_travel.rb', line 33

def current_time=(time)
  Thread.current[:synthra_time_travel] = time
end

.duration_to_seconds(duration) ⇒ Object (private)



270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/synthra/time_travel.rb', line 270

def duration_to_seconds(duration)
  return duration if duration.is_a?(Numeric)

  # Parse a "30.days"-style duration BEFORE any to_i fallback. A String responds to #to_i, so
  # "30.days".to_i (=> 30) used to shadow this multiplier table — aging by 30 *seconds*
  # instead of 30 days. Match on #to_s so a custom duration object works too.
  if (match = duration.to_s.match(/(\d+)\.(year|month|week|day|hour|minute|second)s?/))
    num, unit = match.captures
    multipliers = {
      "year" => 365.25 * 24 * 3600,
      "month" => 30 * 24 * 3600,
      "week" => 7 * 24 * 3600,
      "day" => 24 * 3600,
      "hour" => 3600,
      "minute" => 60,
      "second" => 1
    }
    return num.to_i * multipliers[unit]
  end

  return duration.to_i if duration.respond_to?(:to_i)

  duration.to_f
end

.find_timestamp_field(schema) ⇒ Object (private)



263
264
265
266
267
268
# File 'lib/synthra/time_travel.rb', line 263

def find_timestamp_field(schema)
  %w[created_at timestamp date created].each do |name|
    return name if schema.fields.any? { |f| f.name == name }
  end
  nil
end

.normalize_time(time) ⇒ Object (private)



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
# File 'lib/synthra/time_travel.rb', line 240

def normalize_time(time)
  case time
  when Time
    time
  when DateTime
    time.to_time
  when Date
    time.to_time
  when Numeric
    Time.at(time)
  when Range
    # Return as-is for range support
    time
  else
    Time.parse(time.to_s)
  end
end

.progression(schema, steps:, interval:, seed_record: nil, changes: {}) {|record, step, time| ... } ⇒ Array<Hash>

Create a progression of records over time

Examples:

User subscription progression

progression = Synthra::TimeTravel.progression(
  "User",
  steps: 12,
  interval: 1.month,
  changes: {
    subscription_status: -> (step) { step < 3 ? "trial" : "active" },
    login_count: -> (step) { step * 5 }
  }
)

Parameters:

  • schema (Schema, String)

    schema to generate

  • seed_record (Hash) (defaults to: nil)

    starting record

  • steps (Integer)

    number of progression steps

  • interval (Duration)

    time between steps

  • changes (Hash) (defaults to: {})

    fields that change over time

Yields:

  • (record, step, time)

    customize each step

Returns:

  • (Array<Hash>)

    progression of records



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/synthra/time_travel.rb', line 191

def progression(schema, steps:, interval:, seed_record: nil, changes: {}, &block)
  schema = Synthra.registry.schema(schema.to_s) if schema.is_a?(String) || schema.is_a?(Symbol)
  
  interval_seconds = duration_to_seconds(interval)
  base_time = current_time || Time.now
  records = []

  seed_record ||= schema.generate

  steps.times do |step|
    step_time = base_time + (interval_seconds * step)

    record = at(step_time) do
      step_record = seed_record.dup

      # Apply time-based changes
      changes.each do |field, change_fn|
        step_record[field.to_s] = change_fn.call(step)
      end

      # Update timestamp fields
      schema.fields.each do |field|
        if timestamp_type?(field.type_name)
          step_record[field.name] = step_time.iso8601
        end
      end

      # Allow custom modifications
      block&.call(step_record, step, step_time)

      step_record
    end

    records << record
  end

  records
end

.random_time_in_range(start_time, end_time) ⇒ Object (private)



258
259
260
261
# File 'lib/synthra/time_travel.rb', line 258

def random_time_in_range(start_time, end_time)
  range = end_time.to_f - start_time.to_f
  Time.at(start_time.to_f + rand * range)
end

.time_series(schema, count:, start_time:, end_time: Time.now, distribution: :uniform) ⇒ Array<Hash>

Generate historical time series data

Examples:

Generate a year of orders

orders = Synthra::TimeTravel.time_series(
  "Order",
  count: 1000,
  start_time: 1.year.ago,
  distribution: :exponential  # More recent = more orders
)

Parameters:

  • schema (Schema, String)

    schema to generate

  • count (Integer)

    number of records

  • start_time (Time)

    series start

  • end_time (Time) (defaults to: Time.now)

    series end (default: now)

  • distribution (Symbol) (defaults to: :uniform)

    :uniform, :linear, :exponential

Returns:

  • (Array<Hash>)

    generated records sorted by time



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/synthra/time_travel.rb', line 104

def time_series(schema, count:, start_time:, end_time: Time.now, distribution: :uniform)
  schema = Synthra.registry.schema(schema.to_s) if schema.is_a?(String) || schema.is_a?(Symbol)
  
  start_time = normalize_time(start_time)
  end_time = normalize_time(end_time)
  time_range = end_time - start_time

  records = count.times.map do |i|
    # Calculate time based on distribution
    t = case distribution
        when :uniform
          random_time_in_range(start_time, end_time)
        when :linear
          # Linear growth - evenly distributed
          start_time + (time_range * i / count.to_f)
        when :exponential
          # Exponential - more recent records
          ratio = (i.to_f / count) ** 2
          start_time + (time_range * ratio)
        when :logarithmic
          # Logarithmic - more historical records
          ratio = Math.log(i + 1) / Math.log(count + 1)
          start_time + (time_range * ratio)
        else
          random_time_in_range(start_time, end_time)
        end

    at(t) { schema.generate }
  end

  # Sort by timestamp fields
  timestamp_field = find_timestamp_field(schema)
  if timestamp_field
    records.sort_by { |r| r[timestamp_field] || Time.now }
  else
    records
  end
end

.time_string?(value) ⇒ Boolean (private)

Returns:

  • (Boolean)


295
296
297
298
299
300
301
302
303
304
# File 'lib/synthra/time_travel.rb', line 295

def time_string?(value)
  return false unless value.is_a?(String)
  # Be strict about what constitutes a time string
  # Must contain date-like patterns (YYYY-MM-DD, DD/MM/YYYY, etc.)
  return false unless value.match?(/\d{4}-\d{2}-\d{2}|\d{2}[\/\-]\d{2}[\/\-]\d{2,4}|T\d{2}:\d{2}/)
  Time.parse(value)
  true
rescue ArgumentError, TypeError
  false
end

.timestamp_type?(type_name) ⇒ Boolean (private)

Returns:

  • (Boolean)


306
307
308
# File 'lib/synthra/time_travel.rb', line 306

def timestamp_type?(type_name)
  %w[timestamp datetime now date time].include?(type_name)
end

.with_time_context(context) ⇒ Object (private)



232
233
234
235
236
237
238
# File 'lib/synthra/time_travel.rb', line 232

def with_time_context(context)
  old_context = current_time
  self.current_time = context
  yield
ensure
  self.current_time = old_context
end