portage-ucp-journal API¶
portage-ucp-journal (0.1.1) keeps a buyer-side, append-only record of what your agent bought, where and for how much.
Use it when you want one durable purchase trail across every adapter. It has no runtime dependency on portage-ucp. Core's Dispatcher takes an optional journal: and calls it, so you opt in from your own app. See portage-ucp for the core side.
PurchaseJournal¶
Portage::Ucp::Journal::PurchaseJournal builds one record per line item and hands it to a Store.
| Method | Signature | Returns | Notes |
|---|---|---|---|
new |
PurchaseJournal.new(store: FileStore.new, clock: -> { Time.now }) |
PurchaseJournal |
clock is any callable returning a Time. Use it to fix time in tests. |
record_checkout |
record_checkout(shop:, source:, checkout:, idempotency_key:) |
Array<Hash> |
Writes one entry per line item. Returns the entries written. |
each_record |
each_record(&block) |
Enumerator without a block | Delegates to the store. |
all |
all |
Array<Hash> |
Every record, in write order. |
record_checkout arguments:
shop: the merchant identity string, or nil.source:"native_ucp"for a direct dispatch,"adapter:<platform>"otherwise.checkout: duck-typed. It must answer#currency,#line_itemsand#order(which may be nil). Each line item answers#item(with#id),#quantity(an Integer) and#totals(objects with#typeand#amount).Portage::Ucp::Checkoutfits.idempotency_key: joins the entry to the core transaction and order records from the same dispatch.
Source: portage-ucp-journal/lib/portage/ucp/journal/purchase_journal.rb
Record shape¶
Each record is a Hash with string keys.
| Key | Value |
|---|---|
"shop" |
The shop: you passed. |
"source" |
The source: you passed. |
"product_id" |
line_item.item.id. |
"quantity" |
line_item.quantity. |
"amount" |
Integer minor-unit amount of the line item's total entry in totals, or nil if there is none. |
"currency" |
checkout.currency. |
"order_id" |
checkout.order&.id. |
"idempotency_key" |
The key you passed. |
"recorded_at" |
UTC ISO 8601 string. |
Wiring it into a purchase¶
Pass the journal to core. The Dispatcher calls record_checkout when a complete_checkout settles with an order confirmation.
journal = Portage::Ucp::Journal::PurchaseJournal.new
# Directly:
dispatcher = Portage::Ucp::Dispatcher.new(adapter: adapter, journal: journal)
# Or through the client's loopback transport (server_opts reach Mcp::Server.build):
session = Portage::Ucp::Client.for_adapter(adapter, journal: journal)
Source: portage-ucp/lib/portage/ucp/mcp/server.rb, portage-ucp-client/lib/portage/ucp/client.rb
Store¶
Portage::Ucp::Journal::Store is the abstract base. It has two methods and no default behaviour: both raise NotImplementedError.
| Method | Signature | Returns | Notes |
|---|---|---|---|
append |
append(record) |
Your choice | record is a Hash. Persist it as given. FileStore returns the record. |
each_record |
each_record(&block) |
Enumerator without a block | Yield each record in write order. |
FileStore¶
The default store. One JSON object per line in a JSON Lines file.
| Item | Value |
|---|---|
| Constructor | FileStore.new(path: FileStore::PATH) |
FileStore::PATH |
~/.portage/journal.jsonl |
| Write | Appends one line under an exclusive flock, creating the file with mode 0600. |
| Read | Under a shared flock. A torn last line is skipped. A bad line elsewhere raises JSON::ParserError. |
Writes raise on failure. Nothing is silently dropped.
Source: portage-ucp-journal/lib/portage/ucp/journal/store.rb, portage-ucp-journal/lib/portage/ucp/journal/file_store.rb
Writing a custom Store¶
Subclass Store, call super() in your constructor, and implement both methods. Raise on a failed write. A lost write is a hole in the buyer's record.
class MemoryStore < Portage::Ucp::Journal::Store
def initialize
super()
@records = []
end
def append(record)
@records << record
record
end
def each_record(&block)
return enum_for(:each_record) unless block
@records.each(&block)
end
end
journal = Portage::Ucp::Journal::PurchaseJournal.new(store: MemoryStore.new)
End to end¶
require "portage/ucp/journal"
Total = Struct.new(:type, :amount)
Item = Struct.new(:id)
Line = Struct.new(:item, :quantity, :totals)
Order = Struct.new(:id)
Checkout = Struct.new(:currency, :line_items, :order)
checkout = Checkout.new("GBP", [Line.new(Item.new("sku_1"), 2, [Total.new("total", 2000)])], Order.new("order_1"))
journal = Portage::Ucp::Journal::PurchaseJournal.new(store: Portage::Ucp::Journal::FileStore.new(path: "/tmp/journal.jsonl"))
journal.record_checkout(shop: "shop.example", source: "native_ucp", checkout: checkout, idempotency_key: "idem_1")
journal.all.first
# => {"shop"=>"shop.example", "source"=>"native_ucp", "product_id"=>"sku_1", "quantity"=>2,
# "amount"=>2000, "currency"=>"GBP", "order_id"=>"order_1", "idempotency_key"=>"idem_1",
# "recorded_at"=>"..."}
In real use you rarely call record_checkout yourself. Pass the journal to the Dispatcher and read it with journal.all. For the client that drives the purchase, see portage-ucp-client.