syntropy 0.31.0 → 0.32.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +13 -0
  4. data/TODO.md +46 -1
  5. data/cmd/console.rb +77 -0
  6. data/cmd/serve.rb +1 -1
  7. data/examples/blog/app/_layout/default.rb +11 -0
  8. data/examples/blog/app/_lib/post_store.rb +47 -0
  9. data/examples/blog/app/_schema/2026-01-01-initial.rb +9 -0
  10. data/examples/blog/app/_setup.rb +4 -0
  11. data/examples/blog/app/index.rb +7 -0
  12. data/examples/blog/app/posts/[id]/edit.rb +33 -0
  13. data/examples/blog/app/posts/[id]/index.rb +58 -0
  14. data/examples/blog/app/posts/index.rb +38 -0
  15. data/examples/blog/app/posts/new.rb +29 -0
  16. data/examples/mcp-oauth/README.md +3 -3
  17. data/examples/mcp-oauth/app/mcp.rb +55 -8
  18. data/examples/mcp-oauth/app/oauth/authorize.rb +0 -8
  19. data/examples/mcp-oauth/app/oauth/register.rb +0 -1
  20. data/lib/syntropy/app.rb +23 -9
  21. data/lib/syntropy/db/connection_pool.rb +71 -0
  22. data/lib/syntropy/db/schema.rb +92 -0
  23. data/lib/syntropy/db/store.rb +31 -0
  24. data/lib/syntropy/http/io_extensions.rb +33 -5
  25. data/lib/syntropy/http/server_connection.rb +6 -53
  26. data/lib/syntropy/{module.rb → module_loader.rb} +47 -8
  27. data/lib/syntropy/request/request_info.rb +3 -4
  28. data/lib/syntropy/request/validation.rb +1 -2
  29. data/lib/syntropy/test.rb +13 -1
  30. data/lib/syntropy/version.rb +1 -1
  31. data/lib/syntropy.rb +4 -2
  32. data/syntropy.gemspec +2 -1
  33. data/test/app/_hook.rb +1 -1
  34. data/test/app/by_method.rb +9 -0
  35. data/test/app_setup/_setup.rb +7 -0
  36. data/test/app_setup/index.rb +1 -0
  37. data/test/app_with_schema/_schema/2026-01-02-foo.rb +12 -0
  38. data/test/app_with_schema/_schema/2026-05-30-bar.rb +7 -0
  39. data/test/schema/2026-01-02-foo.rb +12 -0
  40. data/test/schema/2026-05-30-bar.rb +7 -0
  41. data/test/test_app.rb +58 -3
  42. data/test/{test_connection_pool.rb → test_db_connection_pool.rb} +7 -2
  43. data/test/test_db_schema.rb +96 -0
  44. data/test/test_db_store.rb +24 -0
  45. data/test/test_http_protocol.rb +250 -0
  46. data/test/test_http_server_connection.rb +10 -19
  47. data/test/test_json_api.rb +1 -1
  48. data/test/{test_module.rb → test_module_loader.rb} +31 -0
  49. data/test/test_request.rb +7 -4
  50. data/test/test_server.rb +9 -13
  51. metadata +48 -12
  52. data/lib/syntropy/connection_pool.rb +0 -61
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'extralite'
4
+
5
+ module Syntropy
6
+ module DB
7
+ class ConnectionPool
8
+ attr_reader :count
9
+
10
+ def initialize(machine, fn, max_conn)
11
+ @machine = machine
12
+ @fn = fn
13
+ @count = 0
14
+ @max_conn = max_conn
15
+ @queue = UM::Queue.new
16
+ @key = :"connection_pool_#{object_id}"
17
+ end
18
+
19
+ def with_db
20
+ if (db = Thread.current[@key])
21
+ @machine.snooze
22
+ return yield(db)
23
+ end
24
+
25
+ db = checkout
26
+ begin
27
+ Thread.current[@key] = db
28
+ yield(db)
29
+ ensure
30
+ Thread.current[@key] = nil
31
+ checkin(db)
32
+ end
33
+ end
34
+
35
+ def query(sql, *, **, &)
36
+ with_db { it.query(sql, *, **, &) }
37
+ end
38
+
39
+ def execute(sql, *, **)
40
+ with_db { it.execute(sql, *, **) }
41
+ end
42
+
43
+ def close
44
+ while @queue.count > 0
45
+ db = @machine.shift(@queue)
46
+ db.close
47
+ @count -= 1
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def checkout
54
+ return make_db_instance if @queue.count == 0 && @count < @max_conn
55
+
56
+ @machine.shift(@queue)
57
+ end
58
+
59
+ def checkin(db)
60
+ @machine.push(@queue, db)
61
+ end
62
+
63
+ def make_db_instance
64
+ Extralite::Database.new(@fn, wal: true).tap do
65
+ @count += 1
66
+ it.on_progress(mode: :at_least_once, period: 320, tick: 10) { @machine.snooze }
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syntropy
4
+ module DB
5
+ class Schema
6
+ def initialize(module_loader: nil, schema_root: '_schema', &)
7
+ @migrations = {}
8
+ @module_loader = module_loader
9
+ @schema_root = schema_root
10
+ load_schema_from_modules if @module_loader
11
+ run_schema_block(&) if block_given?
12
+ end
13
+
14
+ def apply(connection_pool)
15
+ execute_migrations(connection_pool)
16
+ end
17
+
18
+ def current_version(connection_pool)
19
+ connection_pool.with_db do |db|
20
+ get_schema_version(db)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def load_schema_from_modules
27
+ modules = @module_loader.list(@schema_root)
28
+ modules.each do |name|
29
+ @migrations[File.basename(name)] = @module_loader.load(name)
30
+ end
31
+ end
32
+
33
+ class SchemaBlockRunner
34
+ def initialize(migrations, &)
35
+ @migrations = migrations
36
+ instance_eval(&)
37
+ end
38
+
39
+ def initial(&block)
40
+ @migrations['0000'] = block
41
+ end
42
+
43
+ def version(key, &block)
44
+ @migrations[key] = block
45
+ end
46
+ end
47
+
48
+ def run_schema_block(&)
49
+ SchemaBlockRunner.new(@migrations, &)
50
+ end
51
+
52
+ def execute_migrations(connection_pool)
53
+ connection_pool.with_db do |db|
54
+ current_version = get_schema_version(db)
55
+ migrations_keys = @migrations.keys.sort
56
+ migrations_keys.select { it > current_version } if current_version
57
+
58
+ migrations_keys.each do |key|
59
+ db.transaction do
60
+ @migrations[key].(db)
61
+ set_schema_version(db, key)
62
+ current_version = key
63
+ end
64
+ end
65
+
66
+ current_version
67
+ end
68
+ end
69
+
70
+ def get_schema_version(db)
71
+ db.execute <<~SQL
72
+ create table if not exists __syntropy_schema__(
73
+ k text primary key,
74
+ v text
75
+ );
76
+ SQL
77
+ db.query_single_splat <<~SQL
78
+ select v from __syntropy_schema__
79
+ where k = 'version'
80
+ SQL
81
+ end
82
+
83
+ def set_schema_version(db, version)
84
+ db.execute <<~SQL, v: version
85
+ insert into __syntropy_schema__ (k, v)
86
+ values ('version', :v)
87
+ on conflict(k) do update set v = :v
88
+ SQL
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syntropy
4
+ module DB
5
+ class Store
6
+ def initialize(connection_pool)
7
+ @connection_pool = connection_pool
8
+ end
9
+
10
+ def query(sql, *, **)
11
+ @connection_pool.with_db { it.query(sql, *, **) }
12
+ end
13
+
14
+ def query_single_row(sql, *, **)
15
+ @connection_pool.with_db { it.query_single(sql, *, **) }
16
+ end
17
+
18
+ def query_single_value(sql, *, **)
19
+ @connection_pool.with_db { it.query_single_splat(sql, *, **) }
20
+ end
21
+
22
+ def execute(sql, *, **)
23
+ @connection_pool.with_db { it.execute(sql, *, **) }
24
+ end
25
+
26
+ def transaction(&)
27
+ @connection_pool.with_db(&)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -5,13 +5,13 @@ require 'syntropy/errors'
5
5
  module Syntropy
6
6
  module HTTP
7
7
  module ProtocolMethods
8
- RE_REQUEST_LINE = /^([a-z]+)\s+([^\s]+)\s+HTTP\/([019\.]{1,3})/i
8
+ RE_REQUEST_LINE = /^(get|head|options|trace|put|delete|post|patch|connect)\s+([^\s]+)\s+HTTP\/([019\.]{1,3})/i
9
9
  RE_RESPONSE_LINE = /^HTTP\/1\.1\s+(\d{3})(\s+.+)?$/i
10
- RE_HEADER_LINE = /^([a-z0-9-]+):\s+(.+)/i
10
+ RE_HEADER_LINE = /^([a-z0-9\-]+):\s+(.+)/i
11
11
 
12
12
  MAX_REQUEST_LINE_LEN = 1 << 14 # 16KB
13
13
  MAX_RESPONSE_LINE_LEN = 1 << 8 # 256
14
- MAX_HEADER_LINE_LEN = 1 << 10 # 1KB
14
+ MAX_HEADER_LINE_LEN = 1 << 13 # 8KB
15
15
  MAX_CHUNK_SIZE_LEN = 16
16
16
 
17
17
  # @return [Hash] headers
@@ -27,8 +27,7 @@ module Syntropy
27
27
 
28
28
  headers = {
29
29
  ':method' => m[1].downcase,
30
- ':path' => m[2],
31
- ':protocol' => 'http/1.1'
30
+ ':path' => m[2]
32
31
  }
33
32
 
34
33
  loop do
@@ -93,6 +92,21 @@ module Syntropy
93
92
  nil
94
93
  end
95
94
 
95
+ def http_skip_body(headers)
96
+ content_length = headers['content-length']
97
+ if content_length
98
+ return skip(content_length.to_i)
99
+ end
100
+
101
+ chunked_encoding = headers['transfer-encoding']&.downcase == 'chunked'
102
+ if chunked_encoding
103
+ while http_skip_cte_chunk
104
+ end
105
+ end
106
+
107
+ nil
108
+ end
109
+
96
110
  def http_read_body_chunk(headers)
97
111
  content_length = headers['content-length']
98
112
  if content_length
@@ -141,6 +155,20 @@ module Syntropy
141
155
 
142
156
  buffer ? (buffer << chunk) : chunk
143
157
  end
158
+
159
+ def http_skip_cte_chunk
160
+ chunk_size_str = read_line(MAX_CHUNK_SIZE_LEN)
161
+ return if !chunk_size_str
162
+
163
+ chunk_size = chunk_size_str.to_i(16)
164
+ if chunk_size == 0
165
+ read_line(0)
166
+ return nil
167
+ end
168
+
169
+ chunk = skip(chunk_size)
170
+ read_line(0)
171
+ end
144
172
  end
145
173
  end
146
174
  end
@@ -48,13 +48,17 @@ module Syntropy
48
48
  # connection should be persisted.
49
49
  def serve_request
50
50
  @closed = nil
51
- headers = parse_headers
51
+ headers = @io.http_read_request_headers
52
52
  return false if !headers
53
53
 
54
54
  request = Syntropy::Request.new(headers, self)
55
55
 
56
56
  @app.call(request)
57
- persist_connection?(headers)
57
+ persist = persist_connection?(headers)
58
+ if persist && !headers[':body-done-reading'] && (headers['content-length'] || headers['transfer-encoding'])
59
+ get_body(request)
60
+ end
61
+ persist
58
62
  rescue StandardError => e
59
63
  handle_error(request, e)
60
64
  false
@@ -264,57 +268,6 @@ module Syntropy
264
268
  return connection != 'close'
265
269
  end
266
270
 
267
- def parse_headers
268
- headers = get_request_line(MAX_REQUEST_LINE_LEN)
269
- return nil if !headers
270
-
271
- loop do
272
- line = @io.read_line(MAX_HEADER_LINE_LEN)
273
- break if line.nil? || line.empty?
274
-
275
- m = line.match(RE_HEADER_LINE)
276
- raise ProtocolError, "Invalid header: #{line[0..2047].inspect}" if !m
277
-
278
- headers[m[1].downcase] = m[2]
279
- end
280
-
281
- headers
282
- end
283
-
284
- def get_request_line(buf)
285
- line = @io.read_line(MAX_REQUEST_LINE_LEN)
286
- return nil if !line
287
-
288
- m = line.match(RE_REQUEST_LINE)
289
- raise ProtocolError, 'Invalid request line' if !m
290
-
291
- http_version = m[3]
292
- raise UnsupportedHTTPVersionError, 'HTTP version not supported' if http_version != '1.1'
293
-
294
- {
295
- ':method' => m[1].downcase,
296
- ':path' => m[2],
297
- ':protocol' => 'http/1.1'
298
- }
299
- end
300
-
301
- def read_chunk(headers, buffer)
302
- chunk_size_str = @io.read_line(MAX_CHUNK_SIZE_LEN)
303
- return nil if !chunk_size_str
304
-
305
- chunk_size = chunk_size_str.to_i(16)
306
- if chunk_size == 0
307
- headers[':body-done-reading'] = true
308
- @io.read_line(0)
309
- return nil
310
- end
311
-
312
- chunk = @io.read(chunk_size)
313
- @io.read_line(0)
314
-
315
- buffer ? (buffer << chunk) : chunk
316
- end
317
-
318
271
  INTERNAL_HEADER_REGEXP = /^:/
319
272
 
320
273
  # Formats response headers into an array. If empty_response is true(thy),
@@ -27,8 +27,8 @@ module Syntropy
27
27
  # @param env [Hash] environment hash
28
28
  # @return [void]
29
29
  def initialize(env)
30
- @root_dir = env[:root_dir]
31
30
  @env = env
31
+ @root_dir = env[:root_dir]
32
32
  @modules = {} # maps ref to module entry
33
33
  @fn_map = {} # maps filename to ref
34
34
  end
@@ -37,13 +37,27 @@ module Syntropy
37
37
  #
38
38
  # @param ref [String] module reference
39
39
  # @return [any] export value
40
- def load(ref)
40
+ def load(ref, raise_on_missing: true)
41
41
  ref = "/#{ref}" if ref !~ /^\//
42
42
 
43
- entry = (@modules[ref] ||= load_module(ref))
43
+ entry = load_module(ref, raise_on_missing:)
44
+ return if !entry
45
+
46
+ @modules[ref] ||= entry
44
47
  entry[:export_value]
45
48
  end
46
49
 
50
+ # Returns a list of modules found in the given relative path. The module
51
+ # references are returned as absolute paths (relative to the module loader
52
+ # root directory).
53
+ #
54
+ # @param dir [String] relative module directory
55
+ # @return [Array<String>] list of modules
56
+ def list(dir)
57
+ fns = Dir[File.join(@root_dir, dir, '*.rb')]
58
+ fns.map { it.match(/^#{@root_dir}\/(.+)\.rb$/)[1] }.sort
59
+ end
60
+
47
61
  # Invalidates a module by its filename, normally following a change to the
48
62
  # underlying file (in order to cause reloading of the module). The module
49
63
  # will be removed from the modules map, as well as modules dependending on
@@ -101,17 +115,23 @@ module Syntropy
101
115
  #
102
116
  # @param ref [String] module reference
103
117
  # @return [Hash] module entry
104
- def load_module(ref)
118
+ def load_module(ref, raise_on_missing: true)
105
119
  ref = "/#{ref}" if ref !~ /^\//
106
120
  fn = File.expand_path(File.join(@root_dir, "#{ref}.rb"))
107
- raise Syntropy::Error, "File not found #{fn}" if !File.file?(fn)
121
+ if !File.file?(fn)
122
+ raise Syntropy::Error, "File not found #{fn}" if raise_on_missing
123
+
124
+ return
125
+ end
108
126
 
109
127
  @fn_map[fn] = ref
110
128
  code = IO.read(fn)
111
129
  env = @env.merge(module_loader: self, ref: clean_ref(ref))
112
130
  mod = Syntropy::Module.load(env, code, fn)
113
131
  add_dependencies(ref, mod.__dependencies__)
114
- export_value = transform_module_export_value(mod.__export_value__)
132
+ export_value = transform_module_export_value(
133
+ mod.__export_value__, fn, raise_on_missing:
134
+ )
115
135
 
116
136
  {
117
137
  fn: fn,
@@ -133,10 +153,10 @@ module Syntropy
133
153
  #
134
154
  # @param export_value [any] module's export value
135
155
  # @return [any] transformed value
136
- def transform_module_export_value(export_value)
156
+ def transform_module_export_value(export_value, fn, raise_on_missing:)
137
157
  case export_value
138
158
  when nil
139
- raise Syntropy::Error, 'No export found'
159
+ raise Syntropy::Error, "No export found in #{fn}" if raise_on_missing
140
160
  when String
141
161
  ->(req) { req.respond(export_value) }
142
162
  when Class
@@ -278,9 +298,28 @@ module Syntropy
278
298
  # environment is based on the module's env merged with the given parameters.
279
299
  #
280
300
  # @param env [Hash] environment
301
+ # @return [Syntropy::App]
281
302
  def app(**env)
282
303
  env = @env.merge(env)
283
304
  Syntropy::App.new(**env)
284
305
  end
306
+
307
+ # Returns a request handler that handles requests by calling the appropriate
308
+ # module method (e.g. get, post, etc.)
309
+ #
310
+ # @return [Proc]
311
+ def http_methods
312
+ ->(req) { route_by_http_method(req) }
313
+ end
314
+
315
+ # Handles the given request by calling the module method corresponding to
316
+ # the request's HTTP method. If no method is found, raises a
317
+ # method_not_allowed error.
318
+ def route_by_http_method(req)
319
+ sym = req.method.to_sym
320
+ raise Syntropy::Error.method_not_allowed if !respond_to?(sym)
321
+
322
+ send(sym, req)
323
+ end
285
324
  end
286
325
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'uri'
4
- require 'escape_utils'
5
4
 
6
5
  module Syntropy
7
6
  module RequestInfoMethods
@@ -122,7 +121,7 @@ module Syntropy
122
121
  raise BadRequestError, 'Invalid cookie format' unless c.strip =~ COOKIE_RE
123
122
 
124
123
  key, value = Regexp.last_match[1..2]
125
- h[key] = EscapeUtils.unescape_uri(value)
124
+ h[key] = URI.decode_www_form_component(value)
126
125
  end
127
126
  end
128
127
 
@@ -159,7 +158,7 @@ module Syntropy
159
158
 
160
159
  def auth_bearer_token
161
160
  auth = headers['authorization']
162
- if (m = auth.match(/Bearer\s+([^\w]+)/))
161
+ if auth && (m = auth.match(/Bearer\s+([^\w]+)/))
163
162
  return m[1]
164
163
  end
165
164
 
@@ -248,7 +247,7 @@ module Syntropy
248
247
  v = Regexp.last_match(2)
249
248
  raise BadRequestError, 'Invalid parameter size' if v && v.size > MAX_PARAMETER_VALUE_SIZE
250
249
 
251
- m[EscapeUtils.unescape_uri(k)] = v ? EscapeUtils.unescape_uri(v) : true
250
+ m[URI.decode_www_form_component(k)] = v ? URI.decode_www_form_component(v) : true
252
251
  end
253
252
  end
254
253
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'uri'
4
- require 'escape_utils'
5
4
 
6
5
  module Syntropy
7
6
  module RequestValidationMethods
@@ -40,7 +39,7 @@ module Syntropy
40
39
  # @clauses [Array] one or more validation clauses
41
40
  # @return [any] validated parameter value
42
41
  def validate_param(name, *clauses)
43
- validate(query[name], *clauses)
42
+ validate(query[name.to_s], *clauses)
44
43
  end
45
44
 
46
45
  # Validates and optionally converts a value against the given clauses. If no
data/lib/syntropy/test.rb CHANGED
@@ -8,7 +8,7 @@ module Syntropy
8
8
  class TestHarness
9
9
  def initialize(app)
10
10
  @app = app
11
- @app.test_mode = true
11
+ @app.raise_internal_server_error = true if @app.respond_to?(:raise_internal_server_error=)
12
12
  end
13
13
 
14
14
  def request(headers, body = nil)
@@ -17,6 +17,18 @@ module Syntropy
17
17
  req
18
18
  end
19
19
 
20
+ def no_raise_internal_server_error
21
+ return yield if !@app.respond_to?(:raise_internal_server_error=)
22
+
23
+ begin
24
+ @app.raise_internal_server_error = false
25
+ yield
26
+ ensure
27
+ @app.raise_internal_server_error = true
28
+ end
29
+ end
30
+
31
+
20
32
  private
21
33
 
22
34
  def mock_req(headers, body = nil)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Syntropy
4
- VERSION = '0.31.0'
4
+ VERSION = '0.32.0'
5
5
  end
data/lib/syntropy.rb CHANGED
@@ -8,10 +8,12 @@ require 'syntropy/logger'
8
8
  require 'syntropy/http'
9
9
  require 'syntropy/mime_types'
10
10
  require 'syntropy/app'
11
- require 'syntropy/connection_pool'
11
+ require 'syntropy/db/connection_pool'
12
+ require 'syntropy/db/schema'
13
+ require 'syntropy/db/store'
12
14
  require 'syntropy/errors'
13
15
  require 'syntropy/markdown'
14
- require 'syntropy/module'
16
+ require 'syntropy/module_loader'
15
17
  require 'syntropy/papercraft_extensions'
16
18
  require 'syntropy/routing_tree'
17
19
  require 'syntropy/json_api'
data/syntropy.gemspec CHANGED
@@ -24,11 +24,12 @@ Gem::Specification.new do |s|
24
24
  s.add_dependency 'extralite', '~>2.14'
25
25
  s.add_dependency 'papercraft', '~>3.2.0'
26
26
  s.add_dependency 'uringmachine', '~>1.0.2'
27
- s.add_dependency 'escape_utils', '1.3.0'
28
27
 
29
28
  s.add_dependency 'json'
30
29
  s.add_dependency 'logger'
30
+ s.add_dependency 'irb'
31
31
 
32
32
  s.add_development_dependency 'minitest', '~>6.0.1'
33
33
  s.add_development_dependency 'rake', '~>13.3.1'
34
+ s.add_development_dependency 'solargraph'
34
35
  end
data/test/app/_hook.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  export ->(req, proc) {
2
- req.ctx[:foo] = req.query[:foo]
2
+ req.ctx[:foo] = req.query['foo']
3
3
  proc.(req)
4
4
  }
@@ -0,0 +1,9 @@
1
+ export http_methods
2
+
3
+ def get(req)
4
+ req.respond('foo')
5
+ end
6
+
7
+ def post(req)
8
+ req.respond('bar')
9
+ end
@@ -0,0 +1,7 @@
1
+ @app.env[:setup_imported] = true
2
+
3
+ class << @app
4
+ def foobar
5
+ :foobar
6
+ end
7
+ end
@@ -0,0 +1 @@
1
+ export ->(req) { req.respond('foo') }
@@ -0,0 +1,12 @@
1
+ export ->(db) {
2
+ db.execute <<~SQL
3
+ create table posts (
4
+ id integer primary key autoincrement,
5
+ title text,
6
+ body text
7
+ );
8
+
9
+ insert into posts (title, body)
10
+ values ('foo', 'bar');
11
+ SQL
12
+ }
@@ -0,0 +1,7 @@
1
+ export ->(db) {
2
+ db.execute <<~SQL
3
+ update posts
4
+ set body = 'baz'
5
+ where title = 'foo';
6
+ SQL
7
+ }
@@ -0,0 +1,12 @@
1
+ export ->(db) {
2
+ db.execute <<~SQL
3
+ create table posts (
4
+ id integer primary key autoincrement,
5
+ title text,
6
+ body text
7
+ );
8
+
9
+ insert into posts (title, body)
10
+ values ('foo', 'bar');
11
+ SQL
12
+ }
@@ -0,0 +1,7 @@
1
+ export ->(db) {
2
+ db.execute <<~SQL
3
+ update posts
4
+ set body = 'baz'
5
+ where title = 'foo';
6
+ SQL
7
+ }