syntropy 0.40.0 → 0.42.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/TODO.md +334 -31
  4. data/cmd/new/template/app/_lib/storage.rb +2 -2
  5. data/cmd/new/template/config/test.rb +1 -1
  6. data/examples/agent/config/test.rb +1 -1
  7. data/examples/blog/app/_lib/posts.rb +10 -6
  8. data/examples/blog/app/_lib/storage.rb +2 -2
  9. data/examples/blog/app/posts/[id]/index.rb +2 -2
  10. data/examples/blog/config/test.rb +1 -1
  11. data/lib/syntropy/collection.rb +206 -0
  12. data/lib/syntropy/connection_pool.rb +118 -0
  13. data/lib/syntropy/controller_extensions.rb +12 -0
  14. data/lib/syntropy/http/server_connection.rb +18 -30
  15. data/lib/syntropy/json_api.rb +3 -12
  16. data/lib/syntropy/kv_store.rb +57 -0
  17. data/lib/syntropy/logger.rb +24 -47
  18. data/lib/syntropy/markdown.rb +1 -1
  19. data/lib/syntropy/module_loader.rb +53 -14
  20. data/lib/syntropy/schema.rb +95 -0
  21. data/lib/syntropy/storage.rb +42 -4
  22. data/lib/syntropy/test.rb +1 -1
  23. data/lib/syntropy/version.rb +1 -1
  24. data/lib/syntropy.rb +2 -1
  25. data/syntropy.gemspec +2 -2
  26. data/test/fixtures/app/_lib/dep_invalidate.rb +5 -0
  27. data/test/fixtures/collection/basic/_articles/2026-06-30-foo.md +6 -0
  28. data/test/fixtures/collection/basic/_articles/2026-07-13-bar.md +6 -0
  29. data/test/fixtures/collection/basic/_articles/2026-08-07-baz.md +6 -0
  30. data/test/fixtures/collection/basic/index+.rb +23 -0
  31. data/test/fixtures/collection/docs/_pages/01-foo/01-foo.md +4 -0
  32. data/test/fixtures/collection/docs/_pages/01-foo/02-bar.md +4 -0
  33. data/test/fixtures/collection/docs/_pages/01-foo/_index.yml +1 -0
  34. data/test/fixtures/collection/docs/_pages/02-bar/01-bar.md +4 -0
  35. data/test/fixtures/collection/docs/_pages/02-bar/02-baz.md +4 -0
  36. data/test/fixtures/collection/docs/_pages/02-bar/_index.yml +1 -0
  37. data/test/fixtures/collection/docs/index+.rb +16 -0
  38. data/test/test_app.rb +7 -7
  39. data/test/test_cmd_new.rb +38 -0
  40. data/test/test_collection.rb +164 -0
  41. data/test/test_connection_pool.rb +79 -3
  42. data/test/test_http_server_connection.rb +6 -6
  43. data/test/test_json_api.rb +4 -4
  44. data/test/test_kv_store.rb +6 -31
  45. data/test/test_markdown.rb +3 -3
  46. data/test/test_module_loader.rb +15 -0
  47. data/test/test_schema.rb +4 -4
  48. data/test/test_storage.rb +103 -0
  49. metadata +24 -11
  50. data/lib/syntropy/storage/connection_pool.rb +0 -106
  51. data/lib/syntropy/storage/kv_store.rb +0 -68
  52. data/lib/syntropy/storage/prepared_query.rb +0 -36
  53. data/lib/syntropy/storage/schema.rb +0 -92
  54. data/lib/syntropy/storage/store.rb +0 -33
  55. data/test/test_store.rb +0 -24
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'json'
5
+ require 'syntropy/markdown'
6
+
7
+ module Syntropy
8
+ # A collection represents a collection of data entities represented in files.
9
+ class Collection
10
+ attr_reader :root, :root_dir, :url_base
11
+
12
+ # Initializes a collection.
13
+ #
14
+ # @param machine [UringMachine]
15
+ # @param root [String] collection root
16
+ # @param url_base [String] URL base
17
+ # @return [void]
18
+ def initialize(machine:, root:, url_base:)
19
+ @machine = machine
20
+ @root_dir = File.expand_path(root)
21
+ @url_base = url_base
22
+ @list = {} # list of non-directory items
23
+ @root = make_tree_root
24
+ @dirs = { @root_dir => @root } # mapping directory paths to tree items
25
+
26
+ calc_collection_tree
27
+ end
28
+
29
+ # Returns a list of items in the collection.
30
+ #
31
+ # @return [Array] items
32
+ def list
33
+ @list.values
34
+ end
35
+
36
+ # Returns the item corresponding to the given URL.
37
+ #
38
+ # @param url [String] URL
39
+ # @return [Hash] item
40
+ def get(url)
41
+ @list[url]
42
+ end
43
+
44
+ private
45
+
46
+ def make_tree_root
47
+ {
48
+ fn: find_directory_data_file(@root_dir),
49
+ ref: '',
50
+ url: url_base,
51
+ type: :directory,
52
+ items: []
53
+ }
54
+ end
55
+
56
+ # Calculates the collection tree.
57
+ #
58
+ # @return [void]
59
+ def calc_collection_tree
60
+ queue = UM::Queue.new
61
+ Dir[File.join(@root_dir, '**/*')].each do |fn|
62
+ next if File.basename(fn) =~ /^_/
63
+
64
+ ref = fn_to_ref(fn)
65
+ url = File.join(@url_base, ref)
66
+ parent_fn = File.expand_path(File.join(fn, '..'))
67
+ parent = @dirs[parent_fn]
68
+ raise Error, "Parent not found: #{parent_fn}" if !parent
69
+
70
+ if File.directory?(fn)
71
+ dir = {
72
+ fn: find_directory_data_file(fn),
73
+ ref:,
74
+ url:,
75
+ type: :directory,
76
+ items: []
77
+ }
78
+ @dirs[fn] = dir
79
+ parent[:items] << dir
80
+ @machine.push(queue, dir) if dir[:fn]
81
+ else
82
+ item = {
83
+ fn:,
84
+ ref:,
85
+ url:
86
+ }
87
+ @list[url] = item
88
+ parent[:items] << item
89
+ @machine.push(queue, item)
90
+ end
91
+ end
92
+ 4.times { @machine.push(queue, :stop) }
93
+
94
+ fibers = 4.times.map {
95
+ @machine.spin {
96
+ loop {
97
+ item = @machine.shift(queue)
98
+ break if item == :stop
99
+
100
+ load_item(item)
101
+ }
102
+ }
103
+ }
104
+ @machine.join(fibers)
105
+
106
+ add_prev_next_links
107
+ end
108
+
109
+ def scan_files(path, queue)
110
+ end
111
+
112
+ # Converts a filename to a ref.
113
+ #
114
+ # @param fn [String]
115
+ # @return [String] ref
116
+ def fn_to_ref(fn)
117
+ @ref_rel_regexp ||= /^#{@root_dir}\/(.+)/
118
+ rel = fn.match(@ref_rel_regexp)[1]
119
+
120
+ (m = rel.match(/^(.+)\.(md|json|yml|yaml)$/)) ? m[1] : rel
121
+ end
122
+
123
+ # Returns the data file for the given directory.
124
+ #
125
+ # @param dir [String]
126
+ # @return [String, nil] directory data file
127
+ def find_directory_data_file(dir)
128
+ (
129
+ detect_file(File.join(dir, '_index.yml')) ||
130
+ detect_file(File.join(dir, '_index.json'))
131
+ )
132
+ end
133
+
134
+ # Detects if the given file exists.
135
+ #
136
+ # @param fn [String]
137
+ # @return [String, nil] filename if exists, nil otherwise
138
+ def detect_file(fn)
139
+ File.file?(fn) ? fn : nil
140
+ end
141
+
142
+ # Loads the given item.
143
+ #
144
+ # @param item [Hash]
145
+ # @return [void]
146
+ def load_item(item)
147
+ basename = File.basename(item[:fn])
148
+ case (ext = File.extname(basename))
149
+ when '.md'
150
+ item[:type] ||= :markdown
151
+ load_item_markdown(item)
152
+ when '.json'
153
+ item[:type] ||= :json
154
+ load_item_json(item)
155
+ when '.yml', '.yaml'
156
+ item[:type] ||= :yaml
157
+ load_item_yaml(item)
158
+ else
159
+ raise Syntropy::Error, "Unkown file type #{ext}"
160
+ end
161
+ end
162
+
163
+ # Loads ands parses the file for the given markdown item.
164
+ #
165
+ # @param item [Hash]
166
+ # @return [void]
167
+ def load_item_markdown(item)
168
+ attributes, body = Markdown.parse_file(item[:fn], {})
169
+ item.merge!(attributes:, body:)
170
+ end
171
+
172
+ # Loads ands parses the file for the given JSON item.
173
+ #
174
+ # @param item [Hash]
175
+ # @return [void]
176
+ def load_item_json(item)
177
+ data = @machine.file_read(item[:fn])
178
+ item[:data] = JSON.parse(data, symbolize_names: true)
179
+ end
180
+
181
+ YAML_OPTS = {
182
+ permitted_classes: [Date],
183
+ symbolize_names: true
184
+ }.freeze
185
+
186
+ # Loads ands parses the file for the given YAML item.
187
+ #
188
+ # @param item [Hash]
189
+ # @return [void]
190
+ def load_item_yaml(item)
191
+ data = @machine.file_read(item[:fn])
192
+ item[:data] = YAML.safe_load(data, **YAML_OPTS)
193
+ end
194
+
195
+ def add_prev_next_links
196
+ last = nil
197
+ @list.each_value { |item|
198
+ if last
199
+ item[:prev] = last
200
+ last[:next] = item
201
+ end
202
+ last = item
203
+ }
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'extralite'
4
+
5
+ module Syntropy
6
+ # ConnectionPool implements concurrent access to an SQLite database.
7
+ class ConnectionPool
8
+ attr_reader :count
9
+
10
+ # Initializes the connection pool.
11
+ #
12
+ # @param machine [UringMachine] machine instance
13
+ # @param fn [String] database filename
14
+ # @param max_conn [Integer] maximum number of connections
15
+ # @return [void]
16
+ def initialize(machine, fn, max_conn)
17
+ @machine = machine
18
+ @fn = fn
19
+ @count = 0
20
+ @max_conn = max_conn
21
+ @queue = UM::Queue.new
22
+ @key = :"connection_pool_#{object_id}"
23
+ end
24
+
25
+ # Checks out a database connection, passing it to the given block. This
26
+ # method is reentrant - called from the same fiber, it will yield the same
27
+ # database connection.
28
+ #
29
+ # @return [Extralite::Database] database connection
30
+ def with_db
31
+ if (db = Thread.current[@key])
32
+ @machine.snooze
33
+ return yield(db)
34
+ end
35
+
36
+ db = checkout
37
+ begin
38
+ Thread.current[@key] = db
39
+ yield(db)
40
+ ensure
41
+ Thread.current[@key] = nil
42
+ checkin(db)
43
+ end
44
+ end
45
+
46
+ # Performs the given query by acquiring a database connection and running
47
+ # the query.
48
+ #
49
+ # @return [Array<Hash>] result rows
50
+ def query(*, **, &)
51
+ with_db { it.query(*, **, &) }
52
+ end
53
+
54
+ # Performs the given query by acquiring a database connection and running
55
+ # the query.
56
+ #
57
+ # @return [<Hash>, nil] result row
58
+ def query_single_row(*, **)
59
+ with_db { it.query_single(*, **) }
60
+ end
61
+
62
+ # Performs the given query by acquiring a database connection and running
63
+ # the query.
64
+ #
65
+ # @return [any] result value
66
+ def query_single_value(*, **)
67
+ with_db { it.query_single_splat(*, **) }
68
+ end
69
+
70
+ # Executes the given query by acquiring a database connection and running
71
+ # the query.
72
+ #
73
+ # @return [Integer] number of changed rows
74
+ def execute(*, **)
75
+ with_db { it.execute(*, **) }
76
+ end
77
+
78
+ # Closes the connection pool by removing all connections.
79
+ #
80
+ # @return [void]
81
+ def close
82
+ while @queue.count > 0
83
+ db = @machine.shift(@queue)
84
+ db.close
85
+ @count -= 1
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ # Checks out a connection from the pool.
92
+ #
93
+ # @return [Extralite::Database] database connection
94
+ def checkout
95
+ return make_db_instance if @queue.count == 0 && @count < @max_conn
96
+
97
+ @machine.shift(@queue)
98
+ end
99
+
100
+ # Checks in a connection to the pool.
101
+ #
102
+ # @param db [Extralite::Database] database connection
103
+ # @return [void]
104
+ def checkin(db)
105
+ @machine.push(@queue, db)
106
+ end
107
+
108
+ # Creates a database connection.
109
+ #
110
+ # @return [Extralite::Database] database connection
111
+ def make_db_instance
112
+ Extralite::Database.new(@fn, wal: true).tap do
113
+ @count += 1
114
+ it.on_progress(mode: :at_least_once, period: 320, tick: 10) { @machine.snooze }
115
+ end
116
+ end
117
+ end
118
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
+ require 'syntropy/collection'
4
5
 
5
6
  module Syntropy
6
7
  # Utilities for use in modules
@@ -113,5 +114,16 @@ module Syntropy
113
114
 
114
115
  send(sym, req)
115
116
  end
117
+
118
+ # Opens a collection with the given arguments.
119
+ #
120
+ # @param rel_root [String] relative collection root
121
+ # @param url_base [String] URL base
122
+ # @return [Syntropy::Collection] collection
123
+ def collection(rel_root, url_base:)
124
+ invalidate_on_file_change(File.join(rel_root, '**'))
125
+ path = normalize_import_ref(rel_root)
126
+ Collection.new(machine: @machine, root: File.join(@env[:app_root], path), url_base:)
127
+ end
116
128
  end
117
129
  end
@@ -91,9 +91,10 @@ module Syntropy
91
91
  # @return [void]
92
92
  def handle_error(request, err)
93
93
  case err
94
+ when Errno::ECONNRESET
95
+ # ignore
94
96
  when SystemCallError
95
- log_error(err, 'I/O error')
96
- false
97
+ log_error(err, 'Syscall error')
97
98
  when ProtocolError
98
99
  log_error(err, err.message)
99
100
  respond(request, err.message, ':status' => err.http_status)
@@ -218,16 +219,16 @@ module Syntropy
218
219
  # @param done [boolean] whether the response is completed
219
220
  # @return [void]
220
221
  def send_chunk(request, chunk, done: false)
221
- data = +''
222
- data << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n" if chunk
223
- data << EMPTY_CHUNK if done
224
- return if data.empty?
225
-
226
- @machine.send(@fd, data, data.bytesize, SEND_FLAGS)
227
- return if @done || !done
222
+ return if @done
228
223
 
229
- @logger&.info(request: request, response_headers: @response_headers)
230
- @done = true
224
+ len = chunk.is_a?(IO::Buffer) ? chunk.size : chunk.bytesize
225
+ if !done
226
+ @io.write(len.to_s(16),"\r\n", chunk, "\r\n")
227
+ else
228
+ @io.write(len.to_s(16),"\r\n", chunk, "\r\n0\r\n\r\n")
229
+ @logger&.info(request: request, response_headers: @response_headers)
230
+ @done = true
231
+ end
231
232
  end
232
233
 
233
234
  # Finishes the response to the current request. If no headers were sent,
@@ -257,25 +258,12 @@ module Syntropy
257
258
  env[:headers] = cache_headers
258
259
  end
259
260
 
260
- maxlen = env[:max_len] || 65_536
261
- buf = String.new(capacity: maxlen)
262
- headers_sent = nil
263
- loop do
264
- res = @machine.read(fd, buf, maxlen, 0)
265
- if res < maxlen && !headers_sent
266
- return respond(req, buf, env[:headers])
267
- elsif res == 0
268
- return finish(req)
269
- end
270
-
271
- if !headers_sent
272
- send_headers(req, env[:headers])
273
- headers_sent = true
274
- end
275
- done = res < maxlen
276
- send_chunk(req, buf, done: done)
277
- return if done
278
- end
261
+ io = @machine.io(fd, :file)
262
+ send_headers(req, env[:headers])
263
+ io.read_each { send_chunk(req, it) }
264
+ finish(req)
265
+ ensure
266
+ @machine.close_async(fd) if fd
279
267
  end
280
268
 
281
269
  # Closes the connection.
@@ -25,11 +25,6 @@ module Syntropy
25
25
  ':status' => status,
26
26
  'Content-Type' => 'application/json'
27
27
  )
28
- rescue => e
29
- puts '*' * 40
30
- p e
31
- p e.backtrace.join
32
- puts
33
28
  end
34
29
 
35
30
  private
@@ -39,20 +34,16 @@ module Syntropy
39
34
  # @param req [Syntropy::Request]
40
35
  def __invoke__(req)
41
36
  q = req.validate_param(:q, String).to_sym
42
- response = case req.method
37
+ case req.method
43
38
  when 'get'
44
- __invoke_get__(q, req)
39
+ response = __invoke_get__(q, req)
45
40
  when 'post'
46
- __invoke_post__(q, req)
41
+ response = __invoke_post__(q, req)
47
42
  else
48
43
  raise Syntropy::Error.method_not_allowed
49
44
  end
50
45
  [{ status: 'OK', response: response }, HTTP::OK]
51
46
  rescue StandardError => e
52
- if !e.is_a?(Syntropy::Error)
53
- p e
54
- p e.backtrace
55
- end
56
47
  __error_response__(e)
57
48
  end
58
49
 
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syntropy
4
+ # The KVStore class implements an SQLite-backed key-value store
5
+ class KVStore
6
+ attr_reader :table_name
7
+
8
+ def initialize(db, table_name)
9
+ @table_name = table_name
10
+ setup_queries
11
+ apply_schema(db)
12
+ end
13
+
14
+ def get(db, key)
15
+ db.query_single_splat(@sql_get, key)
16
+ end
17
+
18
+ def set(db, key, value)
19
+ db.execute(@sql_set, key, value)
20
+ end
21
+
22
+ def setex(db, key, value, ttl)
23
+ db.execute(@sql_setex, key, value, ttl ? Time.now.to_f + ttl : nil)
24
+ end
25
+
26
+ def sweep(db)
27
+ db.execute(@sql_sweep, Time.now.to_f)
28
+ end
29
+
30
+ private
31
+
32
+ def apply_schema(db)
33
+ db.execute <<~SQL
34
+ create table if not exists #{@table_name} (key text primary key, value, expires float);
35
+ create index if not exists idx_#{@table_name}_expires on #{@table_name} (expires) where expires is not null;
36
+ SQL
37
+ end
38
+
39
+ def setup_queries
40
+ @sql_get = "select value from #{@table_name} where key = ?"
41
+
42
+ @sql_set = <<~SQL
43
+ insert into #{@table_name} (key, value)
44
+ values($1, $2)
45
+ on conflict (key) do update set value = $2, expires = null
46
+ SQL
47
+
48
+ @sql_setex = <<~SQL
49
+ insert into #{@table_name} (key, value, expires)
50
+ values($1, $2, $3)
51
+ on conflict (key) do update set value = $2, expires = $3
52
+ SQL
53
+
54
+ @sql_sweep = "delete from #{@table_name} where expires < ?"
55
+ end
56
+ end
57
+ end
@@ -80,49 +80,40 @@ module Syntropy
80
80
  # @param o [Hash] raw entry
81
81
  # @return [Hash] log entry
82
82
  def make_entry(level, o)
83
- if o[:request]
84
- make_request_entry(level, o)
85
- elsif o[:error]
86
- make_error_entry(level, o)
87
- else
88
- make_hash_entry(level, o)
89
- end
83
+ t = Time.now
84
+ entry = o.merge(
85
+ level: level.to_s,
86
+ ts: t.to_i,
87
+ ts_s: t.iso8601,
88
+ message: o[:message]
89
+ )
90
+
91
+ entry.merge!(error_info(error)) if (error = o.delete(:error))
92
+ entry.merge!(request_info(request)) if (request = o.delete(:request))
93
+
94
+ entry
90
95
  end
91
96
 
92
- # Makes an error log entry.
97
+ # Extracts error info from given error object
93
98
  #
94
- # @param level [Symbol] log level
95
- # @param o [Hash] input entry
96
- # @return [Hash] output entry
97
- def make_error_entry(level, o)
98
- err = o[:error]
99
- t = Time.now
99
+ # @param error [Exception] exception
100
+ # @return [Hash] error info
101
+ def error_info(error)
100
102
  {
101
- level: level.to_s,
102
- ts: t.to_i,
103
- ts_s: t.iso8601
104
- }.merge(o).merge(
105
- error: "#{err.class}: #{err.message}",
106
- backtrace: err.backtrace
107
- )
103
+ error: "#{error.class}: #{error.message}",
104
+ backtrace: error.backtrace
105
+ }
108
106
  end
109
107
 
110
- # Makes a request log entry.
108
+ # Extracts request info from given request object
111
109
  #
112
- # @param level [Symbol] log level
113
- # @param o [Hash] input entry
114
- # @return [Hash] output entry
115
- def make_request_entry(level, o)
116
- request = o[:request]
110
+ # @param request [Syntropy::Request] request
111
+ # @return [Hash] request info
112
+ def request_info(request)
117
113
  request_headers = request.headers
118
- response_headers = o[:response_headers]
114
+ response_headers = request.response_headers
119
115
  elapsed = monotonic_clock - request.start_stamp
120
- t = Time.now
121
116
  {
122
- level: level.to_s,
123
- ts: t.to_i,
124
- ts_s: t.iso8601,
125
- message: o[:message] || 'HTTP request done',
126
117
  client_ip: request.forwarded_for || '?',
127
118
  http_method: request_headers[':method'].upcase,
128
119
  user_agent: request_headers['user-agent'],
@@ -132,20 +123,6 @@ module Syntropy
132
123
  }
133
124
  end
134
125
 
135
- # Makes a request log entry.
136
- #
137
- # @param level [Symbol] log level
138
- # @param o [Hash] input entry
139
- # @return [Hash] output entry
140
- def make_hash_entry(level, hash)
141
- t = Time.now
142
- {
143
- level: level.to_s,
144
- ts: t.to_i,
145
- ts_s: t.iso8601
146
- }.merge(hash)
147
- end
148
-
149
126
  # Returns the monotonic clock.
150
127
  #
151
128
  # @return [Float]
@@ -137,7 +137,7 @@ module Syntropy
137
137
  # @return [Array<atts, md>] Array containing attributes and clean markdown
138
138
  def parse_md(atts, md)
139
139
  md = parse_content(atts, md)
140
- [atts, md]
140
+ [atts, md.chomp]
141
141
  end
142
142
 
143
143
  # Creates a Markdown file contoroller for the given environment,