portage-ucp-journal 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9eff21bdd3bd61e9e0d06d28fb1730bdb6341cf03b3655ba314c374a6f300a22
4
+ data.tar.gz: db616a0e139f3cf13ada29d12995f3d7905993ba33da43b79b29b560d174105b
5
+ SHA512:
6
+ metadata.gz: b78d052fe12a74a7bd05f75a83604c0c50c4670188bfc2ad75fc94c7d3dde6916b34485eaf97e66c487d3ac03046cabe92e90f2a29757584a0237b18410789eb
7
+ data.tar.gz: 1abe91206ecf4972ab684c0579e80b8e3ae49ba726e5d9ab3986fa4e7660e0b2c845c287d67e19767125694e5f04596c9417130eb9d3dbfcb55c70107cc0176b
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/);
4
+ this project is pre-1.0, so APIs may still shift between minor versions.
5
+
6
+ ## [0.1.0] - 2026-09-15
7
+
8
+ - Initial release: `Portage::Ucp::Journal::Store` (the injectable, append-only
9
+ persistence seam design-log §22 asks for), `FileStore` (its JSON-Lines
10
+ default), and `PurchaseJournal` (one entry per line item of a settled
11
+ `Order` — store origin, source, product id, quantity, amount in minor
12
+ units + currency, order id, idempotency key, timestamp).
13
+ - No runtime dependency on `portage-ucp` — wires into `Dispatcher` via its
14
+ new optional `journal:` argument from the consumer's own app.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tom Whitbread
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # Portage::Ucp::Journal
2
+
3
+ A buyer-side, append-only purchase journal for `portage-ucp` — store origin,
4
+ source, product, amount (minor units + currency), order id, idempotency key,
5
+ and timestamp for every completed purchase — built on a small, injectable
6
+ `Store` interface in the same mold as core's `RateLimiter`/`Authenticator`
7
+ (design-log §22).
8
+
9
+ This gem has no runtime dependency on `portage-ucp` itself, and `portage-ucp`
10
+ has none on this gem — core's `Dispatcher` gained an optional `journal:`
11
+ argument (nil by default, a no-op when absent) rather than requiring this
12
+ gem. You opt in from your own app.
13
+
14
+ ## Usage
15
+
16
+ ```ruby
17
+ require "portage/ucp/journal"
18
+
19
+ journal = Portage::Ucp::Journal::PurchaseJournal.new # FileStore, ~/.portage/journal.jsonl, by default
20
+
21
+ dispatcher = Portage::Ucp::Dispatcher.new(adapter: my_adapter, journal: journal)
22
+ # ... complete_checkout dispatches as normal; each settled checkout that
23
+ # produces an order confirmation now gets one journal entry per line item.
24
+
25
+ journal.all
26
+ # => [{"shop"=>"example.myshopify.com", "source"=>"adapter:shopify",
27
+ # "product_id"=>"sku_1", "quantity"=>2, "amount"=>2000, "currency"=>"USD",
28
+ # "order_id"=>"order_1", "idempotency_key"=>"idem_1",
29
+ # "recorded_at"=>"2026-09-15T12:00:00Z"}, ...]
30
+ ```
31
+
32
+ ## Swapping the store
33
+
34
+ `PurchaseJournal.new(store: your_store)` accepts anything implementing
35
+ `Portage::Ucp::Journal::Store`'s two methods (`#append(record)`,
36
+ `#each_record(&block)`). `FileStore` is the shipped default; nothing else
37
+ ships today (see `docs/plans/storage-abstraction-journal.md`'s open
38
+ decisions) — write your own `Store` subclass for Redis, a real database,
39
+ or an in-memory double for tests.
40
+
41
+ ## What this is not
42
+
43
+ - Not `Support::TransactionLog` (payment-dispatch bookkeeping — reserve/
44
+ commit, spend caps) or `Support::OrderLedger` (a settled-order snapshot
45
+ keyed for lookup). Both stay in core `portage-ucp` — as of design-log §33
46
+ they gained the same pluggable-`Store` seam this gem has, but the classes
47
+ themselves did not move.
48
+ - Not a console, a CLI history command, or a scheduler. This gem is the
49
+ write path and the shared `Store` seam those would read from and build
50
+ on, not the read/UI surface itself.
@@ -0,0 +1,61 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Journal
7
+ # Default Store: a JSON-Lines file, one record per line. Genuinely
8
+ # append-only — unlike core's TransactionLog/OrderLedger, #append never
9
+ # reads or rewrites the whole file, so a large journal stays cheap to
10
+ # write to and a crash mid-append can only ever lose the one
11
+ # in-flight line, never corrupt an earlier one.
12
+ #
13
+ # Same raising-write posture as TransactionLog/OrderLedger: a lost
14
+ # write here is a silent hole in the buyer's own purchase record, so
15
+ # there is no `rescue StandardError; nil` anywhere in this class.
16
+ class FileStore < Store
17
+ PATH = File.join(Dir.home, ".portage", "journal.jsonl").freeze
18
+
19
+ def initialize(path: PATH)
20
+ super()
21
+ @path = path
22
+ end
23
+
24
+ def append(record)
25
+ FileUtils.mkdir_p(File.dirname(@path))
26
+ File.open(@path, File::WRONLY | File::CREAT | File::APPEND, 0o600) do |file|
27
+ file.flock(File::LOCK_EX)
28
+ file.write("#{JSON.generate(record)}\n")
29
+ file.flush
30
+ end
31
+ record
32
+ end
33
+
34
+ # Skips a torn trailing line (a crash mid-write leaves at most one
35
+ # unparseable final line) rather than raising — every earlier,
36
+ # complete line is still good and must not be lost because the last
37
+ # one wasn't. A torn line in the *middle* of the file would indicate
38
+ # something worse than a crash mid-append (the file was hand-edited,
39
+ # or two writers bypassed the flock) and is left to surface as a
40
+ # JSON::ParserError rather than silently swallowed anywhere but the
41
+ # last line.
42
+ def each_record
43
+ return enum_for(:each_record) unless block_given?
44
+
45
+ return unless File.exist?(@path)
46
+
47
+ lines = File.open(@path, File::RDONLY) do |file|
48
+ file.flock(File::LOCK_SH)
49
+ file.readlines
50
+ end
51
+
52
+ lines.each_with_index do |line, index|
53
+ yield JSON.parse(line)
54
+ rescue JSON::ParserError
55
+ raise unless index == lines.length - 1
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,86 @@
1
+ require "time"
2
+
3
+ module Portage
4
+ module Ucp
5
+ module Journal
6
+ # Buyer-side, append-only record of "what did I buy, where, for how
7
+ # much" (design-log §22) — distinct from core's TransactionLog
8
+ # (payment-dispatch bookkeeping) and OrderLedger (a settled-order
9
+ # snapshot keyed for later lookup). Neither of those is shaped as a
10
+ # journal, and neither belongs to a consumer wanting one durable
11
+ # append-only trail across every completed purchase regardless of
12
+ # which adapter or platform made it.
13
+ #
14
+ # Takes only what's already in hand at the dispatcher's settle point —
15
+ # no re-fetch, no new schema. `checkout` is duck-typed against
16
+ # Portage::Ucp::Checkout (currency, line_items — each a LineItem-shaped
17
+ # object with #item.id, a bare integer #quantity, and #totals, an
18
+ # array of Total-shaped objects with #type/#amount — and #order, an
19
+ # OrderConfirmation-shaped object with #id), not against
20
+ # Portage::Ucp::Order: Checkout#order is only ever an
21
+ # order_confirmation stub (id/permalink_url/label per the UCP schema),
22
+ # never the full Order with its own line_items. This gem carries no
23
+ # runtime dependency on portage-ucp itself either way.
24
+ class PurchaseJournal
25
+ def initialize(store: FileStore.new, clock: -> { Time.now })
26
+ @store = store
27
+ @clock = clock
28
+ end
29
+
30
+ # One journal entry per line item — a checkout with several distinct
31
+ # products is several purchases from the buyer's own point of view,
32
+ # not one.
33
+ #
34
+ # @param shop [String, nil] merchant/store identity, same value
35
+ # Dispatcher already threads into TransactionLog.
36
+ # @param source ["native_ucp", String] "native_ucp" for a
37
+ # direct/no-adapter dispatch, "adapter:<platform>" otherwise — see
38
+ # docs/plans/storage-abstraction-journal.md's open decision on the
39
+ # exact platform string.
40
+ # @param checkout [#currency, #line_items, #order] the settled
41
+ # Checkout — Dispatcher's `result` at the complete_checkout settle
42
+ # point, same object #order_ledger snapshots `#order` from.
43
+ # @param idempotency_key [String] joins this entry back to the
44
+ # TransactionLog/OrderLedger records from the same dispatch.
45
+ # @return [Array<Hash>] the entries written, one per line item.
46
+ def record_checkout(shop:, source:, checkout:, idempotency_key:)
47
+ context = { shop: shop, source: source, order_id: checkout.order&.id,
48
+ idempotency_key: idempotency_key, recorded_at: @clock.call.utc.iso8601 }
49
+
50
+ checkout.line_items.map do |line_item|
51
+ entry = build_entry(line_item, checkout.currency, context)
52
+ @store.append(entry)
53
+ entry
54
+ end
55
+ end
56
+
57
+ def each_record(&)
58
+ @store.each_record(&)
59
+ end
60
+
61
+ def all
62
+ enum_for(:each_record).to_a
63
+ end
64
+
65
+ private
66
+
67
+ def build_entry(line_item, currency, context)
68
+ {
69
+ "shop" => context[:shop], "source" => context[:source], "product_id" => line_item.item.id,
70
+ "quantity" => line_item.quantity, "amount" => line_total(line_item), "currency" => currency,
71
+ "order_id" => context[:order_id], "idempotency_key" => context[:idempotency_key],
72
+ "recorded_at" => context[:recorded_at]
73
+ }
74
+ end
75
+
76
+ # Total#amount is a bare integer minor-unit amount — same convention
77
+ # Dispatcher#settled_amount already follows for the checkout-level
78
+ # total.
79
+ def line_total(line_item)
80
+ total = Array(line_item.totals).find { |t| t.type == "total" }
81
+ total&.amount
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,30 @@
1
+ module Portage
2
+ module Ucp
3
+ module Journal
4
+ # @abstract Pluggable append-only record store — same "no bundled
5
+ # storage assumption" posture as core's RateLimiter/Authenticator
6
+ # (portage-ucp §9), extended here to persistence (design-log §22).
7
+ # Unlike NullRateLimiter/UnconfiguredAuthenticator, there is no
8
+ # silent-no-op default: an unconfigured journal that drops every
9
+ # write defeats the point of a purchase record, so FileStore (this
10
+ # gem's own default) is a real, durable implementation, not a null
11
+ # object. The interface is deliberately minimal — append and replay
12
+ # only — because the only consumer today (PurchaseJournal) needs
13
+ # nothing more; extend it when a second real consumer (a console, a
14
+ # scheduler) needs keyed lookups, not speculatively ahead of one.
15
+ class Store
16
+ # @param record [Hash] a single journal entry, already built by the
17
+ # caller (PurchaseJournal). The store persists it as-is.
18
+ def append(record)
19
+ raise NotImplementedError, "#{self.class} must implement #append"
20
+ end
21
+
22
+ # Yields each previously-appended record, in write order, without a
23
+ # block returns an Enumerator.
24
+ def each_record(&)
25
+ raise NotImplementedError, "#{self.class} must implement #each_record"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,7 @@
1
+ module Portage
2
+ module Ucp
3
+ module Journal
4
+ VERSION = "0.1.0".freeze
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,16 @@
1
+ require_relative "journal/version"
2
+ require_relative "journal/store"
3
+ require_relative "journal/file_store"
4
+ require_relative "journal/purchase_journal"
5
+
6
+ module Portage
7
+ module Ucp
8
+ # Buyer-side purchase journal + the injectable Store abstraction it's
9
+ # built on (design-log §22) — kept out of the dependency-light core gem
10
+ # (portage-ucp §2) since it's an optional consumer add-on, not a
11
+ # payment-safety-critical concern the way TransactionLog/OrderLedger
12
+ # are. A future console or scheduler gem depends on Store the same way.
13
+ module Journal
14
+ end
15
+ end
16
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: portage-ucp-journal
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tom Whitbread
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: portage-ucp
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.6'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.6'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.13'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.13'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.88'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.88'
55
+ - !ruby/object:Gem::Dependency
56
+ name: yard
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.9'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.9'
69
+ description: 'An append-only, consumer-swappable record of every purchase a Dispatcher
70
+ completes (store origin, source, product, amount in minor units, order id, idempotency
71
+ key), built on a small Store interface in the rate_limiter/authenticator mold —
72
+ the shared persistence seam design-log §22 asks for so a future console or scheduler
73
+ gem doesn''t each grow an incompatible one. Zero runtime dependency on portage-ucp
74
+ itself; wires in via Dispatcher''s optional journal: argument.'
75
+ email:
76
+ executables: []
77
+ extensions: []
78
+ extra_rdoc_files: []
79
+ files:
80
+ - CHANGELOG.md
81
+ - LICENSE
82
+ - README.md
83
+ - lib/portage/ucp/journal.rb
84
+ - lib/portage/ucp/journal/file_store.rb
85
+ - lib/portage/ucp/journal/purchase_journal.rb
86
+ - lib/portage/ucp/journal/store.rb
87
+ - lib/portage/ucp/journal/version.rb
88
+ homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp-journal
89
+ licenses:
90
+ - MIT
91
+ metadata:
92
+ source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp-journal
93
+ changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp-journal/CHANGELOG.md
94
+ rubygems_mfa_required: 'true'
95
+ post_install_message:
96
+ rdoc_options: []
97
+ require_paths:
98
+ - lib
99
+ required_ruby_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '3.2'
104
+ required_rubygems_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '0'
109
+ requirements: []
110
+ rubygems_version: 3.5.22
111
+ signing_key:
112
+ specification_version: 4
113
+ summary: Buyer-side purchase journal + injectable Store abstraction for portage-ucp
114
+ test_files: []