asset_db 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d71f04f4789fbd8bf52decfa901cfc6c68df8e04e8d708bba64e7d5fd95f0fbc
4
- data.tar.gz: 57c9bf86e113b875eb3829f11c89db95f55c35262b2efa09a99cdf276e2b1301
3
+ metadata.gz: b42a206aff25757d375733d7848c357a4ddccf98d34decb8cf56684f9538ce7d
4
+ data.tar.gz: b6e77803fa31752f932bb9d7cd40e85affbf8b96665756440aa4f936678701a9
5
5
  SHA512:
6
- metadata.gz: 456ce8e2f6f7db0c3c83b30ab6b3130bf10edd30d3621638c58eb133bb4cc0ba8488b103463d08f285873371d365ba8240cde48104d5fee152819b25f80dda64
7
- data.tar.gz: 4496834d507fc071851ce7ae6110f5d926c7f8f17d543a969661c96c2e58706d43f3f7594eee93684c87e4c5065719346b13ada0d46ac5abcc5c81a20a529a59
6
+ metadata.gz: 15e1d12a82c2d41cf53ccf83794161e5113e8f6ecc5b8da9ef82808fbb4d137bc9ae43f36709f956f7ef441593e7116e04dd0223aab30ce71d0d98ad9b944250
7
+ data.tar.gz: c86c0642cb23bd8ecd29f625bb0b57740615d85e572c9ca97eb7c01f30950a7462f470519c9cc48f865d8ce20aa6e0240c50233e695537a2675fdaff9788aa01
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module AssetDB
6
+ class Asset
7
+ attr_reader :type, :id, :url, :metadata, :group, :package
8
+
9
+ def initialize(type:, group:, package:, url:, metadata: nil, id: url)
10
+ @type = type.to_sym
11
+ @url = url.to_s.freeze
12
+ @metadata = metadata
13
+ @id = id.to_s.freeze
14
+ @package = package
15
+ @group = group
16
+ end
17
+
18
+ def ==(other)
19
+ other.is_a?(Asset) && other.id == id
20
+ end
21
+ alias eql? ==
22
+
23
+ def hash
24
+ id.hash
25
+ end
26
+
27
+ def protocol_url?
28
+ @protocol ||= url =~ /\A[A-Za-z][A-Za-z0-9+\-.]*:\/\//
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module AssetDB
6
+ class Database
7
+ CONFIG_RESERVED = %w[types basepath folders].freeze
8
+
9
+ attr_reader :asset_types, :base_path, :groups, :resolver
10
+ attr_accessor :separator
11
+
12
+ def initialize(asset_types: nil, base_path: nil)
13
+ @asset_types = (asset_types&.map(&:to_sym) || %i[css js]).freeze
14
+ @base_path = (base_path || '').dup
15
+ @separator = nil
16
+ @groups = {} # {id ⇒ Group}
17
+ @resolver = Resolver.new(self)
18
+ end
19
+
20
+ # DSL helpers
21
+ G_FOLDER_DEFAULT = Group::FOLDER_DEFAULT
22
+ def group(id, folder: G_FOLDER_DEFAULT, &block)
23
+ g = (@groups[id.to_s] ||= Group.new(self, id, folder: folder))
24
+ yield g if block
25
+ g
26
+ end
27
+
28
+ def groups
29
+ @groups.values
30
+ end
31
+ def asset_types
32
+ @asset_types
33
+ end
34
+
35
+ # Strict fetch for resolver
36
+ def group!(id)
37
+ @groups[id.to_s] or raise Errors::UnknownGroupError, id
38
+ end
39
+
40
+ def package!(g_id, p_id)
41
+ group!(g_id).instance_variable_get(:@packages_hash)[p_id.to_s] or
42
+ raise Errors::UnknownPackageError, "#{g_id}/#{p_id}"
43
+ end
44
+
45
+ # --------------- URL expansion ---------------
46
+ def build_url(asset)
47
+ return asset.url if asset.protocol_url?
48
+ return ensure_root_slash(asset.url) if asset.url.start_with?('/')
49
+
50
+ group = asset.group
51
+ package = asset.package
52
+ path = @base_path.dup
53
+ unless path.empty?
54
+ path.gsub!(':type', asset.type.to_s)
55
+ path.gsub!(':group', group.folder_segment.to_s)
56
+ path.gsub!(':package', package.folder_segment.to_s)
57
+ end
58
+
59
+ ensure_root_slash(File.join(path, asset.url))
60
+ end
61
+
62
+ def ensure_root_slash(pth)
63
+ ('/' + pth.gsub(%r{^/+}, '')).gsub(%r{/{2,}}, '/')
64
+ end
65
+
66
+ # --------------- Config loader ---------------
67
+ def self.from_config(cfg)
68
+ cfg = cfg.transform_keys(&:to_s)
69
+ db = new(asset_types: cfg['types'], base_path: cfg['basepath'])
70
+
71
+ folders_map = (cfg['folders'] || {}).transform_keys(&:to_s).dup
72
+ if sep = folders_map.delete('separator')
73
+ db.separator = sep.to_s
74
+ end
75
+
76
+ cfg.each do |g_id, g_spec|
77
+ next if CONFIG_RESERVED.include?(g_id)
78
+
79
+ raise Errors::InvalidIdentifierError,
80
+ "group name ‘#{g_id}’ conflicts with asset type" \
81
+ if db.asset_types.include?(g_id.to_sym)
82
+
83
+ # ---------- GROUP ---------- #
84
+ if (folders_map.key? g_id)
85
+ g = db.group(g_id, folder: folders_map[g_id]) # explicit override (can be nil/false/empty)
86
+ else
87
+ g = db.group(g_id) # default ⇒ id
88
+ end
89
+ gid = g_id.to_s
90
+
91
+ # ---------- PACKAGES ---------- #
92
+ g_spec.each do |p_id, p_spec|
93
+ p_key = "#{gid}#{db.separator}#{p_id}" # composite key for folder overrides
94
+
95
+ if (folders_map.key? p_key)
96
+ pkg = g.package(p_id, folder: folders_map[p_key]) # explicit override
97
+ else
98
+ pkg = g.package(p_id) # default ⇒ id
99
+ end
100
+
101
+ p_spec.each do |k, v|
102
+ key = k.to_s
103
+ if db.asset_types.include?(key.to_sym) # ASSETS
104
+ Array(v).each { |url| pkg.asset(key, url) }
105
+ else # DEPENDENCIES
106
+ Array(v).each { |target_pkg| pkg.depends_on(target_pkg, group_id: key) }
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ db
113
+ end
114
+
115
+ # Unify any number (≥2) of Package or PackageCollection into one collection
116
+ def unify(*items)
117
+ raise ArgumentError, "unify requires ≥2 packages/collections" if items.size < 2
118
+ merged = items.flat_map do |i|
119
+ case i
120
+ when Resolver::PackageCollection
121
+ i.instance_variable_get(:@packages)
122
+ when Package
123
+ [i]
124
+ else
125
+ raise ArgumentError, "Cannot unify #{i.inspect}"
126
+ end
127
+ end
128
+ Resolver::PackageCollection.new(self, merged)
129
+ end
130
+
131
+ def validate_identifier!(name)
132
+ if separator && name.to_s.include?(separator)
133
+ raise Errors::InvalidIdentifierError, "'#{separator}' forbidden in identifier #{name.inspect}"
134
+ end
135
+ end
136
+
137
+ end
138
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AssetDB
4
+ module Errors
5
+ class AssetDBError < StandardError; end
6
+ class UnknownGroupError < AssetDBError; end
7
+ class UnknownPackageError < AssetDBError; end
8
+ class CycleError < AssetDBError
9
+ attr_reader :cycle
10
+ def initialize(cycle)
11
+ @cycle = cycle
12
+ super("Dependency cycle detected: " +
13
+ cycle.map { |p| "#{p.group.id}:#{p.id}" }.join(' → '))
14
+ end
15
+ end
16
+ class InvalidIdentifierError < AssetDBError; end
17
+ end
18
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AssetDB
4
+ class Group
5
+ FOLDER_DEFAULT = :__default
6
+
7
+ attr_reader :database, :id
8
+
9
+ def initialize(database, id, folder: FOLDER_DEFAULT)
10
+ database.validate_identifier!(id)
11
+ @database = database
12
+ @id = id.to_s
13
+ @folder = folder.equal?(FOLDER_DEFAULT) ? FOLDER_DEFAULT : folder
14
+ @packages_hash = {} # {id ⇒ Package}
15
+ end
16
+
17
+ # DSL – fetch or create.
18
+ def package(id, folder: FOLDER_DEFAULT, &block)
19
+ pkg = (@packages_hash[id.to_s] ||= Package.new(self, id, folder: folder))
20
+ yield pkg if block
21
+ pkg
22
+ end
23
+
24
+ def packages
25
+ @packages_hash.values
26
+ end
27
+ def package_ids
28
+ @packages_hash.keys
29
+ end
30
+ def folder_spec
31
+ @folder
32
+ end
33
+
34
+ def folder_segment
35
+ return id if @folder.equal?(FOLDER_DEFAULT)
36
+ return nil if !@folder || @folder == '' || @folder == false
37
+ @folder.to_s
38
+ end
39
+
40
+ end
41
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AssetDB
4
+ class Package
5
+ FOLDER_DEFAULT = :__default
6
+
7
+ attr_reader :id, :group
8
+
9
+ def initialize(group, id, folder: FOLDER_DEFAULT)
10
+ @group = group
11
+ database.validate_identifier!(id)
12
+ @id = id.to_s
13
+ @folder = folder.equal?(FOLDER_DEFAULT) ? FOLDER_DEFAULT : folder
14
+ @assets = Hash.new { |h, k| h[k] = [] } # {type ⇒ [Asset]}
15
+ @dependencies = [] # [[group_id, package_id]]
16
+ @cache = {} # {type|:all ⇒ …}
17
+ end
18
+
19
+ def asset(type, url, metadata = nil, id: url)
20
+ type = type.to_sym
21
+ check_type!(type)
22
+ @assets[type] << Asset.new(type: type, url: url, group: group, package: self, metadata: metadata, id: id)
23
+ invalidate_cache
24
+ self
25
+ end
26
+
27
+ def depends_on(pkg_id, group_id: group.id)
28
+ @dependencies << [group_id.to_s, pkg_id.to_s]
29
+ invalidate_cache
30
+ self
31
+ end
32
+
33
+ def resolved_assets(type = nil)
34
+ key = type ? type.to_sym : :all
35
+ @cache[key] ||= database.resolver.resolve(self, type: type&.to_sym)
36
+ end
37
+
38
+ def +(other)
39
+ database.unify(self, other)
40
+ end
41
+
42
+ def folder_segment
43
+ return id if @folder.equal?(FOLDER_DEFAULT)
44
+ return nil if !@folder || @folder == '' || @folder == false
45
+ @folder.to_s
46
+ end
47
+
48
+ def assets
49
+ @assets
50
+ end
51
+ def dependencies
52
+ @dependencies
53
+ end
54
+ def key?
55
+ "#{group.id}/#{id}".freeze
56
+ end
57
+ def database
58
+ group.database
59
+ end
60
+
61
+ private
62
+
63
+ def invalidate_cache
64
+ @cache.clear
65
+ end
66
+ def check_type!(t)
67
+ database.asset_types.include?(t) or raise ArgumentError, "Unknown type #{t}"
68
+ end
69
+
70
+ end
71
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module AssetDB
6
+ class Resolver
7
+ def initialize(database)
8
+ @database = database
9
+ @memo = {} # {pkg_key ⇒ {type|:all ⇒ result}}
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def resolve(pkg, type: nil)
14
+ type_key = type ? type.to_sym : :all
15
+ @mutex.synchronize { (@memo[pkg.key?] ||= {})[type_key] ||= (type ? dfs_type(pkg, type_key) : dfs_all(pkg)) }
16
+ end
17
+
18
+ # --------------------------------------------------
19
+ # private helpers
20
+ # --------------------------------------------------
21
+ private
22
+
23
+ def dfs_type(pkg, type, visiting = Set.new, stack = [])
24
+ return @memo.dig(pkg.key?, type) if @memo.dig(pkg.key?, type)
25
+
26
+ raise Errors::CycleError.new(stack + [pkg]) if visiting.include?(pkg.key?)
27
+ visiting.add(pkg.key?); stack.push(pkg)
28
+
29
+ ordered = []
30
+ seen_ids = Set.new
31
+
32
+ pkg.dependencies.each do |(g_id, p_id)|
33
+ dep_pkg = @database.package!(g_id, p_id)
34
+ dfs_type(dep_pkg, type, visiting, stack).each do |asset|
35
+ next if seen_ids.include?(asset.id)
36
+ seen_ids << asset.id
37
+ ordered << asset
38
+ end
39
+ end
40
+
41
+ pkg.assets[type].each do |asset|
42
+ next if seen_ids.include?(asset.id)
43
+ seen_ids << asset.id
44
+ ordered << asset
45
+ end
46
+
47
+ stack.pop; visiting.delete(pkg.key?)
48
+ ordered.freeze
49
+ end
50
+
51
+ def dfs_all(pkg, visiting = Set.new, stack = [])
52
+ return @memo.dig(pkg.key?, :all) if @memo.dig(pkg.key?, :all)
53
+
54
+ raise Errors::CycleError.new(stack + [pkg]) if visiting.include?(pkg.key?)
55
+ visiting.add(pkg.key?); stack.push(pkg)
56
+
57
+ result = Hash.new { |h, k| h[k] = [] }
58
+ seen = Hash.new { |h, k| h[k] = Set.new }
59
+
60
+ pkg.dependencies.each do |(g_id, p_id)|
61
+ dep_pkg = @database.package!(g_id, p_id)
62
+ dfs_all(dep_pkg, visiting, stack).each do |t, assets|
63
+ assets.each do |asset|
64
+ next if seen[t].include?(asset.id)
65
+ seen[t] << asset.id
66
+ result[t] << asset
67
+ end
68
+ end
69
+ end
70
+
71
+ pkg.assets.each do |t, assets|
72
+ assets.each do |asset|
73
+ next if seen[t].include?(asset.id)
74
+ seen[t] << asset.id
75
+ result[t] << asset
76
+ end
77
+ end
78
+
79
+ stack.pop; visiting.delete(pkg.key?)
80
+ result.transform_values!(&:freeze).freeze
81
+ end
82
+
83
+ # --------------------------------------------------
84
+ # lightweight immutable union façade
85
+ # --------------------------------------------------
86
+ class PackageCollection
87
+ include Enumerable
88
+
89
+ def initialize(database, pkgs)
90
+ @database = database
91
+ @packages = pkgs.map(&:itself).uniq
92
+ @cache = {} # {[type, key] ⇒ [Asset]}
93
+ end
94
+
95
+ def +(other)
96
+ @database.unify(self, other)
97
+ end
98
+
99
+ def each_asset(type = nil, &block)
100
+ return @database.asset_types.each { |t| each_asset(t, &block) } if type.nil?
101
+
102
+ type = type.to_sym
103
+ key = [type, @packages.map(&:key?).sort].hash
104
+ @cache[key] ||= begin
105
+ seen = Set.new
106
+ arr = []
107
+ @packages.each do |pkg|
108
+ pkg.resolved_assets(type).each do |asset|
109
+ next if seen.include?(asset.id)
110
+ seen << asset.id
111
+ arr << asset
112
+ end
113
+ end
114
+ arr.freeze
115
+ end
116
+ block ? @cache[key].each(&block) : @cache[key]
117
+ end
118
+
119
+ def each
120
+ return to_enum(:each) unless block_given?
121
+
122
+ seen = Set.new
123
+ @database.asset_types.each do |type|
124
+ each_asset(type).each do |asset|
125
+ next if seen.include?(asset.id) # de-dupe across types just in case
126
+ seen << asset.id
127
+ yield asset
128
+ end
129
+ end
130
+ self
131
+ end
132
+
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AssetDB
4
+ VERSION = '0.1.1'
5
+ end
data/lib/asset_db.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ # version must come first so errors, etc. have a version constant available
6
+ require_relative 'asset_db/version'
7
+ require_relative 'asset_db/errors'
8
+ require_relative 'asset_db/asset'
9
+ require_relative 'asset_db/group'
10
+ require_relative 'asset_db/package'
11
+ require_relative 'asset_db/resolver'
12
+ require_relative 'asset_db/database'
13
+
14
+ module AssetDB
15
+ # DSL entry point, e.g.
16
+ #
17
+ # db = AssetDB.build(types: %i[css js], basepath: '/assets/:type/:group/:package') do |d|
18
+ # # ...
19
+ # end
20
+ def self.build(types: nil, basepath: nil, &block)
21
+ db = Database.new(asset_types: types, base_path: basepath)
22
+ yield db if block
23
+ db
24
+ end
25
+
26
+ # Configuration-driven constructor:
27
+ #
28
+ # db = AssetDB.load(config_hash)
29
+ #
30
+ def self.load(config)
31
+ Database.from_config(config)
32
+ end
33
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asset_db
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Convincible Media
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-08-02 00:00:00.000000000 Z
11
+ date: 2025-08-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -60,7 +60,14 @@ executables: []
60
60
  extensions: []
61
61
  extra_rdoc_files: []
62
62
  files:
63
- - asset_db.gemspec
63
+ - lib/asset_db.rb
64
+ - lib/asset_db/asset.rb
65
+ - lib/asset_db/database.rb
66
+ - lib/asset_db/errors.rb
67
+ - lib/asset_db/group.rb
68
+ - lib/asset_db/package.rb
69
+ - lib/asset_db/resolver.rb
70
+ - lib/asset_db/version.rb
64
71
  homepage: https://github.com/ConvincibleMedia/ruby-gem-assetdb
65
72
  licenses:
66
73
  - MIT
data/asset_db.gemspec DELETED
@@ -1,27 +0,0 @@
1
- # frozen_string_literal: true
2
- require_relative 'lib/asset_db/version'
3
-
4
- Gem::Specification.new do |spec|
5
- spec.name = 'asset_db'
6
- spec.version = AssetDB::VERSION
7
- spec.authors = ['Convincible Media']
8
- spec.email = ['development@convincible.media']
9
-
10
- spec.summary = "Lightweight asset dependency database for Ruby"
11
- spec.description = "Provides a structured way to define, organise, and resolve assets (CSS, JS, etc.) and their interdependencies across packages and groups."
12
- spec.homepage = 'https://github.com/ConvincibleMedia/ruby-gem-assetdb'
13
- spec.license = 'MIT'
14
-
15
- spec.required_ruby_version = '>= 2.4'
16
-
17
- # Files to include in the gem
18
- spec.files = Dir.chdir(__dir__) do
19
- `git ls-files -z`.split("\x0").grep(%r{\A(?:lib|README\.md|LICENSE|asset_db\.gemspec)\z})
20
- end
21
-
22
- # No runtime dependencies
23
- # Development dependencies
24
- spec.add_development_dependency 'bundler', '~> 2.0'
25
- spec.add_development_dependency 'rake', '~> 13.0'
26
- spec.add_development_dependency 'rspec', '~> 3.0'
27
- end