@usagekit/meter 0.3.0 → 0.5.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.
@@ -0,0 +1,15 @@
1
+ import type { ConnectionPolicy, MeterReserveInput, Operation, PricingCatalog, ReserveInput, ValidationFailure } from "@usagekit/core";
2
+ /** Host lookup of a connection's provider, plan, manual prices and tracking policy. */
3
+ export type ConnectionResolver = (connectionId: string) => Promise<ConnectionPolicy | undefined> | ConnectionPolicy | undefined;
4
+ /** What reserve does with a request after the catalog and the connection policy spoke. */
5
+ export type ReserveResolution = {
6
+ kind: "reserve";
7
+ input: ReserveInput;
8
+ } | {
9
+ kind: "count";
10
+ state: "passthrough" | "unpriced";
11
+ } | {
12
+ kind: "invalid";
13
+ failure: ValidationFailure;
14
+ };
15
+ export declare function resolveReserve(requested: MeterReserveInput, existing: Operation | null, catalog?: PricingCatalog, resolveConnection?: ConnectionResolver): Promise<ReserveResolution>;
@@ -0,0 +1,62 @@
1
+ const invalid = (field, reason) => ({
2
+ kind: "invalid",
3
+ failure: { outcome: "invalid", field, reason },
4
+ });
5
+ /**
6
+ * Order: a stored operation replays with its own estimate; then the connection's tracking
7
+ * policy and free catalog operations count as passthrough; then a given estimate is used as is;
8
+ * then the catalog resolves one (manual, measured, list, unknown), and an operation the catalog
9
+ * does not know counts as unpriced.
10
+ */
11
+ const provenanceOf = (estimateSource, providerPriceVersion) => ({
12
+ ...(estimateSource ? { estimateSource } : {}),
13
+ ...(providerPriceVersion ? { providerPriceVersion } : {}),
14
+ });
15
+ export async function resolveReserve(requested, existing, catalog, resolveConnection) {
16
+ const { options = {}, estimate, estimateSource, providerPriceVersion, ...rest } = requested;
17
+ // A retry that omits the estimate replays the stored one, including provenance. A retry that
18
+ // sends an estimate keeps only the provenance on that request.
19
+ if (existing) {
20
+ if (estimate === undefined)
21
+ return {
22
+ kind: "reserve",
23
+ input: {
24
+ ...rest,
25
+ estimate: existing.estimate,
26
+ ...provenanceOf(existing.estimateSource, existing.providerPriceVersion),
27
+ },
28
+ };
29
+ return {
30
+ kind: "reserve",
31
+ input: { ...rest, estimate, ...provenanceOf(estimateSource, providerPriceVersion) },
32
+ };
33
+ }
34
+ const policy = catalog && resolveConnection ? await resolveConnection(rest.scope.connection) : undefined;
35
+ if (policy && policy.provider !== rest.provider)
36
+ return invalid("provider", "differs from the connection provider");
37
+ if (policy && catalog) {
38
+ const known = catalog.operationsOf(policy.provider).find((o) => o.id === rest.operation);
39
+ if (policy.tracking?.[rest.operation] === "passthrough" || known?.billable === false)
40
+ return { kind: "count", state: "passthrough" };
41
+ if (estimate === undefined) {
42
+ if (!known)
43
+ return { kind: "count", state: "unpriced" };
44
+ const { tracking: _, ...connection } = policy, resolved = catalog.estimate({ ...connection, id: rest.scope.connection }, rest.operation, options);
45
+ return {
46
+ kind: "reserve",
47
+ input: {
48
+ ...rest,
49
+ estimate: resolved.quantities,
50
+ estimateSource: resolved.source,
51
+ ...(resolved.priceVersion ? { providerPriceVersion: resolved.priceVersion } : {}),
52
+ },
53
+ };
54
+ }
55
+ }
56
+ if (estimate === undefined)
57
+ return invalid("estimate", catalog && resolveConnection ? "unknown connection" : "required without a catalog");
58
+ return {
59
+ kind: "reserve",
60
+ input: { ...rest, estimate, ...provenanceOf(estimateSource, providerPriceVersion) },
61
+ };
62
+ }
package/dist/index.d.ts CHANGED
@@ -3,3 +3,5 @@ export { createMeter } from "./meter.js";
3
3
  export { defaultPolicy } from "./policy.js";
4
4
  export type { MeterPolicy } from "./policy.js";
5
5
  export type { OwnershipResolver } from "./access.js";
6
+ export { resolveReserve } from "./catalog.js";
7
+ export type { ConnectionResolver, ReserveResolution } from "./catalog.js";
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { createMeter } from "./meter.js";
2
2
  export { defaultPolicy } from "./policy.js";
3
+ export { resolveReserve } from "./catalog.js";
package/dist/meter.d.ts CHANGED
@@ -1,9 +1,13 @@
1
- import type { Meter } from "@usagekit/core";
1
+ import type { Meter, PricingCatalog } from "@usagekit/core";
2
+ import type { ConnectionResolver } from "./catalog.js";
2
3
  import type { Store, Clock } from "@usagekit/store";
3
4
  import type { MeterPolicy } from "./policy.js";
4
- export declare function createMeter({ store, policy, clock, resolveOwnership, }: {
5
+ export declare function createMeter({ store, policy, clock, resolveOwnership, catalog, resolveConnection, }: {
5
6
  store: Store;
6
7
  policy?: MeterPolicy;
7
8
  clock: Clock;
8
9
  resolveOwnership?: import("./access.js").OwnershipResolver;
10
+ /** With resolveConnection, lets reserve omit estimate and applies tracking policy. */
11
+ catalog?: PricingCatalog;
12
+ resolveConnection?: ConnectionResolver;
9
13
  }): Meter;
package/dist/meter.js CHANGED
@@ -1,9 +1,11 @@
1
+ import { resolveReserve } from "./catalog.js";
1
2
  import { InvalidInput } from "@usagekit/store";
2
3
  import { defaultPolicy } from "./policy.js";
3
- import { validate, reserveValidation, usageValidation, operationsValidation, sourceValidation, } from "./validation.js";
4
+ import { validate, reserveValidation, usageValidation, operationsValidation, sourceValidation, provenanceValidation, requestCountsValidation, } from "./validation.js";
4
5
  import { canRead, canReadBudget, canReadShared, isShared } from "./access.js";
5
6
  import { defaultBudgetOrder, normalizeTags } from "@usagekit/core";
6
- export function createMeter({ store, policy = defaultPolicy, clock, resolveOwnership, }) {
7
+ import { validateBillingImportInput, validateBillingImportsQuery } from "@usagekit/store";
8
+ export function createMeter({ store, policy = defaultPolicy, clock, resolveOwnership, catalog, resolveConnection, }) {
7
9
  if (!Number.isFinite(clock.now().getTime()))
8
10
  throw new Error("InvalidInput: clock");
9
11
  if (policy.budgetOrder.length !== defaultBudgetOrder.length ||
@@ -29,19 +31,99 @@ export function createMeter({ store, policy = defaultPolicy, clock, resolveOwner
29
31
  };
30
32
  const read = async (fn) => safely(async () => ({ outcome: "ok", value: await fn() }));
31
33
  return {
32
- expireReservations: (i) => command(i, () => store.expireReservations(i)),
33
- reserve: async (raw) => {
34
- const invalid = reserveValidation(raw);
34
+ importBilling: async (access, i) => {
35
+ const invalid = validateBillingImportInput(i);
35
36
  if (invalid)
36
37
  return invalid;
37
- // Tags are attribution: sorted here so every adapter stores one canonical order.
38
- const tags = raw.scope.tags === undefined ? undefined : normalizeTags(raw.scope.tags), i = tags ? { ...raw, scope: { ...raw.scope, tags } } : raw;
39
- return safely(async () => {
40
- const existing = await store.getOperation({
38
+ if (access.canImportBilling !== true ||
39
+ !access.canReadBillingDetail ||
40
+ !canRead(access, {
41
+ kind: "principal",
41
42
  namespace: i.scope.namespace,
42
43
  principal: i.scope.principal,
43
- operationId: i.operationId,
44
+ }))
45
+ return { outcome: "forbidden" };
46
+ const owner = await resolveOwnership?.({
47
+ kind: "connection",
48
+ namespace: i.scope.namespace,
49
+ connection: i.scope.connection,
50
+ });
51
+ if (!owner ||
52
+ (owner.kind === "principal"
53
+ ? owner.principal !== i.scope.principal
54
+ : owner.group !== i.scope.group) ||
55
+ !canRead(access, owner))
56
+ return { outcome: "forbidden" };
57
+ const connection = await resolveConnection?.(i.scope.connection);
58
+ if (resolveConnection && (!connection || connection.provider !== i.provider))
59
+ return { outcome: "forbidden" };
60
+ if (catalog && catalog.operationsOf(i.provider).length === 0)
61
+ return { outcome: "forbidden" };
62
+ return safely(() => store.importBilling(i));
63
+ },
64
+ billingImports: async (access, q) => {
65
+ const invalid = validateBillingImportsQuery(q);
66
+ if (invalid)
67
+ return invalid;
68
+ if (!access.canReadBillingDetail ||
69
+ !canRead(access, { kind: "principal", ...q.scope }) ||
70
+ !(await canReadBudget(resolveOwnership, access, {
71
+ kind: "connection",
72
+ namespace: q.scope.namespace,
73
+ connection: q.connection,
74
+ })))
75
+ return { outcome: "forbidden" };
76
+ return read(() => store.billingImports(q));
77
+ },
78
+ expireReservations: (i) => command(i, () => store.expireReservations(i)),
79
+ reserve: async (requested) => {
80
+ if (requested.source === "import")
81
+ return {
82
+ outcome: "invalid",
83
+ field: "source",
84
+ reason: "import source is reserved for authorized billing evidence",
85
+ };
86
+ const early = validate({ ...requested, estimate: requested.estimate ?? [] }) ??
87
+ sourceValidation(requested) ??
88
+ provenanceValidation(requested);
89
+ if (early)
90
+ return early;
91
+ return safely(async () => {
92
+ const existing = await store.getOperation({
93
+ namespace: requested.scope.namespace,
94
+ principal: requested.scope.principal,
95
+ operationId: requested.operationId,
44
96
  });
97
+ const resolution = await resolveReserve(requested, existing, catalog, resolveConnection);
98
+ if (resolution.kind === "invalid")
99
+ return resolution.failure;
100
+ if (resolution.kind === "count") {
101
+ const { scope, surface, source, provider, operation, platformPools } = requested;
102
+ const counted = await store.countRequest({
103
+ commandId: requested.operationId,
104
+ scope,
105
+ surface,
106
+ source,
107
+ provider,
108
+ operation,
109
+ state: resolution.state,
110
+ ...(platformPools ? { platformPools } : {}),
111
+ });
112
+ if (counted.outcome === "conflict")
113
+ return {
114
+ outcome: "invalid",
115
+ field: "operationId",
116
+ reason: "reused for a different counted request",
117
+ };
118
+ if (counted.outcome === "invalid")
119
+ return counted;
120
+ return { outcome: resolution.state, replayed: counted.replayed, operation: null };
121
+ }
122
+ const raw = resolution.input, invalid = reserveValidation(raw);
123
+ if (invalid)
124
+ return invalid;
125
+ // Tags are attribution: sorted here so every adapter stores one canonical order.
126
+ const tags = raw.scope.tags === undefined ? undefined : normalizeTags(raw.scope.tags), i = tags ? { ...raw, scope: { ...raw.scope, tags } } : raw;
45
127
  if (existing)
46
128
  return store.reserve(i, { budgetOrder: policy.budgetOrder });
47
129
  const statuses = await store.applicableBudgets({
@@ -65,9 +147,39 @@ export function createMeter({ store, policy = defaultPolicy, clock, resolveOwner
65
147
  markDispatchIntent: (i) => command(i, () => store.markDispatchIntent(i)),
66
148
  renewLease: (i) => command(i, () => store.renewLease(i)),
67
149
  claimForRecovery: (i) => command(i, () => store.claimForRecovery(i)),
68
- settle: (i) => command(i, () => store.settle(i)),
69
- correct: (i) => command(i, () => store.correct(i)),
150
+ settle: (i) => i.receipt.source === "import"
151
+ ? Promise.resolve({
152
+ outcome: "invalid",
153
+ field: "receipt.source",
154
+ reason: "import evidence requires the atomic billing import command",
155
+ })
156
+ : command(i, () => store.settle(i)),
157
+ correct: (i) => i.receipt.source === "import"
158
+ ? Promise.resolve({
159
+ outcome: "invalid",
160
+ field: "receipt.source",
161
+ reason: "import evidence requires the atomic billing import command",
162
+ })
163
+ : command(i, () => store.correct(i)),
70
164
  releaseUndispatched: (i) => command(i, () => store.releaseUndispatched(i)),
165
+ countRequest: (i) => {
166
+ if (i.source === "import")
167
+ return Promise.resolve({
168
+ outcome: "invalid",
169
+ field: "source",
170
+ reason: "import source is reserved for authorized billing evidence",
171
+ });
172
+ const invalid = validate(i) ?? sourceValidation(i);
173
+ return invalid ? Promise.resolve(invalid) : safely(() => store.countRequest(i));
174
+ },
175
+ requestCounts: async (access, q) => {
176
+ const invalid = requestCountsValidation(q);
177
+ if (invalid)
178
+ return invalid;
179
+ if (!access.canReadBillingDetail || !canRead(access, q.scope))
180
+ return { outcome: "forbidden" };
181
+ return read(() => store.requestCounts(q));
182
+ },
71
183
  getOperation: async (access, i) => {
72
184
  const invalid = validate(i);
73
185
  if (invalid)
@@ -1,7 +1,12 @@
1
- import type { ValidationFailure, ReserveInput, UsageQuery, ApplicableBudgetsQuery, OperationsQuery } from "@usagekit/core";
1
+ import type { ValidationFailure, ReserveInput, UsageQuery, ApplicableBudgetsQuery, OperationsQuery, RequestCountsQuery } from "@usagekit/core";
2
2
  export declare function validate(value: unknown, field?: string): ValidationFailure | null;
3
3
  /** The host sets both fields at authentication; a source outside its surface group is a forgery or a bug. */
4
4
  export declare function sourceValidation(i: Pick<ApplicableBudgetsQuery, "surface" | "source">): ValidationFailure | null;
5
5
  export declare function reserveValidation(i: ReserveInput): ValidationFailure | null;
6
6
  export declare function operationsValidation(q: OperationsQuery): ValidationFailure | null;
7
7
  export declare function usageValidation(q: UsageQuery): ValidationFailure | null;
8
+ /** Hosts may name the source; a value outside the catalog order is rejected before Store. */
9
+ export declare function provenanceValidation(i: {
10
+ estimateSource?: string;
11
+ }): ValidationFailure | null;
12
+ export declare function requestCountsValidation(q: RequestCountsQuery): ValidationFailure | null;
@@ -74,3 +74,14 @@ export function usageValidation(q) {
74
74
  };
75
75
  return null;
76
76
  }
77
+ const estimateSources = ["manual", "measured", "list", "unknown"];
78
+ /** Hosts may name the source; a value outside the catalog order is rejected before Store. */
79
+ export function provenanceValidation(i) {
80
+ if (i.estimateSource !== undefined &&
81
+ !estimateSources.includes(i.estimateSource))
82
+ return { outcome: "invalid", field: "estimateSource", reason: "unknown estimate source" };
83
+ return null;
84
+ }
85
+ export function requestCountsValidation(q) {
86
+ return validate(q) ?? windowValidation(q);
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagekit/meter",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Admission, reservations, settlement and recovery.",
@@ -23,8 +23,8 @@
23
23
  "prepack": "node ../../scripts/package-license.mjs"
24
24
  },
25
25
  "dependencies": {
26
- "@usagekit/core": "0.3.0",
27
- "@usagekit/store": "0.3.0"
26
+ "@usagekit/core": "0.5.0",
27
+ "@usagekit/store": "0.5.0"
28
28
  },
29
29
  "sideEffects": false,
30
30
  "publishConfig": {