syntropy 0.41.0 → 0.43.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 +15 -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 +15 -28
  15. data/lib/syntropy/json_api.rb +3 -12
  16. data/lib/syntropy/kv_store.rb +57 -0
  17. data/lib/syntropy/logger.rb +28 -48
  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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5fa009d6a635c90587a2a4e9a6b4b55751969f6185a175c80d4cc5a01cee82e5
4
- data.tar.gz: 04fb7ee45c534fc052c1a4674d32c35a17a94fac8e4100c4af7e704df847f6e4
3
+ metadata.gz: 6109ea0e6322adfd9467ba0aed2b85f04182556dc24efa07eaa6982a19a05480
4
+ data.tar.gz: 24520d125bf9f1c2bf54af4def8e2b7f2f0027da3e2508f58f754e46d48e6610
5
5
  SHA512:
6
- metadata.gz: 271e3fddd94f4a209d1207ce7c6447a8dbd8d50890467cc2937da4ba60332740352035b4ec99de89a6ae75ec34798dd92fa58da00c8a7f146a5cfa27df6a312e
7
- data.tar.gz: 29463012056e3f3a848fcfb5d8505bf47b89f2534fcee59b1872ea0f2dc82333719773c73e93f119a1ab6fb6508ee609b738565508d903efca158797faff4c2c
6
+ metadata.gz: 1af2662580cac682ca0162c2d7088ad2756d0738b8f16cbf41102b3ee06d9283118bafcac36fe24b388f46801481d279833e9bd3b44b6394b2af1ecca7a4856a
7
+ data.tar.gz: 26a62a199095a62dcdda41a811991a0818934959f2fd4851d224e83790b26beb305a0e258cbae63e08d018742727d9b789444754e3d99cfb3c5c9291b6804d2e
data/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ # 0.43.0 2026-10-07
2
+
3
+ - Fix logging
4
+ - Update Extralite
5
+
6
+ # 0.42.0 2026-10-04
7
+
8
+ - Add `Syntropy::Collection`
9
+ - Support for nested collections
10
+ - Support for invalidation on collection file change
11
+ - Improve static file serving
12
+ - Read modules using `UM#file_read`
13
+ - Refactor log entry computation
14
+ - Refactor and simplify storage layer
15
+
1
16
  # 0.41.0 2026-08-25
2
17
 
3
18
  - Don't log ECONNRESET errors
data/TODO.md CHANGED
@@ -1,6 +1,19 @@
1
- ## Immediate
1
+ ## Extralite doc site
2
2
 
3
- - [ ] Nicer logging in development mode:
3
+ - Similar to Papercraft site
4
+ - Implement collection with code from papercraft.noteflakes.com
5
+ - Further take ideas from discussion below
6
+ - Implement auto light/dark CSS theme
7
+ - Write docs
8
+
9
+ ## Logging
10
+
11
+ - Make it possible to use different logger implementations, or maybe chain
12
+ multiple logger implementations, such that we could emit for example both to
13
+ STDOUT and to a database.
14
+ - Add optional SQLite-log store (+optional log viewing in Admin interface)
15
+
16
+ ## Nicer logging in development mode:
4
17
 
5
18
  Nice terminal formatting with colors:
6
19
 
@@ -34,6 +47,128 @@ sharon@nf1:~/tmp/capatest2$ npx serve dist
34
47
  INFO Gracefully shutting down. Please wait...
35
48
  ```
36
49
 
50
+ ```
51
+ sharon@nf1 ~/ $ be syntropy serve
52
+
53
+ ooo
54
+ ooooo
55
+ ooo vvv Syntropy - a web framework for Ruby
56
+ o vvvvv --------------------------------------
57
+ | vvv o https://github.com/digital-fabric/syntropy
58
+ :|:::|::|:
59
+ ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
60
+
61
+ 2026-06-07 08:04:23 <== GET /
62
+ 2026-06-07 08:04:23 ==> 200 (42ms)
63
+ 2026-06-07 08:04:23 <== GET /assets/index-edfizW3i.js
64
+ 2026-06-07 08:04:23 <== GET /assets/index-PVjztr5e.css
65
+ 2026-06-07 08:04:23 ==> 200 (3ms)
66
+ 2026-06-07 08:04:23 ==> 200 (12ms)
67
+
68
+ ```
69
+
70
+ Verbose output (`-v`):
71
+
72
+ ```
73
+ 2026-06-07 08:04:23 <== GET /
74
+ Host: localhost:3000
75
+ User-Agent: ...
76
+ Cookie: ...
77
+ Accept: ...
78
+ 2026-06-07 08:04:23 ==> 200 (42ms)
79
+ Server: syntropy
80
+ Transfer-Encoding: chunked
81
+ ...
82
+ 2026-06-07 08:04:23 <== GET /assets/index-edfizW3i.js
83
+ ...
84
+ 2026-06-07 08:04:23 <== GET /assets/index-PVjztr5e.css
85
+ ...
86
+ 2026-06-07 08:04:23 ==> 200 (3ms)
87
+ ...
88
+ 2026-06-07 08:04:23 ==> 200 (12ms)
89
+ ...
90
+ ```
91
+
92
+ ## Background jobs
93
+
94
+ - Backed by SQLite db
95
+ - Recurring jobs
96
+ - Scheduled jobs
97
+ - Retries
98
+ - CLI command `syntropy background`
99
+ - Run automatically when running `syntropy serve` in dev mode
100
+
101
+ ## Admin Dashboard
102
+
103
+ - Server state / stats
104
+ - Background jobs state / stats
105
+ - For resources:
106
+
107
+ ```ruby
108
+ # /app/admin+.rb
109
+ export admin_ui(
110
+ resources: {
111
+ posts: {
112
+ type: 'crud',
113
+ module: '/storage/posts',
114
+ primary_key: :id,
115
+ editor_fields: {
116
+ id: hidden
117
+ title: text,
118
+ body: textarea
119
+ },
120
+ actions: {
121
+ list: ->(s) { s.list() },
122
+ list:
123
+ }
124
+ }
125
+ }
126
+ )
127
+ ```
128
+
129
+ Or, maybe:
130
+
131
+ ```ruby
132
+ # /app/admin/index.rb
133
+ Admin = import '/admin/.controller'
134
+ export Admin.homepage
135
+
136
+
137
+ # /app/admin/.controller.rb
138
+ require 'syntropy/admin'
139
+ export Syntropy::Admin.new(
140
+ # config
141
+ )
142
+
143
+
144
+ # /app/admin/resources/posts.rb
145
+ Admin = import '/admin/.controller'
146
+ Posts = import '/storage/posts'
147
+
148
+ Resource = Admin.resource(
149
+ name: 'Posts',
150
+ fields: {
151
+ id: hidden
152
+ title: text,
153
+ body: textarea
154
+ }
155
+ )
156
+ export Resource.controller
157
+
158
+ def list(req)
159
+ list = Posts.list
160
+ end
161
+
162
+ actions: {
163
+ list: ->(s) { s.list() },
164
+ list:
165
+ }
166
+ )
167
+ ```
168
+
169
+
170
+ # Pub/sub
171
+
37
172
  - [ ] Ability to load modules from builtin applet
38
173
  - [ ] Can we mount them on the app's module loader?
39
174
 
@@ -41,46 +176,56 @@ sharon@nf1:~/tmp/capatest2$ npx serve dist
41
176
 
42
177
  - [ ] Pub/sub
43
178
  - [ ] Ruby side
179
+
180
+ ```ruby
181
+ @firehose = @app.import('/.syntropy/firehose')
182
+
183
+ def on_change(v)
184
+ @firehose.publish({ kind: 'value_changed', value: v })
185
+ end
186
+
187
+ ## in a pub/sub controller
188
+ @firehose = @app.import('/.syntropy/firehose')
189
+
190
+ def call(req)
191
+ @firehose.stream_sse(req)
192
+ end
193
+ ```
194
+
44
195
  - [ ] JS side
196
+
197
+ ```javascript
198
+ await window.syntropy.firehose.process(async (evt, next) => {
199
+ if (evt.kind == 'value_changed')
200
+ await valueChanged(evt.value)
201
+ else if (next)
202
+ await next(evt);
203
+ });
204
+ ```
205
+
45
206
  - [ ] Reimplement `auto_refresh` using a *default* event bus provided by
46
207
  Syntropy
47
208
 
48
209
  - [ ] An alternative to the pub/sub design - add streaming responses to the
49
210
  JSON/jS API (using SSE).
50
211
 
51
- ## Collections
52
-
53
- - [ ] Collection - treat directories and files as collections of data.
212
+ ## Scaffolding
54
213
 
55
- Kind of similar to the routing tree, but instead of routes it just takes a
56
- bunch of files and turns it into a dataset. Each directory is a "table" and is
57
- composed of zero or more files that form rows in the table. Supported file
58
- formats:
214
+ In rails, you can generate specific parts of the app, like a model or a
215
+ controller, or a whole set of model, view, controller code for a given entity
216
+ (scaffolding.)
59
217
 
60
- - foo.md - markdown with optional front matter
61
- - foo.json - JSON record
62
- - foo.yml - YAML record
218
+ Does this make sense for Syntropy? The different kinds of code generated:
63
219
 
64
- API:
220
+ - a model with CRUD methods for the given entity.
221
+ - a set of CRUD controllers (spread over multiple files).
222
+ - a set of CRUD views corresponding to the controller.
223
+ - a set of tests for model, controller, view.
65
224
 
66
- ```ruby
67
- Articles = @app.collection('_articles/*.md')
68
- article = Articles.last_by(&:date)
69
-
70
- article.title #=>
71
- article.date #=>
72
- article.layout #=>
73
- article.render_proc #=> (load layout, apply article)
74
- article.render #=> (render to HTML)
75
-
76
- # there should also be methods for creating, updating and deleting of
77
- # articles/items.
78
- ...
79
- ```
80
-
81
- - [ ] Improve serving of static files:
82
- - [ ] support for compression
83
- - [ ] add `Request#render_static_file(route, fn)
225
+ But in fact they don't make much sense separately, except maybe for the model.
226
+ So we follow Rail's example and implement a code generator. This is especially
227
+ useful for working with controllers. Since the controller code is spread over
228
+ four files, this is not something trivial.
84
229
 
85
230
  ## Missing for a first public release
86
231
 
@@ -153,3 +298,161 @@ end
153
298
  2.5. If docker compose services are running, restart
154
299
  2.6. Otherwise, start services
155
300
  2.7. Verify service is running correctly
301
+
302
+ ## Syntropy Flow - Live Templates
303
+
304
+ The idea is to have something that works like LiveView, but without WebSocket.
305
+ Instead, we'll use SSE in conjunction with a controller URL that provides both
306
+ updates (through SSE), and receives user interaction from the browser with POST
307
+ requests.
308
+
309
+ The compiler needs to be able to compile:
310
+
311
+ - The HTML
312
+ - The initial update tree (statics + dynamics)
313
+ - Updated dynamic values
314
+
315
+ Supposing that the HTML and the initial update tree are rendered at once. Let's
316
+ say we have the following template:
317
+
318
+ ```ruby
319
+ ->(name:) {
320
+ html {
321
+ head { title "My live page" }
322
+ body {
323
+ p "Hello, #{name}!"
324
+ }
325
+ }
326
+ }
327
+ ```
328
+
329
+ Which normally compiles to:
330
+
331
+ ```ruby
332
+ ->(__buffer__, name:) {
333
+ __buffer__
334
+ .<<("<!DOCTYPE html><html><head><title>My live page</title></head><body><p>")
335
+ .<<(ERB::Escape.html_escape(("Hello, #{name}!")))
336
+ .<<("</p></body></html>")
337
+ __buffer__
338
+ }
339
+ ```
340
+
341
+ A live template would look something like:
342
+
343
+ ```ruby
344
+ # __flow_url__ is the URL used for the SSE connection, it includes an id for the
345
+ # specific connection.
346
+ initial = ->(__flow_url__, name:) {
347
+ __html__ = +''
348
+ __static__ = []; __dynamic__ = {}; __dynamic_counter__ = 0;
349
+
350
+ __html__
351
+ .<<(
352
+ s = "<!DOCTYPE html><html><head><title>My live page</title></head><body><p>"
353
+ __static__ << s
354
+ s
355
+ )
356
+ .<<(
357
+ s = ERB::Escape.html_escape(("Hello, #{name}!"))
358
+ __dynamic__[__dynamic_counter__] = s
359
+ __dynamic_counter__ += 1
360
+ s
361
+ )
362
+ .<<(
363
+ s = "</p></body><script src=\"#{__flow_url__}\"></script></html>"
364
+ __static__ << s
365
+ s
366
+ )
367
+ {
368
+ html: __html__,
369
+ update_tree = {
370
+ s: __static__,
371
+ d: __dynamic__
372
+ }
373
+ }
374
+ }
375
+ ```
376
+
377
+ The controller would look something like:
378
+
379
+ ```ruby
380
+ @template = import '/views/hello_world'
381
+
382
+ export flow_controller do
383
+ def setup
384
+ assigns[:name] = 'world'
385
+ assigns[:counter] ||= 1
386
+ @updater = @machine.spin {
387
+ @machine.periodically(3) {
388
+ assigns[:name] = "world (#{assignes[:counter] += 1})"
389
+ }
390
+ }
391
+ end
392
+
393
+ def teardown
394
+ @machine.cancel(@updater)
395
+ end
396
+ end
397
+ ```
398
+
399
+ The flow_controller can handle four kinds of requests:
400
+
401
+ - `GET /hello` - This is the initial render, where it will:
402
+ - generate a unique flow id which will be embedded in a flow url (see below)
403
+ - render `initial` compiled template, and store the update tree in a short
404
+ term hash (which is evacuated periodically for failed connections)
405
+ - respond with the HTML
406
+ - `GET /hello?flow=setup` - The JS payload to start the SSE connection
407
+ - boilerplate code for setting up the SSE connection
408
+ - include inline the initial update tree containing static and dynamic parts
409
+ - `GET /hello?sse&fid=xxxx` - the SSE long-running connection, on which
410
+ updates are sent. The flow id identifies the flow session, which permits
411
+ reconnection in case of comm error. On connection, the controller will emit an
412
+ initial update with the update tree (see below).
413
+
414
+ - `POST /hello?fid=xxxx` - for responding to user interaction.
415
+
416
+ Whenever a value in `assigns` changes, the *update* template is rerendered.
417
+ Here's how the compiled update template looks:
418
+
419
+ ```ruby
420
+ initial = ->(name:) {
421
+ __dynamic__ = {}; __dynamic_counter__ = 0;
422
+
423
+ s = ERB::Escape.html_escape(("Hello, #{name}!"))
424
+ __dynamic__[__dynamic_counter__] = s
425
+ __dynamic_counter__ += 1
426
+
427
+ __dynamic__
428
+ }
429
+ ```
430
+
431
+ So, the initial update for this template will look something like the following:
432
+
433
+ ```json
434
+ {
435
+ s: [
436
+ "<!DOCTYPE html><html><head><title>My live page</title></head><body><p>",
437
+ "</p></body><script src=\"/hello?sse&fid=12345678\"></script></html>"
438
+ ],
439
+ d: {
440
+ 0: "world"
441
+ }
442
+ }
443
+ ```
444
+
445
+ Subsequent updates will look like:
446
+
447
+ ```json
448
+ {
449
+ d: {
450
+ 0: "world (10:27:32)"
451
+ }
452
+ }
453
+ ```
454
+
455
+ On the client side, each time an update is received on the SSE connection, it
456
+ includes the dynamic values. Those are diffed against the previous values, the
457
+ entire page HTML is zipped from the static and dynamic parts, and then morphdom
458
+ is used to patch the DOM with the relevant changes.
@@ -1,11 +1,11 @@
1
1
  export self
2
2
 
3
3
  def connection_pool
4
- @connection_pool ||= Storage::ConnectionPool.new(@machine, @env[:config][:storage][:path], 4)
4
+ @connection_pool ||= ConnectionPool.new(@machine, @env[:config][:storage][:path], 4)
5
5
  end
6
6
 
7
7
  def schema
8
- Storage::Schema.new(module_loader: @module_loader, schema_root: '_schema')
8
+ Schema.new(module_loader: @module_loader, schema_root: '_schema')
9
9
  end
10
10
 
11
11
  def migrate!
@@ -1,5 +1,5 @@
1
1
  export({
2
2
  storage: {
3
- path: ENV['DATABASE_PATH'] || Syntropy.tmp_path('test-db')
3
+ path: ENV['DATABASE_PATH'] || tmp_path('test-db')
4
4
  }
5
5
  })
@@ -1,5 +1,5 @@
1
1
  export({
2
2
  storage: {
3
- path: ENV['DATABASE_PATH'] || Syntropy.tmp_path('test-db')
3
+ path: ENV['DATABASE_PATH'] || tmp_path('test-db')
4
4
  }
5
5
  })
@@ -1,9 +1,13 @@
1
1
  Storage = import '/_lib/storage'
2
2
 
3
- class PostStore < Syntropy::Storage::Store
3
+ class PostStore
4
+ def initialize(cp)
5
+ @cp = cp
6
+ end
7
+
4
8
  # @return [Integer] post id
5
9
  def create(title, body)
6
- query_single_value <<~SQL, title:, body:
10
+ @cp.query_single_value <<~SQL, title:, body:
7
11
  insert into posts (title, body)
8
12
  values (:title, :body)
9
13
  returning id;
@@ -12,7 +16,7 @@ class PostStore < Syntropy::Storage::Store
12
16
 
13
17
  # @return [void]
14
18
  def update(id, title, body)
15
- execute <<~SQL, id:, title:, body:
19
+ @cp.execute <<~SQL, id:, title:, body:
16
20
  update posts
17
21
  set title = :title, body = :body
18
22
  where id = :id
@@ -21,7 +25,7 @@ class PostStore < Syntropy::Storage::Store
21
25
 
22
26
  # @return [void]
23
27
  def delete(id)
24
- execute <<~SQL, id:
28
+ @cp.execute <<~SQL, id:
25
29
  delete from posts
26
30
  where id = :id
27
31
  SQL
@@ -29,7 +33,7 @@ class PostStore < Syntropy::Storage::Store
29
33
 
30
34
  # return [Hash]
31
35
  def get(id)
32
- query_single_row <<~SQL, id:
36
+ @cp.query_single_row <<~SQL, id:
33
37
  select id, title, body
34
38
  from posts
35
39
  where id = :id
@@ -38,7 +42,7 @@ class PostStore < Syntropy::Storage::Store
38
42
 
39
43
  # return [Array<Hash>]
40
44
  def get_all
41
- query <<~SQL
45
+ @cp.query <<~SQL
42
46
  select id, title, body
43
47
  from posts
44
48
  order by id desc
@@ -1,11 +1,11 @@
1
1
  export self
2
2
 
3
3
  def connection_pool
4
- @connection_pool ||= Storage::ConnectionPool.new(@machine, @env[:config][:storage][:path], 4)
4
+ @connection_pool ||= ConnectionPool.new(@machine, @env[:config][:storage][:path], 4)
5
5
  end
6
6
 
7
7
  def schema
8
- Storage::Schema.new(module_loader: @module_loader, schema_root: '_schema')
8
+ Schema.new(module_loader: @module_loader, schema_root: '_schema')
9
9
  end
10
10
 
11
11
  def migrate!
@@ -23,7 +23,7 @@ def post(req)
23
23
  raise BadRequestError, "Failed to update post" if updated != 1
24
24
 
25
25
  req.flash[:notice] = 'Post was successfully updated.'
26
- req.redirect "/posts/#{id}", Syntropy::HTTP::SEE_OTHER
26
+ req.redirect "/posts/#{id}", HTTP::SEE_OTHER
27
27
  end
28
28
 
29
29
  def delete(req)
@@ -33,7 +33,7 @@ def delete(req)
33
33
  raise BadRequestError, 'Failed to delete post' if deleted != 1
34
34
 
35
35
  req.flash[:notice] = 'Post was successfully destroyed.'
36
- req.redirect '..', Syntropy::HTTP::SEE_OTHER
36
+ req.redirect '..', HTTP::SEE_OTHER
37
37
  end
38
38
 
39
39
  @template = @layout.apply { |post:, req:, **props|
@@ -1,5 +1,5 @@
1
1
  export({
2
2
  storage: {
3
- path: ENV['DATABASE_PATH'] || Syntropy.tmp_path('test-db')
3
+ path: ENV['DATABASE_PATH'] || tmp_path('test-db')
4
4
  }
5
5
  })