İçeriğe geç

price() ve plan

price() kütüphanenin gerçek çekirdeğidir: saf, adapter’sız, I/O’suz. observe yalnızca “getRate çek → price() → commit” sarmalayıcısıdır. Görüşlü bir ürün price()’ı doğrudan kullanabilir.

docs/src/snippets/plan-and-hold.test.ts
const sms = defineRate({ model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 20_000 }, { from: 10_000, unitPriceMicroUsd: 15_000 }] });
const { result, plan, rated } = price(
[{ meter: "otp/sms", dims: { country: "TR" }, quantity: 20, rate: sms, usedSoFar: 9_990 }],
{ type: "otp_send", id: "m9" },
{ accountId: "acme" },
{ at: Date.parse("2026-09-28T12:00:00Z") },
);
result; // { ok: true, charged: 350000, lines: [{ tierIndex: 1, breakdown: [{ from: 0, quantity: 10 }, { from: 10000, quantity: 10 }] }] }
rated[0]; // { totalMicroUsd: 350000, carry: 0, holdUpperBound: 400000, … }

Girdi PricedLine[]’dır: tarifesi (ValidRate) ve usedSoFar’ı eklenmiş satırlar. Çıktı { result, plan, rated }; rated[i], i. satırın rate() sonucudur (breakdown, tierIndex, carry, holdUpperBound, clamped). Kök price() havuz genişletmesi yapmaz; katalogdaki metering.price(lines, ref, ctx, { pools }) feeds’i uygular, eksik havuz satırının tarifesini opts.pools’tan alır.

Plan, kütüphanenin tek çıktısıdır. commit onu ya hep ya hiç yazmalı ve tekrarlanan (refType, refId)’yi sessizce atlamalıdır.

packages/core/src/plan.ts
/**
* Everything the library wants written, and the only thing it produces.
* `commit` must apply it all or nothing, and treat a repeated `(refType, refId)` as a no-op.
*/
export interface Plan {
ledger: LedgerOp[];
usage: UsageRow[];
}
export type LedgerOp = ChargeOp | HoldOp | ExtendOp | CaptureOp | ReleaseOp;
export interface UsageRow {
account: string;
meter: string;
dims: Record<string, unknown>;
quantity: number;
amount: MicroUsd;
rateId?: string;
detail: UsageDetail;
refType: string;
refId: string;
at: number;
/**
* The period's hard cap for this meter (from `getRate`). `commit` must refuse the whole plan with
* `QuotaExceeded` if the meter's counter for the row's period plus `quantity` would exceed it.
*/
limit?: number;
}
LedgerOp.op Anlamı amount
charge Para hareketi pozitif = borç; negatif = alacak (hacim düzeltmesi, kredi)
hold Hold için rezervasyon hold’un üst sınırı
extend Rezervasyonu büyütür üst sınır farkı
capture Rezervasyondan tahsil yakalanan tutar
release Kalanı serbest bırakır upperBound − captured

UsageRow.detail satırın nasıl fiyatlandığını taşır: breakdown (kademe dilimleri), havuz satırında feeder ve weight, clamped, hold satırlarında holdId, düzeltme satırında adjustment: true, cumulative’de carry. rateId, tarifenin id’sidir: her satır onu fiyatlayan tarifeye kadar izlenebilir.

docs/src/snippets/plan-and-hold.test.ts
const { plan } = await metering.plan.observe(
[
{ meter: "otp/sms", dims: { country: "TR" }, quantity: 1 },
{ meter: "otp/sms", dims: { country: "TR" }, quantity: 1 }, // same meter again → #2
],
{ type: "otp_send", id: "m1" },
ctx,
);
plan.usage.map((u) => [u.refId, u.amount]);
// [ ["otp_send:m1:otp/sms", 20000],
// ["otp_send:m1:otp/sms#2", 20000],
// ["otp_send:m1:otp/sms:msg/free_pool", 0], ← pool line: {refType}:{id}:{feeder}:{pool}
// ["otp_send:m1:otp/sms:msg/free_pool#2", 0] ]
plan.ledger.map((l) => l.refId);
// [ "otp_send:m1:otp/sms", "otp_send:m1:otp/sms#2" ] ← zero amounts: usage only, no ledger row
Satır refId
Gözlem satırı {refType}:{ref.id}:{meter}
Aynı çağrıda aynı sayaç tekrar ederse …#2, …#3 (A84)
Havuz satırı {refType}:{ref.id}:{feeder}:{pool}
Hacim düzeltmesi {satırın refId'si}:adj (A81)
Hold {refType}:{ref.id}:hold — holdId = {refType}:{ref.id}
Extend {holdId}:extend:{n}
Capture {refType}:{ref.id}:{meter}:capture:{n} (A87); havuz satırı …:{feeder}:{pool}:capture:{n}
Release {holdId}:release

refId tamamen ref ve satır sırasından türetilir; aynı çağrı aynı planı üretir. Uygulama retry’ının güvenli olması buradan gelir: commit ikinci planı no-op yapar.

docs/src/snippets/plan-and-hold.test.ts
// usedSoFar 900, volume on_crossing: 200 × 8,000 and −900 × 2,000 for the earlier units
const { result, plan } = await metering.plan.observe("api", {}, 200, { type: "req", id: "r2" }, ctx);
result; // { ok: true, charged: -200000, lines: [{ amount: 1600000, adjustment: -1800000, … }] }
plan.ledger; // [{ op: "charge", amount: 1600000, refId: "req:r2:api" },
// { op: "charge", amount: -1800000, refId: "req:r2:api:adj" }]

Rated.totalMicroUsd düzeltmeyi içermez (A81): usage satırı gözlemin kendi tutarıdır, düzeltme …:adj refId’li ayrı bir usage satırı (quantity: 0, detail.adjustment: true) ve ayrı bir charge’dır. result.charged ikisinin toplamıdır ve negatif olabilir.

Tutarı 0 olan satır usage’a yazılır, ledger’a yazılmaz (A85). “Ücretsiz ama sayılır”: 0 fiyatlı tarife, havuzun bedava dilimi ve ifMissing: "free" bu yüzden usedSoFar sayaçlarını ilerletir ama boş ledger kalemi üretmez. Hem ledger’ı hem usage’ı boş bir plan için commit hiç çağrılmaz.

Sayaçlar ve bakiye tek bir atomik yerdeyse (Cloudflare’de hesabın Durable Object’i), fiyatlamayı da oraya taşımak yarışı kapatır: Worker planı kendi görüşüyle çıkarır, Durable Object satırları kendi sayaçlarıyla yeniden fiyatlar ve aynı adımda uygular.

  1. Worker: metering.plan.observe(...) → { result, plan, lines }. lines fiyatlanmış satırlardır (havuz satırları dahil, tarifeleriyle; A91).
  2. Durable Object: usedSoFar’ı kendi sayacından doldurur, catalog.price(lines, ref, ctx) çağırır, çıkan planı transactionSync içinde uygular. Arada await yoktur; DO’nun giriş kapısı bunu atomik yapar.
examples/messaging/src/account.ts
export class AccountCore implements AccountApi {
constructor(
private storage: DurableStorageLike,
private sink: (rows: readonly UsageRow[]) => Promise<void>,
private opts: { prepaid: boolean } = { prepaid: true },
) {
migrateAccount(storage.sql);
}
async commit(plan: Plan): Promise<CommitReply> {
return commitReply(this.storage, plan, { prepaid: this.opts.prepaid, periodOf });
}
async used(meter: string, period: string): Promise<number> {
return accountUsed(this.storage.sql, meter, period);
}
async observeEmbedded(lines: CatalogLine[], ref: CatalogRef, ctx: Context): Promise<Result> {
// Nothing awaits between reading counters and writing: the DO's input gate makes this atomic.
const fresh = lines.map((l) => ({ ...l, usedSoFar: accountUsed(this.storage.sql, l.meter, periodOf({ at: l.at ?? Date.now() })) }));
const { result, plan } = catalog.price(fresh, ref, ctx);
if (!result.ok) return result;
const reply = commitReply(this.storage, plan, { prepaid: this.opts.prepaid, periodOf });
return reply.ok ? result : { ok: false, reason: "insufficient_credit" };
}
async credit(amountMicroUsd: number): Promise<void> {
creditAccount(this.storage.sql, amountMicroUsd);
}
async state(): Promise<AccountState> {
return accountState(this.storage.sql);
}
async flush(): Promise<number> {
let total = 0;
for (let n = -1; n !== 0; total += n) n = await flushOutbox(this.storage.sql, this.sink);
return total;
}
}

Bkz. mesajlaşma örneği ve Cloudflare adapter’ı.