jade-sql 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ module JadeSql
2
+ module Compiler
3
+ module Assignable
4
+ extend self
5
+ include Helpers
6
+
7
+ INTERFACE = 'Sql.Assignable'
8
+ ASSIGNMENT = 'Sql.Assignment'
9
+ ASSIGNMENT_FIELDS = %w[col value_sql params].freeze
10
+
11
+ def supports?(interface) = interface == INTERFACE
12
+
13
+ def derive(constraint, registry, entry_name, &lookup)
14
+ return failed(constraint, entry_name) unless assignment_matches?(registry)
15
+
16
+ case constraint.type
17
+ in Type::Application(constructor: Type::Constructor(name:), args:)
18
+ Symbol
19
+ .type_ref_from_qualified_name(name)
20
+ .then { registry.lookup(it) }
21
+ .then { derive_for(constraint, it, args, registry, lookup, entry_name) }
22
+
23
+ else
24
+ failed(constraint, entry_name)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ # The interface is matched by name, so some other module called `Sql`
31
+ # would otherwise be derived against as though it were jade-sql.
32
+ def assignment_matches?(registry)
33
+ Symbol
34
+ .type_ref_from_qualified_name(ASSIGNMENT)
35
+ .then { registry.lookup(it) }
36
+ .then do
37
+ it in Symbol::Struct(record_type: { fields: }) and
38
+ fields.keys.map(&:to_s) == ASSIGNMENT_FIELDS
39
+ end
40
+ end
41
+
42
+ def derive_for(constraint, symbol, args, registry, lookup, entry_name)
43
+ case symbol
44
+ in Symbol::Union if args.empty? && single_payload?(symbol, registry)
45
+ derive_union(constraint, symbol, registry, lookup, entry_name)
46
+
47
+ in Symbol::Struct
48
+ derive_struct(constraint, symbol, args, registry, lookup, entry_name)
49
+
50
+ else
51
+ failed(constraint, entry_name)
52
+ end
53
+ end
54
+
55
+ # One column per variant, so each variant carries exactly the value
56
+ # that column is set to.
57
+ def single_payload?(union_sym, registry)
58
+ variants(union_sym, registry)
59
+ .then { it.any? && it.all? { it.args.size == 1 } }
60
+ end
61
+
62
+ def derive_union(constraint, union_sym, registry, lookup, entry_name)
63
+ vs = variants(union_sym, registry)
64
+
65
+ vs
66
+ .map { encodable_dep(it, registry) }
67
+ .map { lookup.call(it) }
68
+ .then { Results.sequence(it) }
69
+ .map { assignable(constraint, union_body(vs), it) }
70
+ end
71
+
72
+ def derive_struct(constraint, struct_sym, args, registry, lookup, entry_name)
73
+ fields = struct_fields(struct_sym, args, registry)
74
+
75
+ fields
76
+ .map { |_, type| Type.constraint('Encode.Encodable', type, nil) }
77
+ .map { lookup.call(it) }
78
+ .then { Results.sequence(it) }
79
+ .map { assignable(constraint, struct_body(fields), it) }
80
+ end
81
+
82
+ def struct_body(fields)
83
+ fields
84
+ .each_with_index
85
+ .map { |(name, _), idx| field_assignment(name, idx) }
86
+ .then { [:list, it] }
87
+ end
88
+
89
+ def field_assignment(name, idx)
90
+ [:call,
91
+ [:struct_constructor, ASSIGNMENT, 3],
92
+ [
93
+ Compiler.column_name(name),
94
+ '?',
95
+ [:list,
96
+ [[:call, [:impl_arg, idx, 'encoder'], [[:access, [:var, 'f'], name.to_s]]]],
97
+ ],
98
+ ],
99
+ ]
100
+ end
101
+
102
+ def encodable_dep(variant, registry)
103
+ variant
104
+ .args
105
+ .first
106
+ .then { instantiate(it, {}, registry) }
107
+ .then { Type.constraint('Encode.Encodable', it, nil) }
108
+ end
109
+
110
+ def union_body(variants)
111
+ variants
112
+ .each_with_index
113
+ .map { |v, idx| [[:constructor, v.qualified_name, ['x']], [assignment(v, idx)]] }
114
+ .then { [:case, [:var, 'f'], it] }
115
+ end
116
+
117
+ def assignment(variant, idx)
118
+ [:call,
119
+ [:struct_constructor, ASSIGNMENT, 3],
120
+ [
121
+ wire_name(variant),
122
+ '?',
123
+ [:list, [[:call, [:impl_arg, idx, 'encoder'], [[:var, 'x']]]]],
124
+ ],
125
+ ]
126
+ .then { [:list, [it]] }
127
+ end
128
+
129
+ def failed(constraint, entry_name)
130
+ Err[
131
+ DerivationFailed.new(
132
+ entry_name, constraint.origin&.range, constraint:, trace: [],
133
+ )
134
+ ]
135
+ end
136
+
137
+ def assignable(constraint, body, deps)
138
+ implementation(
139
+ constraint,
140
+ { 'to_assigns' => Symbol::DerivedFunction.new(params: ['f'], body:) },
141
+ deps:,
142
+ )
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,220 @@
1
+ module JadeSql
2
+ module Compiler
3
+ # A struct's fields map onto a table's columns by name, and the mapping
4
+ # derives, so nothing else ever compares the two — a field the table has
5
+ # no column for reaches Postgres as invalid SQL. Both types are concrete
6
+ # at the call site, which is why this is a check and not a constraint.
7
+ module Columns
8
+ extend self
9
+ include Helpers
10
+
11
+ TABLE = 'Sql.Table'
12
+ EXPR = 'Sql.Expr'
13
+ LIST = 'List.List'
14
+ TUPLE2 = 'Tuple.Tuple2'
15
+ ASSIGNABLE = 'Sql.Assignable'
16
+ STAMPED = 'Sql.Write.Stamped'
17
+ STAMPS = %w[created_at updated_at].freeze
18
+
19
+ # Where the written value is, where the table is, what the value arrives
20
+ # wrapped in, and whether the row is being created — only then does a
21
+ # column the database cannot fill have to be written.
22
+ #
23
+ # Every write taking a struct of the caller's is here. `update_all` and
24
+ # `delete_all` are not, because they build their SET and WHERE from the
25
+ # table's own `c` and `s` — there is no struct to compare.
26
+ Target = Data.define(:value_at, :table_at, :wrapper, :creates)
27
+
28
+ TARGETS = {
29
+ 'Sql.Write.insert' => Target[0, 1, :bare, true],
30
+ 'Sql.Write.insert_all' => Target[0, 1, :list, true],
31
+ 'Sql.Write.update' => Target[0, 1, :bare, false],
32
+ 'Sql.Write.update_many' => Target[0, 1, :keyed_list, false],
33
+ }.freeze
34
+
35
+ def watches = TARGETS.keys
36
+
37
+ def check(ctx)
38
+ TARGETS.fetch(ctx.name).then do |target|
39
+ table = ctx.arg_types[target.table_at]
40
+ value, stamps = unstamp(unwrap(ctx.arg_types[target.value_at], target.wrapper))
41
+
42
+ next [] if written?(value, ctx.registry)
43
+
44
+ compare(value, cols_of(table), ctx) +
45
+ (target.creates ? missing(value, stamps, table, ctx) : [])
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # A field maps to the column of the same name only because the deriver
52
+ # says so. An author who wrote `to_assigns` by hand has already said
53
+ # what the columns are — one field may become two, or none — so there
54
+ # is nothing here to compare against.
55
+ def written?(value, registry)
56
+ case implementation(value, registry)
57
+ in Symbol::Implementation(module_name: String) then true
58
+ else false
59
+ end
60
+ end
61
+
62
+ def implementation(value, registry)
63
+ case value
64
+ in Type::Application(constructor: Type::Constructor(name:))
65
+ registry.implementations[[ASSIGNABLE, name]]
66
+
67
+ else nil
68
+ end
69
+ end
70
+
71
+ # `r` names the columns the database will not fill in, so a value that
72
+ # writes none of them produces a row Postgres rejects. `timestamped` writes
73
+ # two of them without them being fields, which is why it is carried
74
+ # alongside the value's own.
75
+ def missing(value, stamps, table, ctx)
76
+ case [fields_of(value, ctx.registry), columns_of(required_of(table), ctx.registry)]
77
+ in [Array => fields, Hash => required]
78
+ required.keys - fields.map { Compiler.column_name(it.first) } - stamps
79
+
80
+ else []
81
+ end
82
+ .then do |absent|
83
+ next [] if absent.empty?
84
+
85
+ [Errors::MissingColumns.new(
86
+ ctx.entry_name, ctx.span,
87
+ struct: name_of(value), table: name_of(cols_of(table)), missing: absent,
88
+ )]
89
+ end
90
+ end
91
+
92
+ # `Table(c, m, k, o, r, s)` — the required columns are its fifth
93
+ # argument. Matched by position rather than "the last one", so appending
94
+ # a parameter cannot silently point this at something else.
95
+ def required_of(table)
96
+ case table
97
+ in Type::Application(
98
+ constructor: Type::Constructor(name: TABLE), args: [_, _, _, _, required, _]
99
+ )
100
+ required
101
+
102
+ else nil
103
+ end
104
+ end
105
+
106
+ # `timestamped` writes `created_at` and `updated_at` on a value that has no
107
+ # such fields, so it answers for them without appearing among them.
108
+ def unstamp(value)
109
+ case value
110
+ in Type::Application(constructor: Type::Constructor(name: STAMPED), args: [inner])
111
+ [inner, STAMPS]
112
+
113
+ else [value, []]
114
+ end
115
+ end
116
+
117
+ # Either side may be absent — an argument the caller left off, a table
118
+ # still polymorphic in its columns — and then there is nothing to compare.
119
+ def compare(value, cols, ctx)
120
+ case [fields_of(value, ctx.registry), columns_of(cols, ctx.registry)]
121
+ in [Array => fields, Hash => columns]
122
+ fields.filter_map { mismatch(it, columns, value, cols, ctx) }
123
+
124
+ else []
125
+ end
126
+ end
127
+
128
+ def mismatch((name, type), columns, value, cols, ctx)
129
+ column = Compiler.column_name(name)
130
+
131
+ case columns[column]
132
+ in nil
133
+ Errors::UnknownColumn.new(
134
+ ctx.entry_name, ctx.span,
135
+ struct: name_of(value), field: name, table: name_of(cols),
136
+ columns: columns.keys,
137
+ )
138
+
139
+ in ^type
140
+ nil
141
+
142
+ in found
143
+ Errors::ColumnTypeMismatch.new(
144
+ ctx.entry_name, ctx.span,
145
+ struct: name_of(value), field: name, table: name_of(cols),
146
+ column:, expected: found, actual: type,
147
+ )
148
+ end
149
+ end
150
+
151
+ # `Table(c, m, k, ...)` — the columns are its first argument.
152
+ def cols_of(table)
153
+ case table
154
+ in Type::Application(constructor: Type::Constructor(name: TABLE), args: [cols, *])
155
+ cols
156
+
157
+ else nil
158
+ end
159
+ end
160
+
161
+ # Each column is an `Expr(t)` whose `t` is what the field has to be.
162
+ def columns_of(cols, registry)
163
+ fields_of(cols, registry)
164
+ &.to_h { |field, type| [Compiler.column_name(field), unwrap_expr(type)] }
165
+ end
166
+
167
+ def fields_of(type, registry)
168
+ case type
169
+ in Type::Application(constructor: Type::Constructor(name:), args:)
170
+ Symbol
171
+ .type_ref_from_qualified_name(name)
172
+ .then { registry.lookup(it) }
173
+ .then { (it in Symbol::Struct) ? struct_fields(it, args, registry) : nil }
174
+
175
+ else nil
176
+ end
177
+ end
178
+
179
+ def unwrap(type, wrapper)
180
+ case [wrapper, type]
181
+ in [:list, Type::Application(constructor: Type::Constructor(name: LIST), args: [inner])]
182
+ inner
183
+
184
+ # `update_many` takes the key alongside the value, so the struct is the
185
+ # second half of each pair rather than the element itself.
186
+ in [
187
+ :keyed_list,
188
+ Type::Application(
189
+ constructor: Type::Constructor(name: LIST),
190
+ args: [Type::Application(
191
+ constructor: Type::Constructor(name: TUPLE2), args: [_, inner]
192
+ )],
193
+ )
194
+ ]
195
+ inner
196
+
197
+ else type
198
+ end
199
+ end
200
+
201
+ def unwrap_expr(type)
202
+ case type
203
+ in Type::Application(constructor: Type::Constructor(name: EXPR), args: [inner])
204
+ inner
205
+
206
+ else type
207
+ end
208
+ end
209
+
210
+ def name_of(type)
211
+ case type
212
+ in Type::Application(constructor: Type::Constructor(name:), args: _)
213
+ name.split('.').last
214
+
215
+ else type.to_s
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,82 @@
1
+ module JadeSql
2
+ module Compiler
3
+ module Errors
4
+ class UnknownColumn < Jade::Error
5
+ def initialize(entry, span, struct:, field:, table:, columns:)
6
+ @struct = struct
7
+ @field = field
8
+ @table = table
9
+ @columns = columns
10
+ super(entry:, span:)
11
+ end
12
+
13
+ def message
14
+ "#{@struct}.#{@field} has no column on #{@table}"
15
+ end
16
+
17
+ def label
18
+ "no column `#{@field}`"
19
+ end
20
+
21
+ def queried_name = @field.to_s
22
+
23
+ def candidates = @columns
24
+ end
25
+
26
+ class MissingColumns < Jade::Error
27
+ STAMPS = %w[created_at updated_at].freeze
28
+
29
+ def initialize(entry, span, struct:, table:, missing:)
30
+ @struct = struct
31
+ @table = table
32
+ @missing = missing
33
+ super(entry:, span:)
34
+ end
35
+
36
+ def message
37
+ "#{@struct} does not write #{list(@missing)}, " \
38
+ "which #{@table} requires"
39
+ end
40
+
41
+ def label
42
+ "missing #{list(@missing)}"
43
+ end
44
+
45
+ def notes
46
+ return [] unless (@missing - STAMPS).empty?
47
+
48
+ [Jade::Diagnostics::Annotation[:help, 'pipe the value through `timestamped`']]
49
+ end
50
+
51
+ private
52
+
53
+ def list(names)
54
+ names.map { "`#{it}`" }.then do |quoted|
55
+ quoted.length == 1 ? quoted.first : "#{quoted[..-2].join(', ')} and #{quoted.last}"
56
+ end
57
+ end
58
+ end
59
+
60
+ class ColumnTypeMismatch < Jade::Error
61
+ def initialize(entry, span, struct:, field:, table:, column:, expected:, actual:)
62
+ @struct = struct
63
+ @field = field
64
+ @table = table
65
+ @column = column
66
+ @expected = expected
67
+ @actual = actual
68
+ super(entry:, span:)
69
+ end
70
+
71
+ def message
72
+ "#{@table}.#{@column} is #{@expected}, " \
73
+ "but #{@struct}.#{@field} is #{@actual}"
74
+ end
75
+
76
+ def label
77
+ "column is #{@expected}, field is #{@actual}"
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,76 @@
1
+ module JadeSql
2
+ module Compiler
3
+ # `Selectable(a)` is the read side of `Assignable(a)`: the fields of the
4
+ # shape you asked for become the columns selected, so a straightforward
5
+ # read needs no `select` at all.
6
+ #
7
+ # Only the names are derived. The values decode through the port's own
8
+ # `Decodable(a)`, which already reads a row by field name.
9
+ module Selectable
10
+ extend self
11
+ include Helpers
12
+
13
+ INTERFACE = 'Sql.Selectable'
14
+ SELECTOR = 'Sql.Selector'
15
+
16
+ def supports?(interface) = interface == INTERFACE
17
+
18
+ def derive(constraint, registry, entry_name, &_lookup)
19
+ case constraint.type
20
+ in Type::AnonymousRecord(fields:)
21
+ Ok[selectable(constraint, fields.keys)]
22
+
23
+ in Type::Application(constructor: Type::Constructor(name:), args:)
24
+ Symbol
25
+ .type_ref_from_qualified_name(name)
26
+ .then { registry.lookup(it) }
27
+ .then { derive_named(constraint, it, args, registry, entry_name) }
28
+
29
+ else
30
+ failed(constraint, entry_name)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def derive_named(constraint, symbol, args, registry, entry_name)
37
+ case symbol
38
+ in Symbol::Struct
39
+ struct_fields(symbol, args, registry)
40
+ .map(&:first)
41
+ .then { Ok[selectable(constraint, it)] }
42
+
43
+ else
44
+ failed(constraint, entry_name)
45
+ end
46
+ end
47
+
48
+ def selectable(constraint, names)
49
+ implementation(
50
+ constraint,
51
+ { 'selector' => Symbol::DerivedFunction.new(params: [], body: body(names)) },
52
+ )
53
+ end
54
+
55
+ # `Selector(columns, params)`: no params, because a column list carries
56
+ # no values to bind.
57
+ def body(names)
58
+ [:call,
59
+ [:struct_constructor, SELECTOR, 2],
60
+ [
61
+ [:list, names.map { Compiler.column_name(it) }],
62
+ [:list, []],
63
+ ],
64
+ ]
65
+ end
66
+
67
+ def failed(constraint, entry_name)
68
+ Err[
69
+ DerivationFailed.new(
70
+ entry_name, constraint.origin&.range, constraint:, trace: [],
71
+ )
72
+ ]
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,34 @@
1
+ require 'jade'
2
+
3
+ module JadeSql
4
+ # jade-sql's half of the compiler: an interface it derives and a check it
5
+ # runs, both registered through `Jade::Extensions`.
6
+ module Compiler
7
+ Type = Jade::Type
8
+ Symbol = Jade::Symbol
9
+ Results = Jade::Results
10
+ Ok = Jade::Ok
11
+ Err = Jade::Err
12
+ Lexer = Jade::Lexer
13
+ Helpers = Jade::Frontend::TypeChecking::Constraints::Deriving::Helpers
14
+ DerivationFailed = Jade::Frontend::TypeChecking::Error::DerivationFailed
15
+
16
+ # The generator renames a column that collides with a jade keyword, so
17
+ # `type_` maps back to the `type` it came from.
18
+ def self.column_name(field)
19
+ field
20
+ .to_s
21
+ .then { it.end_with?('_') ? it.delete_suffix('_') : it }
22
+ .then { Lexer::KEYWORDS.include?(it) ? it : field.to_s }
23
+ end
24
+ end
25
+ end
26
+
27
+ require_relative 'compiler/errors'
28
+ require_relative 'compiler/assignable'
29
+ require_relative 'compiler/columns'
30
+ require_relative 'compiler/selectable'
31
+
32
+ Jade::Extensions.register_deriver('jade-sql', JadeSql::Compiler::Assignable)
33
+ Jade::Extensions.register_deriver('jade-sql', JadeSql::Compiler::Selectable)
34
+ Jade::Extensions.register_check('jade-sql', :call, JadeSql::Compiler::Columns)
@@ -13,7 +13,7 @@ module JadeSql
13
13
  conn = ::ActiveRecord::Base.connection
14
14
  t.ok(conn.exec_update(adapt_sql(fill_now(sql), conn), "Jade", typed_params(params, conn)))
15
15
  rescue ::ActiveRecord::RecordNotUnique => e
16
- t.err(JadeSql::SqlErrors.conflict(constraint_name(e)))
16
+ t.err(JadeSql::SqlErrors.unique_violation(constraint_name(e)))
17
17
  rescue ::ActiveRecord::StatementInvalid => e
18
18
  t.err(JadeSql::SqlErrors.db_error(e.message))
19
19
  end
@@ -24,10 +24,10 @@ module JadeSql
24
24
  case rows.length
25
25
  when 0 then t.err(JadeSql::SqlErrors.not_found)
26
26
  when 1 then t.ok(coerce_row(rows.first))
27
- else t.err(JadeSql::SqlErrors.not_unique)
27
+ else t.err(JadeSql::SqlErrors.too_many_rows)
28
28
  end
29
29
  rescue ::ActiveRecord::RecordNotUnique => e
30
- t.err(JadeSql::SqlErrors.conflict(constraint_name(e)))
30
+ t.err(JadeSql::SqlErrors.unique_violation(constraint_name(e)))
31
31
  rescue ::ActiveRecord::StatementInvalid => e
32
32
  t.err(JadeSql::SqlErrors.db_error(e.message))
33
33
  end
@@ -37,7 +37,7 @@ module JadeSql
37
37
  rows = conn.exec_query(adapt_sql(fill_now(sql), conn), "Jade", typed_params(params, conn)).to_a
38
38
  t.ok(rows.map { |row| coerce_row(row) })
39
39
  rescue ::ActiveRecord::RecordNotUnique => e
40
- t.err(JadeSql::SqlErrors.conflict(constraint_name(e)))
40
+ t.err(JadeSql::SqlErrors.unique_violation(constraint_name(e)))
41
41
  rescue ::ActiveRecord::StatementInvalid => e
42
42
  t.err(JadeSql::SqlErrors.db_error(e.message))
43
43
  end
@@ -45,25 +45,29 @@ module JadeSql
45
45
  # Transaction control on the shared connection. The execute/fetch ports
46
46
  # above use the same `ActiveRecord::Base.connection`, so anything they
47
47
  # run between begin and commit/rollback is part of this transaction.
48
- # These bypass AR's transaction manager (no savepoints), so they don't
49
- # nest see `Sql.transaction`. Rollback is best-effort: it swallows
50
- # adapter errors so the original failure is the one that propagates.
48
+ #
49
+ # Not the raw `begin_db_transaction` family: those only emit SQL, so a
50
+ # second BEGIN is a warning and its COMMIT ends whichever transaction was
51
+ # already running. The manager issues a SAVEPOINT instead.
52
+ #
53
+ # Rollback is best-effort: it swallows adapter errors so the original
54
+ # failure is the one that propagates.
51
55
  task :port_begin do |t|
52
- ::ActiveRecord::Base.connection.begin_db_transaction
56
+ ::ActiveRecord::Base.connection.begin_transaction
53
57
  t.ok(true)
54
58
  rescue ::ActiveRecord::StatementInvalid => e
55
59
  t.err(JadeSql::SqlErrors.db_error(e.message))
56
60
  end
57
61
 
58
62
  task :port_commit do |t|
59
- ::ActiveRecord::Base.connection.commit_db_transaction
63
+ ::ActiveRecord::Base.connection.commit_transaction
60
64
  t.ok(true)
61
65
  rescue ::ActiveRecord::StatementInvalid => e
62
66
  t.err(JadeSql::SqlErrors.db_error(e.message))
63
67
  end
64
68
 
65
69
  task :port_rollback do |t|
66
- ::ActiveRecord::Base.connection.rollback_db_transaction
70
+ ::ActiveRecord::Base.connection.rollback_transaction
67
71
  t.ok(true)
68
72
  rescue ::ActiveRecord::StatementInvalid
69
73
  t.ok(true)
@@ -81,8 +85,18 @@ module JadeSql
81
85
  # numeric/decimal columns come back as ::BigDecimal; the schema generator
82
86
  # maps them to jade's stdlib Decimal, whose decoder reads the exact
83
87
  # "<coefficient>e<exponent>" wire form. Float would lose precision, so don't.
88
+ # Most rows have nothing to convert — Integers, plain Strings and nils
89
+ # all come back as themselves — so the copy only happens once a value
90
+ # actually changes, and each value is still only looked at once.
84
91
  def self.coerce_row(row)
85
- row.transform_values { |v| coerce_value(v) }
92
+ out = nil
93
+
94
+ row.each_pair do |k, v|
95
+ coerced = coerce_value(v)
96
+ (out ||= row.dup)[k] = coerced unless coerced.equal?(v)
97
+ end
98
+
99
+ out || row
86
100
  end
87
101
 
88
102
  def self.coerce_value(v)
@@ -175,7 +189,7 @@ module JadeSql
175
189
  ""
176
190
  end
177
191
 
178
- # Sql.Mutation.timestamped emits "$JADE_SQL_NOW$" where created_at /
192
+ # Sql.Write.timestamped emits "$JADE_SQL_NOW$" where created_at /
179
193
  # updated_at go. Swap it for one UTC timestamp literal per statement,
180
194
  # computed here — the app clock, so it moves with travel_to/Timecop
181
195
  # (unlike DB now()). The token lives in SQL we generate, never in a