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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3908853da702fa27301792535415763adf546fe51bd9f3b91d530a65b55f8d66
4
- data.tar.gz: 34f6387d31fa46134ed8aa87d609b3400c506b832fe2f9baa64ee5225da1f929
3
+ metadata.gz: fbf52e43aad7bcf2dc6259fd40dac6fb6f70d2215513ae89a5e72b120c4b8585
4
+ data.tar.gz: 7a7fd31a1e47bf999d64ad3216bb2c77345a4eb3753df6e2a51384870d9f764f
5
5
  SHA512:
6
- metadata.gz: f899a3f06335acc22181160e5820563fa21f1a1dd981fd33a7f0b1530ac61801455f430f82a1be130682a1c58d7d0b21dfb3d816efc742898c51133fa9223c24
7
- data.tar.gz: 4e7a6637bf8b4f280689d3053557e8a77585da711e626fab7e81f8bfa39fdc1065288a9bd9a5d940f2e04ad0b1a0b6a3f53ee3d65e6f53a14ebd7d31883e3696
6
+ metadata.gz: ff3ac2fbb77785db855080dcd0302beb61332c067c30db6330540cda9a60a7ab72ad684b87772951e8498d5c6aa10b2cf57cce7f1a85932f95db889432c704a0
7
+ data.tar.gz: 84ee4aa581f46c84999109bb102d9c890f9dc45b2803a52f114357981edf9150b84c187ba3a62670691e3eb8629b6396bd8f1789f882857b1558089c198f2877
data/.gitignore CHANGED
@@ -54,3 +54,5 @@ Gemfile.lock
54
54
 
55
55
  # Used by RuboCop. Remote config files pulled in from inherit_from directive.
56
56
  # .rubocop-https?--*
57
+
58
+ examples/**/*.db*
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ # 0.32.0 2026-06-01
2
+
3
+ - Ensure HTTP request body is consumed (skipped) before treating next request
4
+ - Fix `Request#auth_bearer_token`
5
+ - Add `syntropy console` CLI command
6
+ - Add `Module#http_methods` method for simpler REST controllers
7
+ - Add `App#setup_db` method
8
+ - Add `DB::Schema` for schema migrations with support for migration modules
9
+ - Add `DB::Store` class
10
+ - Rename `ConnectionPool` to `DB::ConnectionPool`
11
+ - Add support for setup file (`_setup.rb`)
12
+ - Remove escape_utils dependency
13
+
1
14
  # 0.31.0 2026-05-29
2
15
 
3
16
  - Add message kwarg to `Request#validate`
data/TODO.md CHANGED
@@ -1,5 +1,44 @@
1
1
  ## Immediate
2
2
 
3
+ - [ ] Session
4
+
5
+ https://guides.rubyonrails.org/action_controller_overview.html#session
6
+
7
+ We want something that offers the same features as in Ruby on Rails. A storage
8
+ space for session metadata which can include a user_id, flash messages etc.
9
+
10
+ - The session is attached to the request, and is valid for the browser
11
+ session.
12
+ - The session is a KV store. It can be used to store any data relevant to the
13
+ user's browser session.
14
+ - Each session has a unique ID and that ID is passed to the browser as a
15
+ non-persistent cookie.
16
+ - A session expires if not used (e.g. after 7 days)
17
+ - Session storage either in memory or in DB
18
+ - The session is generated (and session cookie set) upon first write to
19
+ session.
20
+ - Session `Set-Cookie` header should be injected into the HTTP response.
21
+ - The entire session info can be stored in a cookie, provided it does not
22
+ exceed 4KB.
23
+
24
+ ```ruby
25
+ req.session[:flash] = 'Title cannot be empty!'
26
+ req.redirect '/blah'
27
+ ```
28
+
29
+ - [ ] Flash messages
30
+
31
+ - Flash messages are a sub-feature of session storage and are used to relay
32
+ messages from one request to the next in the same session.
33
+ - Flash messages have more complex semantics:
34
+ - There can be more than one.
35
+ - They have types - notice, alert, etc.
36
+ - They normally disappear on the next request (i.e. the session cookie
37
+ should be updated in the response with Set-Cookie).
38
+ - But, you can keep them for the next request with `req.session.flash.keep`
39
+ - The flash messages could be used for the current request with
40
+ `req.session.flash.now`
41
+
3
42
  - [ ] Collection - treat directories and files as collections of data.
4
43
 
5
44
  Kind of similar to the routing tree, but instead of routes it just takes a
@@ -39,9 +78,15 @@
39
78
  - [ ] Website
40
79
  - [v] Frontend part of JSON API
41
80
  - [v] Auto-refresh page when file changes
81
+ - [ ] SQLite database capabilities
82
+ - [ ] Model API + tools
83
+ - [ ] Do we need/want migrations?
84
+ - [ ] Stores
85
+ - [ ] KV store (with TTL)
42
86
  - [v] Examples
43
87
  - [v] Reactive app - counter or some other simple app showing interaction with
44
88
  server
89
+ - [ ] blog
45
90
 
46
91
  ## Testing facilities
47
92
 
@@ -49,7 +94,7 @@
49
94
  - Routes
50
95
  - Route responses
51
96
  - Changes to state / DB
52
- -
97
+ - Rendered HTML - presence of certain markup / elements / text
53
98
 
54
99
  ## Support for applets
55
100
 
data/cmd/console.rb ADDED
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../lib/syntropy'
4
+ require 'optparse'
5
+
6
+ env = {
7
+ mount_path: '/',
8
+ logger: true,
9
+ builtin_applet_path: '/.syntropy',
10
+ watch_files: true
11
+ }
12
+
13
+ parser = OptionParser.new do |o|
14
+ o.banner = 'Usage: syntropy serve [options] DIR'
15
+
16
+ o.on('-h', '--help', 'Show this help message') do
17
+ puts o
18
+ exit
19
+ end
20
+
21
+ o.on('-m', '--mount PATH', 'Set mount path (default: /)') do |path|
22
+ p mount: path
23
+ env[:mount_path] = path
24
+ env[:builtin_applet_path] = File.join(path, '.syntropy')
25
+ end
26
+
27
+ o.on('--no-builtin-applet', 'Do not mount builtin applet') do
28
+ env[:builtin_applet_path] = nil
29
+ end
30
+ end
31
+
32
+ RubyVM::YJIT.enable rescue nil
33
+
34
+ begin
35
+ parser.parse!
36
+ rescue OptionParser::InvalidOption
37
+ puts parser
38
+ exit
39
+ rescue StandardError => e
40
+ p e
41
+ puts e.message
42
+ puts e.backtrace.join("\n")
43
+ exit
44
+ end
45
+
46
+ $syntropy_dev_mode = env[:dev_mode]
47
+ env[:root_dir] = (ARGV.shift || '.').gsub(/\/$/, '')
48
+
49
+ if !File.directory?(env[:root_dir])
50
+ puts "#{File.expand_path(env[:root_dir])} Not a directory"
51
+ exit
52
+ end
53
+
54
+ puts env[:banner] if env[:banner]
55
+ env[:banner] = false
56
+
57
+ # We set Syntropy.machine so we can reference it from anywhere
58
+ env[:machine] = Syntropy.machine = UM.new
59
+ env[:logger] = env[:logger] && Syntropy::Logger.new(env[:machine], **env)
60
+
61
+ @app = Syntropy::App.load(env)
62
+ @env = env
63
+ @machine = env[:machine]
64
+ @connection_pool = @app.connection_pool if @app.respond_to?(:connection_pool)
65
+ @schema = @app.schema if @app.respond_to?(:schema)
66
+ @module_loader = @app.module_loader
67
+
68
+ require 'uringmachine/fiber_scheduler'
69
+ @scheduler = UM::FiberScheduler.new(@machine)
70
+ Fiber.set_scheduler(@scheduler)
71
+
72
+ def import(ref)
73
+ @module_loader.load(ref)
74
+ end
75
+
76
+ require 'irb'
77
+ IRB.start
data/cmd/serve.rb CHANGED
@@ -29,7 +29,7 @@ parser = OptionParser.new do |o|
29
29
 
30
30
  o.on('-d', '--dev', 'Development mode') do
31
31
  env[:dev_mode] = true
32
- env[:watch_files] = 0.1
32
+ env[:watch_files] = true
33
33
  end
34
34
 
35
35
  o.on('-h', '--help', 'Show this help message') do
@@ -0,0 +1,11 @@
1
+ export template { |**props|
2
+ html {
3
+ head {
4
+ title "My awesome blog"
5
+ }
6
+ body {
7
+ render_children(**props)
8
+ auto_refresh!
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,47 @@
1
+ class PostStore < Syntropy::DB::Store
2
+ # @return [Integer] post id
3
+ def create(title, body)
4
+ query_single_value <<~SQL, title:, body:
5
+ insert into posts (title, body)
6
+ values (:title, :body)
7
+ returning id;
8
+ SQL
9
+ end
10
+
11
+ # @return [void]
12
+ def update(id, title, body)
13
+ execute <<~SQL, id:, title:, body:
14
+ update posts
15
+ set title = :title, body = :body
16
+ where id = :id
17
+ SQL
18
+ end
19
+
20
+ # @return [void]
21
+ def delete(id)
22
+ execute <<~SQL, id:
23
+ delete from posts
24
+ where id = :id
25
+ SQL
26
+ end
27
+
28
+ # return [Hash]
29
+ def get(id)
30
+ query_single_row <<~SQL, id:
31
+ select id, title, body
32
+ from posts
33
+ where id = :id
34
+ SQL
35
+ end
36
+
37
+ # return [Array<Hash>]
38
+ def get_all
39
+ query <<~SQL
40
+ select id, title, body
41
+ from posts
42
+ order by id desc
43
+ SQL
44
+ end
45
+ end
46
+
47
+ export PostStore.new(@app.connection_pool)
@@ -0,0 +1,9 @@
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
+ SQL
9
+ }
@@ -0,0 +1,4 @@
1
+ @app.setup_db(
2
+ db_path: File.join(@app.root_dir, '../blog.db'),
3
+ schema_root: '_schema'
4
+ )
@@ -0,0 +1,7 @@
1
+ layout = import '_layout/default'
2
+
3
+ export layout.apply {
4
+ p {
5
+ a 'Blog posts', href: '/posts'
6
+ }
7
+ }
@@ -0,0 +1,33 @@
1
+ @post_store = import '/_lib/post_store'
2
+ @layout = import '/_layout/default'
3
+
4
+ export http_methods
5
+
6
+ def get(req)
7
+ id = req.route_params['id'].to_i
8
+ post = @post_store.get(id)
9
+ raise Syntropy::Error.not_found if !post
10
+
11
+ req.respond_html(
12
+ @template.render(post:)
13
+ )
14
+ end
15
+
16
+ @template = @layout.apply { |post:, **props|
17
+ h1 "Edit blog post"
18
+ div {
19
+ form(action: "/posts/#{post[:id]}", method: 'post') {
20
+ div {
21
+ label 'Title', for: 'title'
22
+ input name: 'title', type: 'text', value: post[:title]
23
+ }
24
+ div {
25
+ label 'Body', for: 'body'
26
+ textarea post[:body], name: 'body', rows: 5
27
+ }
28
+ div {
29
+ button 'Submit', type: 'submit'
30
+ }
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,58 @@
1
+ @post_store = import '/_lib/post_store'
2
+ @layout = import '/_layout/default'
3
+
4
+ export http_methods
5
+
6
+ def get(req)
7
+ id = req.route_params['id'].to_i
8
+ post = @post_store.get(id)
9
+ raise Syntropy::Error.not_found if !post
10
+
11
+ req.respond_html(
12
+ @template.render(post:)
13
+ )
14
+ end
15
+
16
+ def post(req)
17
+ data = req.get_form_data
18
+ return delete(req) if data['method'] == 'delete'
19
+
20
+ id = req.route_params['id'].to_i
21
+ title = req.validate(data['title'], String, /.+/)
22
+ body = req.validate(data['body'], String, /.+/)
23
+
24
+ updated = @post_store.update(id, title, body)
25
+ raise BadRequestError, "Failed to update post" if updated != 1
26
+
27
+ req.redirect "/posts/#{id}", Syntropy::HTTP::SEE_OTHER
28
+ end
29
+
30
+ def delete(req)
31
+ id = req.route_params['id'].to_i
32
+
33
+ deleted = @post_store.delete(id)
34
+ raise BadRequestError, "Failed to delete post" if deleted != 1
35
+
36
+ req.redirect "/posts", Syntropy::HTTP::SEE_OTHER
37
+ end
38
+
39
+ @template = @layout.apply { |post:, **props|
40
+ h1 "My blog"
41
+ div {
42
+ h2 {
43
+ a post[:title]
44
+ }
45
+ p post[:body]
46
+ }
47
+ p {
48
+ a "Edit", href: "/posts/#{post[:id]}/edit"
49
+ span '|'
50
+ a "Back to posts", href: '/posts'
51
+ }
52
+ div {
53
+ form(method: 'post') {
54
+ input type: 'hidden', name: 'method', value: 'delete'
55
+ button 'Delete this post', name: 'delete', type: 'submit'
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,38 @@
1
+ @post_store = import '_lib/post_store'
2
+ @layout = import '_layout/default'
3
+
4
+ export http_methods
5
+
6
+ def get(req)
7
+ posts = @post_store.get_all
8
+ req.respond_html(
9
+ @template.render(posts:)
10
+ )
11
+ end
12
+
13
+ def post(req)
14
+ data = req.get_form_data
15
+ title = req.validate(data['title'], String, /.+/)
16
+ body = req.validate(data['body'], String, /.+/)
17
+ id = @post_store.create(title, body)
18
+
19
+ req.redirect("posts/#{id}")
20
+ end
21
+
22
+ @template = @layout.apply { |**props|
23
+ h1 "My blog"
24
+ props[:posts].each { |post|
25
+ div {
26
+ h2 {
27
+ a post[:title], href: "/posts/#{post[:id]}"
28
+ }
29
+ p post[:body]
30
+ }
31
+ }
32
+
33
+ div {
34
+ p {
35
+ a "New post", href: '/posts/new'
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,29 @@
1
+ @post_store = import '/_lib/post_store'
2
+ @layout = import '/_layout/default'
3
+
4
+ export http_methods
5
+
6
+ def get(req)
7
+ req.respond_html(
8
+ @template.render
9
+ )
10
+ end
11
+
12
+ @template = @layout.apply { |**props|
13
+ h1 "Create blog post"
14
+ div {
15
+ form(action: "/posts", method: 'post') {
16
+ div {
17
+ label 'Title', for: 'title'
18
+ input name: 'title', type: 'text'
19
+ }
20
+ div {
21
+ label 'Body', for: 'body'
22
+ textarea '', name: 'body', rows: 5
23
+ }
24
+ div {
25
+ button 'Submit', type: 'submit'
26
+ }
27
+ }
28
+ }
29
+ }
@@ -69,7 +69,7 @@ This app implements a site that includes an MCP server with OAuth 2.1 authorizat
69
69
  ```
70
70
  HTTP/1.1 201 Created
71
71
  Content-Type: application/json
72
-
72
+
73
73
  {
74
74
  "client_id": "mcp_client_xyz789",
75
75
  "client_name": "Cursor AI Agent",
@@ -102,7 +102,7 @@ This app implements a site that includes an MCP server with OAuth 2.1 authorizat
102
102
  POST /oauth/token HTTP/1.1
103
103
  Host: auth.example.com
104
104
  Content-Type: application/x-www-form-urlencoded
105
-
105
+
106
106
  grant_type=authorization_code&code=splat-auth-code-123&redirect_uri=http%3A%2F%2Flocalhost%3A8400%2Fcallback&client_id=mcp_client_xyz789&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
107
107
  ```
108
108
 
@@ -112,7 +112,7 @@ This app implements a site that includes an MCP server with OAuth 2.1 authorizat
112
112
  ```
113
113
  HTTP/1.1 200 OK
114
114
  Content-Type: application/json
115
-
115
+
116
116
  {
117
117
  "access_token": "mcp_access_token_abc123",
118
118
  "token_type": "Bearer",
@@ -1,16 +1,26 @@
1
+ AuthStore = import './_lib/auth_store'
2
+
1
3
  export ->(req) {
2
4
  req.validate_http_method('post')
3
5
  req.validate_content_type('application/json')
4
6
 
5
- if valid_token?(req)
6
- respond_authorized(req)
7
- else
7
+ if !(token_info = valid_token?(req))
8
8
  respond_unauthorized(req)
9
+ return
9
10
  end
11
+
12
+ handle(req, token_info)
10
13
  }
11
14
 
15
+ # @param req [Syntropy::Request]
12
16
  def valid_token?(req)
13
- false
17
+ token = req.auth_bearer_token
18
+ return false if !token
19
+
20
+ token_info = AuthStore.fetch(token)
21
+ return false if !token_info
22
+
23
+ true
14
24
  end
15
25
 
16
26
  def respond_unauthorized(req)
@@ -27,12 +37,49 @@ def respond_unauthorized(req)
27
37
  )
28
38
  end
29
39
 
30
- def respond_authorized(req)
40
+ def handle(req, token_info)
41
+ req.validate_http_method('post')
42
+ req.validate_content_type('application/json')
43
+ json = JSON.parse(req.read)
44
+
45
+ req.validate(json['jsonrpc'], '2.0')
46
+ req.validate(json['id'], [Integer, String])
47
+
48
+ method = req.validate(json['method'], String)
49
+ sym = :"handle_#{method}"
50
+ raise Syntropy::ValidationError, 'METHOD_NOT_FOUND: method not found' if !respond_to?(sym)
51
+
52
+ send(sym, req, json, token_info)
53
+ rescue Syntropy::ValidationError => e
54
+ if (m = e.message.match(/(.+)\: (.+)/))
55
+ type, message = m[1], m[2]
56
+ else
57
+ type, message = 'INVALID_REQUEST', e.message
58
+ end
59
+ respond_error(req, json, type, message)
60
+ end
61
+
62
+ ERROR_CODES = {
63
+ 'INVALID_REQUEST' => -32600,
64
+ 'METHOD_NOT_FOUND' => -32601,
65
+ 'INVALID_PARAMS' => -32602,
66
+ 'INTERNAL_ERROR' => -32603,
67
+ 'PARSE_ERROR' => -32700
68
+ }
69
+
70
+ def respond_error(req, json, error_type, error_message)
71
+ error_code = ERROR_CODES[type] || ERROR_CODES['INTERNAL_ERROR']
31
72
  req.respond_json(
32
73
  {
33
- resource: "http://localhost:1234/mcp",
34
- authorization_servers: ["http://localhost:1234/"],
35
- scopes_supported: ["mcp:tools", "mcp:resources"]
74
+ jsonrpc: '2.0',
75
+ id: json['id'],
76
+ error: {
77
+ code: error_code,
78
+ message: error_message
79
+ }
36
80
  }
37
81
  )
38
82
  end
83
+
84
+ def handle_initialize()
85
+ end
@@ -9,14 +9,6 @@ export ->(req) {
9
9
  client_info = AuthStore.fetch(params['client_id'])
10
10
  req.validate(client_info, Hash)
11
11
 
12
- # GET /oauth/authorize?
13
- # response_type=code
14
- # client_id=mcp_client_xyz789
15
- # redirect_uri=http%3A%2F%2Flocalhost%3A8400%2Fcallback
16
- # code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
17
- # code_challenge_method=S256
18
- # state=random_state_string
19
-
20
12
  key = AuthStore.store(req.query)
21
13
  req.respond(nil, {
22
14
  ':status' => Syntropy::HTTP::FOUND,
@@ -4,7 +4,6 @@ AuthStore = import '../_lib/auth_store'
4
4
  export ->(req) {
5
5
  req.validate_http_method('post')
6
6
  req.validate_content_type('application/json')
7
-
8
7
  client_info = JSON.parse(req.read)
9
8
  client_id = AuthStore.store(client_info)
10
9
 
data/lib/syntropy/app.rb CHANGED
@@ -6,7 +6,7 @@ require 'yaml'
6
6
  require 'papercraft'
7
7
 
8
8
  require 'syntropy/errors'
9
- require 'syntropy/module'
9
+ require 'syntropy/module_loader'
10
10
  require 'syntropy/routing_tree'
11
11
  require 'syntropy/mime_types'
12
12
 
@@ -35,7 +35,7 @@ module Syntropy
35
35
  end
36
36
 
37
37
  attr_reader :module_loader, :routing_tree, :root_dir, :mount_path, :env
38
- attr_accessor :test_mode
38
+ attr_accessor :raise_on_internal_server_error
39
39
 
40
40
  def initialize(**env)
41
41
  @machine = env[:machine]
@@ -102,6 +102,21 @@ module Syntropy
102
102
  route
103
103
  end
104
104
 
105
+ def setup_db(db_path:, schema_root: '_schema')
106
+ @env[:db_path] = db_path
107
+ @env[:schema_root] = schema_root
108
+
109
+ class << self
110
+ def connection_pool
111
+ @connection_pool ||= DB::ConnectionPool.new(@machine, @env[:db_path], 4)
112
+ end
113
+
114
+ def schema
115
+ @schema ||= DB::Schema.new(module_loader: @module_loader, schema_root: @env[:schema_root])
116
+ end
117
+ end
118
+ end
119
+
105
120
  private
106
121
 
107
122
  # Handles a not found error, taking into account hooks up the tree from the
@@ -458,7 +473,7 @@ module Syntropy
458
473
  req.respond(msg, ':status' => status) rescue nil
459
474
  }
460
475
 
461
- TEST_MODE_DEFAULT_ERROR_HANDLER = ->(req, err) {
476
+ RAISE_INTERNAL_SERVER_ERROR_DEFAULT_ERROR_HANDLER = ->(req, err) {
462
477
  status = Syntropy::Error.http_status(err)
463
478
  raise if status == HTTP::INTERNAL_SERVER_ERROR
464
479
 
@@ -471,8 +486,10 @@ module Syntropy
471
486
  @default_error_handler ||= begin
472
487
  if @builtin_applet
473
488
  @builtin_applet.module_loader.load('/default_error_handler')
489
+ elsif @raise_on_internal_server_error
490
+ RAISE_INTERNAL_SERVER_ERROR_DEFAULT_ERROR_HANDLER
474
491
  else
475
- @test_mode ? TEST_MODE_DEFAULT_ERROR_HANDLER : RAW_DEFAULT_ERROR_HANDLER
492
+ RAW_DEFAULT_ERROR_HANDLER
476
493
  end
477
494
  end
478
495
  end
@@ -482,6 +499,8 @@ module Syntropy
482
499
  #
483
500
  # @return [void]
484
501
  def start
502
+ @module_loader.load('_setup', raise_on_missing: false)
503
+
485
504
  @machine.spin do
486
505
  # we do startup stuff asynchronously, in order to first let Syntropy do
487
506
  # its setup tasks.
@@ -500,9 +519,6 @@ module Syntropy
500
519
  #
501
520
  # @return [void]
502
521
  def file_watcher_loop
503
- wf = @env[:watch_files]
504
- period = wf.is_a?(Numeric) ? wf : 0.1
505
-
506
522
  @machine.file_watch(@root_dir, UM::IN_CREATE | UM::IN_DELETE | UM::IN_CLOSE_WRITE) { |e|
507
523
  fn = e[:fn]
508
524
  @logger&.info(message: 'File change detected', fn: fn)
@@ -510,8 +526,6 @@ module Syntropy
510
526
  debounce_file_change
511
527
  }
512
528
 
513
-
514
-
515
529
  # Syntropy.file_watch(@machine, @root_dir, period: period) do |event, fn|
516
530
  # @logger&.info(message: 'File change detected', fn: fn)
517
531
  # @module_loader.invalidate_fn(fn)