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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/TODO.md +334 -31
- data/cmd/new/template/app/_lib/storage.rb +2 -2
- data/cmd/new/template/config/test.rb +1 -1
- data/examples/agent/config/test.rb +1 -1
- data/examples/blog/app/_lib/posts.rb +10 -6
- data/examples/blog/app/_lib/storage.rb +2 -2
- data/examples/blog/app/posts/[id]/index.rb +2 -2
- data/examples/blog/config/test.rb +1 -1
- data/lib/syntropy/collection.rb +206 -0
- data/lib/syntropy/connection_pool.rb +118 -0
- data/lib/syntropy/controller_extensions.rb +12 -0
- data/lib/syntropy/http/server_connection.rb +18 -30
- data/lib/syntropy/json_api.rb +3 -12
- data/lib/syntropy/kv_store.rb +57 -0
- data/lib/syntropy/logger.rb +24 -47
- data/lib/syntropy/markdown.rb +1 -1
- data/lib/syntropy/module_loader.rb +53 -14
- data/lib/syntropy/schema.rb +95 -0
- data/lib/syntropy/storage.rb +42 -4
- data/lib/syntropy/test.rb +1 -1
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +2 -1
- data/syntropy.gemspec +2 -2
- data/test/fixtures/app/_lib/dep_invalidate.rb +5 -0
- data/test/fixtures/collection/basic/_articles/2026-06-30-foo.md +6 -0
- data/test/fixtures/collection/basic/_articles/2026-07-13-bar.md +6 -0
- data/test/fixtures/collection/basic/_articles/2026-08-07-baz.md +6 -0
- data/test/fixtures/collection/basic/index+.rb +23 -0
- data/test/fixtures/collection/docs/_pages/01-foo/01-foo.md +4 -0
- data/test/fixtures/collection/docs/_pages/01-foo/02-bar.md +4 -0
- data/test/fixtures/collection/docs/_pages/01-foo/_index.yml +1 -0
- data/test/fixtures/collection/docs/_pages/02-bar/01-bar.md +4 -0
- data/test/fixtures/collection/docs/_pages/02-bar/02-baz.md +4 -0
- data/test/fixtures/collection/docs/_pages/02-bar/_index.yml +1 -0
- data/test/fixtures/collection/docs/index+.rb +16 -0
- data/test/test_app.rb +7 -7
- data/test/test_cmd_new.rb +38 -0
- data/test/test_collection.rb +164 -0
- data/test/test_connection_pool.rb +79 -3
- data/test/test_http_server_connection.rb +6 -6
- data/test/test_json_api.rb +4 -4
- data/test/test_kv_store.rb +6 -31
- data/test/test_markdown.rb +3 -3
- data/test/test_module_loader.rb +15 -0
- data/test/test_schema.rb +4 -4
- data/test/test_storage.rb +103 -0
- metadata +24 -11
- data/lib/syntropy/storage/connection_pool.rb +0 -106
- data/lib/syntropy/storage/kv_store.rb +0 -68
- data/lib/syntropy/storage/prepared_query.rb +0 -36
- data/lib/syntropy/storage/schema.rb +0 -92
- data/lib/syntropy/storage/store.rb +0 -33
- data/test/test_store.rb +0 -24
|
@@ -38,6 +38,7 @@ module Syntropy
|
|
|
38
38
|
@loading = Set.new
|
|
39
39
|
@lock = UM::Mutex.new
|
|
40
40
|
@lock_holder = nil
|
|
41
|
+
@invalidation_patterns = Hash.new { |h, k| h[k] = [] }
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
# Loads a module (if not already loaded) and returns its export value.
|
|
@@ -80,12 +81,27 @@ module Syntropy
|
|
|
80
81
|
ref = @fn_map[fn]
|
|
81
82
|
invalidate_ref(ref) if ref
|
|
82
83
|
invalidate_collection_modules
|
|
84
|
+
invalidate_by_pattern(fn)
|
|
83
85
|
end
|
|
84
86
|
end
|
|
85
87
|
|
|
88
|
+
# Adds an invalidation pattern. When a ref has been invalidated (using
|
|
89
|
+
# #invalidate_fn), the loader will check for matches against any
|
|
90
|
+
# invalidation patterns, and will invalidate any corresponding refs. This
|
|
91
|
+
# mechanism allows hot reload for collections.
|
|
92
|
+
#
|
|
93
|
+
# @param ref [String] module ref
|
|
94
|
+
# @param pattern [String] pattern normalized to app root
|
|
95
|
+
# @return [void]
|
|
96
|
+
def invalidate_on_file_change(ref, pattern)
|
|
97
|
+
# pattern should be absolute in order to match against fn
|
|
98
|
+
pattern = File.join(@app_root, pattern)
|
|
99
|
+
@invalidation_patterns[pattern] << ref
|
|
100
|
+
end
|
|
101
|
+
|
|
86
102
|
private
|
|
87
103
|
|
|
88
|
-
# Synchronizes access to the module loader state
|
|
104
|
+
# Synchronizes access to the module loader state.
|
|
89
105
|
#
|
|
90
106
|
# @return [any] block return value
|
|
91
107
|
def lock
|
|
@@ -107,6 +123,11 @@ module Syntropy
|
|
|
107
123
|
# @param ref [String] module reference
|
|
108
124
|
# @return [void]
|
|
109
125
|
def invalidate_ref(ref)
|
|
126
|
+
if ref == '/'
|
|
127
|
+
invalidate_ref('/index')
|
|
128
|
+
invalidate_ref('/index+')
|
|
129
|
+
end
|
|
130
|
+
|
|
110
131
|
entry = @modules.delete(ref)
|
|
111
132
|
return if !entry
|
|
112
133
|
|
|
@@ -125,6 +146,19 @@ module Syntropy
|
|
|
125
146
|
refs.each { invalidate_ref(it) }
|
|
126
147
|
end
|
|
127
148
|
|
|
149
|
+
# Iterates through invalidation patterns and for each match invalidates the
|
|
150
|
+
# corresponding refs.
|
|
151
|
+
#
|
|
152
|
+
# @param fn [String] filename
|
|
153
|
+
# @return [void]
|
|
154
|
+
def invalidate_by_pattern(fn)
|
|
155
|
+
invalidated = Set.new
|
|
156
|
+
@invalidation_patterns.each do |pat, refs|
|
|
157
|
+
refs.each { invalidated << it } if File.fnmatch(pat, fn)
|
|
158
|
+
end
|
|
159
|
+
invalidated.each { invalidate_ref(it) }
|
|
160
|
+
end
|
|
161
|
+
|
|
128
162
|
# Registers reverse dependencies for the given module reference.
|
|
129
163
|
#
|
|
130
164
|
# @param ref [String] module reference
|
|
@@ -165,7 +199,7 @@ module Syntropy
|
|
|
165
199
|
def do_load_module(ref, fn, raise_on_missing_export:)
|
|
166
200
|
@loading << ref
|
|
167
201
|
@fn_map[fn] = ref
|
|
168
|
-
code =
|
|
202
|
+
code = @machine.file_read(fn)
|
|
169
203
|
env = @env.merge(module_loader: self, ref: clean_ref(ref))
|
|
170
204
|
mod = Syntropy::ModuleContext.new(env, code, fn, @extensions)
|
|
171
205
|
add_dependencies(ref, mod.__dependencies__)
|
|
@@ -187,18 +221,6 @@ module Syntropy
|
|
|
187
221
|
@loading.delete(ref)
|
|
188
222
|
end
|
|
189
223
|
|
|
190
|
-
# Returns a file's content.
|
|
191
|
-
#
|
|
192
|
-
# @param fn [String] filename
|
|
193
|
-
# @return [String] file content
|
|
194
|
-
def read_file(fn)
|
|
195
|
-
@machine.open(fn, UM::O_RDONLY) { |fd|
|
|
196
|
-
buf = +''
|
|
197
|
-
res = @machine.read(fd, buf, 1 << 20)
|
|
198
|
-
buf
|
|
199
|
-
}
|
|
200
|
-
end
|
|
201
|
-
|
|
202
224
|
# Cleans up a module reference specifier, turning /index into /
|
|
203
225
|
#
|
|
204
226
|
# @param ref [String] input ref
|
|
@@ -270,6 +292,11 @@ module Syntropy
|
|
|
270
292
|
@module_loader = env[:module_loader]
|
|
271
293
|
@app = env[:app]
|
|
272
294
|
@ref = env[:ref]
|
|
295
|
+
|
|
296
|
+
mp = env[:mount_path]
|
|
297
|
+
@url = mp && (@ref == '/' ? mp : File.join(mp, @ref))
|
|
298
|
+
@fn = fn
|
|
299
|
+
|
|
273
300
|
@logger = env[:logger]
|
|
274
301
|
@__dependencies__ = []
|
|
275
302
|
singleton_class.const_set(:MODULE, self)
|
|
@@ -320,6 +347,18 @@ module Syntropy
|
|
|
320
347
|
self
|
|
321
348
|
end
|
|
322
349
|
|
|
350
|
+
# Causes the module to be invalidated (reloaded) when a change is detected
|
|
351
|
+
# in any file that matches the given pattern. The pattern is relative to the
|
|
352
|
+
# current module's ref. The pattern normally includes a wildcard:
|
|
353
|
+
#
|
|
354
|
+
# invalidate_on_file_change('_articles/*')
|
|
355
|
+
#
|
|
356
|
+
# @param pattern [String] pattern in relative path
|
|
357
|
+
# @return [void]
|
|
358
|
+
def invalidate_on_file_change(pattern)
|
|
359
|
+
@module_loader.invalidate_on_file_change(@ref, normalize_import_ref(pattern))
|
|
360
|
+
end
|
|
361
|
+
|
|
323
362
|
# Normalize an import reference, turning a relative path into an absolute one.
|
|
324
363
|
#
|
|
325
364
|
# @param ref [String] input ref
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syntropy
|
|
4
|
+
class Schema
|
|
5
|
+
def initialize(module_loader: nil, schema_root: '_schema', &)
|
|
6
|
+
@migrations = {}
|
|
7
|
+
@module_loader = module_loader
|
|
8
|
+
@schema_root = schema_root
|
|
9
|
+
load_schema_from_modules if @module_loader
|
|
10
|
+
run_schema_dsl(&) if block_given?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def apply(connection_pool)
|
|
14
|
+
execute_migrations(connection_pool)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def current_version(connection_pool)
|
|
18
|
+
connection_pool.with_db do |db|
|
|
19
|
+
get_schema_version(db)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def load_schema_from_modules
|
|
26
|
+
modules = @module_loader.list(@schema_root)
|
|
27
|
+
modules.each do |name|
|
|
28
|
+
@migrations[File.basename(name)] = @module_loader.load(name)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class SchemaDSLRunner
|
|
33
|
+
def initialize(migrations)
|
|
34
|
+
@migrations = migrations
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run(&)
|
|
38
|
+
instance_eval(&)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def initial(&block)
|
|
44
|
+
@migrations['0000'] = block
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def version(key, &block)
|
|
48
|
+
@migrations[key] = block
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def run_schema_dsl(&)
|
|
53
|
+
SchemaDSLRunner.new(@migrations).run(&)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def execute_migrations(connection_pool)
|
|
57
|
+
connection_pool.with_db do |db|
|
|
58
|
+
current_version = get_schema_version(db)
|
|
59
|
+
migrations_keys = @migrations.keys.sort
|
|
60
|
+
migrations_keys.select! { it > current_version } if current_version
|
|
61
|
+
|
|
62
|
+
migrations_keys.each do |key|
|
|
63
|
+
db.transaction do
|
|
64
|
+
@migrations[key].(db)
|
|
65
|
+
set_schema_version(db, key)
|
|
66
|
+
current_version = key
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
current_version
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def get_schema_version(db)
|
|
75
|
+
db.execute <<~SQL
|
|
76
|
+
create table if not exists __syntropy_schema__(
|
|
77
|
+
k text primary key,
|
|
78
|
+
v text
|
|
79
|
+
);
|
|
80
|
+
SQL
|
|
81
|
+
db.query_single_splat <<~SQL
|
|
82
|
+
select v from __syntropy_schema__
|
|
83
|
+
where k = 'version'
|
|
84
|
+
SQL
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def set_schema_version(db, version)
|
|
88
|
+
db.execute <<~SQL, version
|
|
89
|
+
insert into __syntropy_schema__ (k, v)
|
|
90
|
+
values ('version', $1)
|
|
91
|
+
on conflict(k) do update set v = $1
|
|
92
|
+
SQL
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
data/lib/syntropy/storage.rb
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require '
|
|
4
|
-
require 'syntropy/
|
|
5
|
-
require 'syntropy/
|
|
6
|
-
require 'syntropy/
|
|
3
|
+
require 'extralite'
|
|
4
|
+
require 'syntropy/connection_pool'
|
|
5
|
+
require 'syntropy/schema'
|
|
6
|
+
require 'syntropy/kv_store'
|
|
7
|
+
|
|
8
|
+
module Syntropy
|
|
9
|
+
class Storage
|
|
10
|
+
attr_reader :connection_pool, :schema
|
|
11
|
+
|
|
12
|
+
def initialize(machine, module_loader, config)
|
|
13
|
+
@machine = machine
|
|
14
|
+
@module_loader = module_loader
|
|
15
|
+
@config = config
|
|
16
|
+
|
|
17
|
+
raise Syntropy::Error, 'Missing storage config' if !config
|
|
18
|
+
raise Syntropy::Error, 'Missing storage config' if !config[:path]
|
|
19
|
+
|
|
20
|
+
@connection_pool ||= ConnectionPool.new(
|
|
21
|
+
@machine,
|
|
22
|
+
config[:path],
|
|
23
|
+
config[:concurrency] || 4
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
@schema = Schema.new(
|
|
27
|
+
module_loader: @module_loader,
|
|
28
|
+
schema_root: @config[:schema_root] || '_schema'
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def query(*, **, &)
|
|
33
|
+
connection_pool.with_db { it.query(*, **, &) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def query_single_value(*, **, &)
|
|
37
|
+
connection_pool.with_db { it.query_splat(*, **, &) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def execute(*, **)
|
|
41
|
+
connection_pool.with_db { it.execute(*, **) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/syntropy/test.rb
CHANGED
|
@@ -235,7 +235,7 @@ module Syntropy
|
|
|
235
235
|
# @return [any] parsed JSON object
|
|
236
236
|
def response_json
|
|
237
237
|
raise if response_content_type != 'application/json'
|
|
238
|
-
JSON.parse(response_body)
|
|
238
|
+
JSON.parse(response_body, symbolize_names: true)
|
|
239
239
|
end
|
|
240
240
|
|
|
241
241
|
# Returns the response content MIME type.
|
data/lib/syntropy/version.rb
CHANGED
data/lib/syntropy.rb
CHANGED
|
@@ -17,6 +17,7 @@ require 'syntropy/papercraft_extensions'
|
|
|
17
17
|
require 'syntropy/routing_tree'
|
|
18
18
|
require 'syntropy/json_api'
|
|
19
19
|
require 'syntropy/side_run'
|
|
20
|
+
require 'syntropy/collection'
|
|
20
21
|
require 'syntropy/version'
|
|
21
22
|
|
|
22
23
|
# Syntropy is a web framework for building web apps in Ruby. Syntropy uses
|
|
@@ -86,7 +87,7 @@ module Syntropy
|
|
|
86
87
|
logger: nil,
|
|
87
88
|
machine: UM.new(size: 8)
|
|
88
89
|
)
|
|
89
|
-
loader = ModuleLoader.new(loader_env)
|
|
90
|
+
loader = ModuleLoader.new(loader_env, extensions: ControllerExtensions)
|
|
90
91
|
if (config = loader.load(env[:mode], raise_on_missing_export: false))
|
|
91
92
|
env[:config] = config
|
|
92
93
|
end
|
data/syntropy.gemspec
CHANGED
|
@@ -21,9 +21,9 @@ Gem::Specification.new do |s|
|
|
|
21
21
|
s.required_ruby_version = '>= 3.4'
|
|
22
22
|
s.executables = ['syntropy']
|
|
23
23
|
|
|
24
|
-
s.add_dependency 'extralite', '~>3.
|
|
24
|
+
s.add_dependency 'extralite', '~>3.1.3'
|
|
25
25
|
s.add_dependency 'papercraft', '~>3.2.2'
|
|
26
|
-
s.add_dependency 'uringmachine', '~>1.0.
|
|
26
|
+
s.add_dependency 'uringmachine', '~>1.0.6'
|
|
27
27
|
|
|
28
28
|
s.add_dependency 'json'
|
|
29
29
|
s.add_dependency 'base64'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
begin
|
|
2
|
+
@articles = collection('_articles', url_base: @url)
|
|
3
|
+
rescue => e
|
|
4
|
+
p e
|
|
5
|
+
p e.backtrace
|
|
6
|
+
exit!
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
export ->(req) do
|
|
10
|
+
if req.path == @url
|
|
11
|
+
list = @articles.list.map { it[:ref] }
|
|
12
|
+
req.respond_json(list)
|
|
13
|
+
elsif (article = @articles.get(req.path))
|
|
14
|
+
o = article.slice(:fn, :ref, :url, :type, :attributes, :body)
|
|
15
|
+
req.respond_json(o)
|
|
16
|
+
else
|
|
17
|
+
raise Error.not_found if !article
|
|
18
|
+
end
|
|
19
|
+
rescue => e
|
|
20
|
+
p e
|
|
21
|
+
p e.backtrace
|
|
22
|
+
exit!
|
|
23
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
title: "FooFoo"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
title: "BarBar"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
@pages = collection('_pages', url_base: @url)
|
|
2
|
+
|
|
3
|
+
export ->(req) do
|
|
4
|
+
if req.path == @url
|
|
5
|
+
list = @articles.list.map { it[:ref] }
|
|
6
|
+
req.respond_json(list)
|
|
7
|
+
elsif (page = @pages.get(req.path))
|
|
8
|
+
if page[:type] == :directory
|
|
9
|
+
req.respond_json(page.slice(:type, :title))
|
|
10
|
+
else
|
|
11
|
+
req.respond_json(page.slice(:type, :title, :body))
|
|
12
|
+
end
|
|
13
|
+
else
|
|
14
|
+
raise Error.not_found if !article
|
|
15
|
+
end
|
|
16
|
+
end
|
data/test/test_app.rb
CHANGED
|
@@ -56,28 +56,28 @@ class AppTest < Minitest::Test
|
|
|
56
56
|
assert_equal HTTP::NOT_FOUND, req.response_status
|
|
57
57
|
|
|
58
58
|
req = @test_harness.request(':method' => 'GET', ':path' => '/test/api?q=get')
|
|
59
|
-
assert_equal({
|
|
59
|
+
assert_equal({ status: 'OK', response: 0 }, req.response_json)
|
|
60
60
|
|
|
61
61
|
req = @test_harness.request(':method' => 'POST', ':path' => '/test/api?q=get')
|
|
62
62
|
assert_equal HTTP::METHOD_NOT_ALLOWED, req.response_status
|
|
63
|
-
assert_equal({
|
|
63
|
+
assert_equal({ status: 'Error', message: 'Method not allowed' }, req.response_json)
|
|
64
64
|
|
|
65
65
|
req = @test_harness.request(':method' => 'GET', ':path' => '/test/api/foo?q=get')
|
|
66
|
-
assert_equal({
|
|
66
|
+
assert_equal({ status: 'OK', response: 0 }, req.response_json)
|
|
67
67
|
|
|
68
68
|
req = @test_harness.request(':method' => 'POST', ':path' => '/test/api?q=incr')
|
|
69
|
-
assert_equal({
|
|
69
|
+
assert_equal({ status: 'OK', response: 1 }, req.response_json)
|
|
70
70
|
|
|
71
71
|
req = @test_harness.request(':method' => 'GET', ':path' => '/test/api?q=incr')
|
|
72
72
|
assert_equal HTTP::METHOD_NOT_ALLOWED, req.response_status
|
|
73
|
-
assert_equal({
|
|
73
|
+
assert_equal({ status: 'Error', message: 'Method not allowed' }, req.response_json)
|
|
74
74
|
|
|
75
75
|
req = @test_harness.request(':method' => 'POST', ':path' => '/test/api/foo?q=incr')
|
|
76
|
-
assert_equal({
|
|
76
|
+
assert_equal({ status: 'Error', message: 'Teapot' }, req.response_json)
|
|
77
77
|
assert_equal HTTP::TEAPOT, req.response_status
|
|
78
78
|
|
|
79
79
|
req = @test_harness.request(':method' => 'POST', ':path' => '/test/api/foo/bar?q=incr')
|
|
80
|
-
assert_equal({
|
|
80
|
+
assert_equal({ status: 'Error', message: 'Teapot' }, req.response_json)
|
|
81
81
|
assert_equal HTTP::TEAPOT, req.response_status
|
|
82
82
|
|
|
83
83
|
req = @test_harness.request(':method' => 'GET', ':path' => '/test/bar')
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'helper'
|
|
4
|
+
require 'syntropy/test'
|
|
5
|
+
|
|
6
|
+
class CmdNewTemplateTest < Syntropy::Test
|
|
7
|
+
def env
|
|
8
|
+
{
|
|
9
|
+
mode: 'test',
|
|
10
|
+
app_root: File.join(__dir__, '../cmd/new/template/app'),
|
|
11
|
+
config_root: File.join(__dir__, '../cmd/new/template/config'),
|
|
12
|
+
mount_path: '/'
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def test_cmd_new_template_server
|
|
17
|
+
req = get('/foo')
|
|
18
|
+
assert_equal HTTP::NOT_FOUND, req.response_status
|
|
19
|
+
|
|
20
|
+
req = get('/')
|
|
21
|
+
assert_equal HTTP::OK, req.response_status
|
|
22
|
+
assert_equal 'text/html', req.response_content_type
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_cmd_new_template_storage
|
|
26
|
+
storage = load_module('_lib/storage')
|
|
27
|
+
refute_nil storage
|
|
28
|
+
|
|
29
|
+
cp = storage.connection_pool
|
|
30
|
+
assert_kind_of Syntropy::ConnectionPool, cp
|
|
31
|
+
|
|
32
|
+
schema = storage.schema
|
|
33
|
+
assert_kind_of Syntropy::Schema, schema
|
|
34
|
+
|
|
35
|
+
# migration already ran
|
|
36
|
+
refute_nil schema.current_version(cp)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'helper'
|
|
4
|
+
|
|
5
|
+
class CollectionTest < Minitest::Test
|
|
6
|
+
COLLECTION_ROOT = File.join(__dir__, 'fixtures/collection/basic/_articles')
|
|
7
|
+
|
|
8
|
+
def setup
|
|
9
|
+
@machine = UM.new
|
|
10
|
+
@collection = Syntropy::Collection.new(
|
|
11
|
+
machine: @machine,
|
|
12
|
+
root: COLLECTION_ROOT,
|
|
13
|
+
url_base: '/apps/blog'
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_collection_setup
|
|
18
|
+
assert_equal COLLECTION_ROOT, @collection.root_dir
|
|
19
|
+
assert_equal '/apps/blog', @collection.url_base
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def test_collection_list
|
|
23
|
+
list = @collection.list
|
|
24
|
+
assert_kind_of Array, list
|
|
25
|
+
assert_equal 3, list.size
|
|
26
|
+
|
|
27
|
+
foo = list[0]
|
|
28
|
+
assert_kind_of Hash, foo
|
|
29
|
+
assert_equal({
|
|
30
|
+
fn: File.join(COLLECTION_ROOT, '2026-06-30-foo.md'),
|
|
31
|
+
ref: '2026-06-30-foo',
|
|
32
|
+
url: '/apps/blog/2026-06-30-foo',
|
|
33
|
+
type: :markdown,
|
|
34
|
+
attributes: {
|
|
35
|
+
date: Date.parse('2026-06-30'),
|
|
36
|
+
title: 'FooFoo',
|
|
37
|
+
category: ['a', 'b'],
|
|
38
|
+
author: 'Bar Baz',
|
|
39
|
+
},
|
|
40
|
+
body: 'Foo foo foo.'
|
|
41
|
+
}, foo.slice(:fn, :ref, :url, :type, :attributes, :body))
|
|
42
|
+
|
|
43
|
+
bar = list[1]
|
|
44
|
+
assert_kind_of Hash, foo
|
|
45
|
+
assert_equal({
|
|
46
|
+
fn: File.join(COLLECTION_ROOT, '2026-07-13-bar.md'),
|
|
47
|
+
ref: '2026-07-13-bar',
|
|
48
|
+
url: '/apps/blog/2026-07-13-bar',
|
|
49
|
+
type: :markdown,
|
|
50
|
+
attributes: {
|
|
51
|
+
date: Date.parse('2026-07-13'),
|
|
52
|
+
title: 'BarBar',
|
|
53
|
+
category: ['b', 'c'],
|
|
54
|
+
author: 'Baz Foo',
|
|
55
|
+
},
|
|
56
|
+
body: 'Bar bar bar.'
|
|
57
|
+
}, bar.slice(:fn, :ref, :url, :type, :attributes, :body))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def test_collection_get
|
|
61
|
+
assert_nil @collection.get('/')
|
|
62
|
+
assert_nil @collection.get('/apps/blog')
|
|
63
|
+
|
|
64
|
+
item = @collection.get('/apps/blog/2026-06-30-foo')
|
|
65
|
+
assert_kind_of Hash, item
|
|
66
|
+
assert_equal({
|
|
67
|
+
fn: File.join(COLLECTION_ROOT, '2026-06-30-foo.md'),
|
|
68
|
+
ref: '2026-06-30-foo',
|
|
69
|
+
url: '/apps/blog/2026-06-30-foo',
|
|
70
|
+
type: :markdown,
|
|
71
|
+
attributes: {
|
|
72
|
+
date: Date.parse('2026-06-30'),
|
|
73
|
+
title: 'FooFoo',
|
|
74
|
+
category: ['a', 'b'],
|
|
75
|
+
author: 'Bar Baz',
|
|
76
|
+
},
|
|
77
|
+
body: 'Foo foo foo.'
|
|
78
|
+
}, item.slice(:fn, :ref, :url, :type, :attributes, :body))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class CollectionAppTest < Syntropy::Test
|
|
83
|
+
self.env = {
|
|
84
|
+
app_root: File.join(__dir__, 'fixtures/collection/basic'),
|
|
85
|
+
mount_path: '/blog'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
def test_collection_app_controller
|
|
89
|
+
req = get('/blog/2026-06-30-foo')
|
|
90
|
+
assert_equal HTTP::OK, req.response_status
|
|
91
|
+
|
|
92
|
+
assert_equal ['/index+'], app.module_loader.modules.keys
|
|
93
|
+
|
|
94
|
+
app.module_loader.invalidate_fn(File.join(env[:app_root], '_articles/2026-06-30-foo.md'))
|
|
95
|
+
assert_equal [], app.module_loader.modules.keys
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
class NestedCollectionTest < Syntropy::Test
|
|
100
|
+
self.env = {
|
|
101
|
+
app_root: File.join(__dir__, 'fixtures/collection/docs'),
|
|
102
|
+
mount_path: '/docs'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
def setup
|
|
106
|
+
super
|
|
107
|
+
@collection = Syntropy::Collection.new(
|
|
108
|
+
machine: @machine,
|
|
109
|
+
root: File.join(env[:app_root], '_pages'),
|
|
110
|
+
url_base: '/docs'
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def test_nested_collection_list
|
|
115
|
+
list = @collection.list
|
|
116
|
+
assert_equal 4, list.size
|
|
117
|
+
|
|
118
|
+
assert_equal 'FooFoo Foo', list[0][:attributes][:title]
|
|
119
|
+
assert_equal 'FooFoo Bar', list[1][:attributes][:title]
|
|
120
|
+
assert_equal 'BarBar Bar', list[2][:attributes][:title]
|
|
121
|
+
assert_equal 'BarBar Baz', list[3][:attributes][:title]
|
|
122
|
+
|
|
123
|
+
assert_nil list[0][:prev]
|
|
124
|
+
assert_equal list[1], list[0][:next]
|
|
125
|
+
|
|
126
|
+
assert_equal list[0], list[1][:prev]
|
|
127
|
+
assert_equal list[2], list[1][:next]
|
|
128
|
+
|
|
129
|
+
assert_equal list[1], list[2][:prev]
|
|
130
|
+
assert_equal list[3], list[2][:next]
|
|
131
|
+
|
|
132
|
+
assert_equal list[2], list[3][:prev]
|
|
133
|
+
assert_nil list[3][:next]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def test_nested_collection_dirs
|
|
137
|
+
root_dir = File.join(env[:app_root], '_pages')
|
|
138
|
+
root = @collection.root
|
|
139
|
+
assert_kind_of Hash, root
|
|
140
|
+
|
|
141
|
+
assert_equal({
|
|
142
|
+
fn: nil,
|
|
143
|
+
ref: '',
|
|
144
|
+
url: '/docs',
|
|
145
|
+
type: :directory,
|
|
146
|
+
}, root.slice(:fn, :ref, :url, :type))
|
|
147
|
+
|
|
148
|
+
assert_equal 2, root[:items].size
|
|
149
|
+
|
|
150
|
+
assert_equal({
|
|
151
|
+
fn: File.join(root_dir, '01-foo/_index.yml'),
|
|
152
|
+
ref: '01-foo',
|
|
153
|
+
url: '/docs/01-foo',
|
|
154
|
+
type: :directory
|
|
155
|
+
}, root[:items][0].slice(:fn, :ref, :url, :type))
|
|
156
|
+
|
|
157
|
+
assert_equal({
|
|
158
|
+
fn: File.join(root_dir, '02-bar/_index.yml'),
|
|
159
|
+
ref: '02-bar',
|
|
160
|
+
url: '/docs/02-bar',
|
|
161
|
+
type: :directory
|
|
162
|
+
}, root[:items][1].slice(:fn, :ref, :url, :type))
|
|
163
|
+
end
|
|
164
|
+
end
|