@farthershore/product 0.2.1 → 0.3.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.
package/README.md CHANGED
@@ -64,7 +64,16 @@ Modules are plain synchronous functions:
64
64
  import { fs, type ProductModule } from "@farthershore/product";
65
65
 
66
66
  export const configureMeters: ProductModule = (product) => {
67
- product.meter("requests", { display: "Requests" });
67
+ product.requests();
68
+
69
+ const tokens = product.meter("tokens_used", {
70
+ unit: "token",
71
+ estimate: 500,
72
+ });
73
+
74
+ product.feature("runs").route("POST /v1/runs", {
75
+ reports: tokens,
76
+ });
68
77
  };
69
78
 
70
79
  export const configurePlans: ProductModule = (product) => {
@@ -83,6 +92,53 @@ export const configurePlans: ProductModule = (product) => {
83
92
  };
84
93
  ```
85
94
 
95
+ ## Metering
96
+
97
+ `product.requests()` declares the platform-managed request meter and applies
98
+ `requests = 1` to every metered route. Builders do not need backend code for
99
+ plain request counting.
100
+
101
+ ```ts
102
+ const product = fs.product("croncloud", {
103
+ baseUrl: "https://api.croncloud.com",
104
+ });
105
+
106
+ product.requests();
107
+
108
+ const tokens = product.meter("tokens_used", {
109
+ unit: "token",
110
+ estimate: 500,
111
+ });
112
+
113
+ product.feature("runs").route("POST /v1/runs", {
114
+ reports: tokens,
115
+ });
116
+ ```
117
+
118
+ Route metering is explicit:
119
+
120
+ - omitted route metering inherits product defaults such as `requests = 1`
121
+ - `reports` declares dynamic meters the upstream may report with
122
+ `@farthershore/metering`
123
+ - `costs` declares gateway-known fixed usage values for a route
124
+ - `estimates` overrides reusable meter estimates for admission checks
125
+ - `unmetered: true` clears all inherited and explicit route metering
126
+ - `inheritDefaultMeters: false` disables inherited defaults only
127
+
128
+ ```ts
129
+ const credits = product.meter("api_credits", { unit: "credit" });
130
+
131
+ product.defaultMeters(credits.fixed(1));
132
+
133
+ product.feature("exports").route("POST /v1/bulk-export", {
134
+ costs: credits.fixed(10),
135
+ });
136
+
137
+ product.feature("health").route("GET /healthz", {
138
+ unmetered: true,
139
+ });
140
+ ```
141
+
86
142
  ## Lifecycle apply paths
87
143
 
88
144
  Generated product repos use GitHub as the required automation and frontend
@@ -113,11 +169,14 @@ accepts; Core, not the user repo, remains the lifecycle authority.
113
169
  - `business.use(...modules)` — compose Product SDK modules from any files under
114
170
  `product/`.
115
171
  - `business.meter(...)` — declare billable or enforceable dimensions.
172
+ - `business.requests()` — declare and inherit the platform-managed successful
173
+ request meter.
174
+ - `business.defaultMeters(...)` — apply reusable fixed costs to metered routes.
116
175
  - `business.resource(...)` — declare counted resources for resource-count
117
176
  constraints.
118
177
  - `business.capability(...)` — declare capability bundles and plan grants.
119
178
  - `business.feature(...)` / `business.api.route(...)` — declare gateway routes,
120
- meters, and action metadata.
179
+ static costs, dynamic reports, estimates, and action metadata.
121
180
  - `business.policy(...)` — declare policy files in code.
122
181
  - `business.plan(...)` — declare plan pricing, limits, grants, and lifecycle
123
182
  behavior.
package/dist/bin.js CHANGED
@@ -1051,10 +1051,11 @@ function validateRouteMeters(spec, ctx) {
1051
1051
  for (const [featureKey, feature] of Object.entries(spec.features)) {
1052
1052
  const routes = feature.routes ?? [];
1053
1053
  routes.forEach((route, routeIdx) => {
1054
- if (!route.meters || route.meters.length === 0)
1054
+ const routeMeters = routeMeterKeys(route);
1055
+ if (routeMeters.length === 0)
1055
1056
  return;
1056
1057
  anyRouteDeclaresMeters = true;
1057
- route.meters.forEach((meter, meterIdx) => {
1058
+ routeMeters.forEach((meter, meterIdx) => {
1058
1059
  if (meterKeys.has(meter))
1059
1060
  return;
1060
1061
  ctx.addIssue({
@@ -1064,7 +1065,7 @@ function validateRouteMeters(spec, ctx) {
1064
1065
  featureKey,
1065
1066
  "routes",
1066
1067
  routeIdx,
1067
- "meters",
1068
+ "metering",
1068
1069
  meterIdx
1069
1070
  ],
1070
1071
  message: `Route references unknown meter "${meter}". Declare it in metering.meters[].`
@@ -1076,7 +1077,7 @@ function validateRouteMeters(spec, ctx) {
1076
1077
  ctx.addIssue({
1077
1078
  code: "custom",
1078
1079
  path: ["metering", "meters"],
1079
- message: "One or more routes declare `meters` but `metering.meters[]` is empty. Declare meters at the product level first."
1080
+ message: "One or more routes declare metering keys but `metering.meters[]` is empty. Declare meters at the product level first."
1080
1081
  });
1081
1082
  }
1082
1083
  }
@@ -1093,11 +1094,11 @@ function buildReachableMetersByPlan(spec, runtimeMeters) {
1093
1094
  return out;
1094
1095
  }
1095
1096
  for (const feature of Object.values(spec.features)) {
1096
- addFeatureMetersToReachable(feature, runtimeMeters, out);
1097
+ addFeatureMetersToReachable(feature, out);
1097
1098
  }
1098
1099
  return out;
1099
1100
  }
1100
- function addFeatureMetersToReachable(feature, runtimeMeters, reachableByPlan) {
1101
+ function addFeatureMetersToReachable(feature, reachableByPlan) {
1101
1102
  const grantedPlanKeys = feature.plans ?? [];
1102
1103
  const routes = feature.routes ?? [];
1103
1104
  for (const planKey of grantedPlanKeys) {
@@ -1107,16 +1108,25 @@ function addFeatureMetersToReachable(feature, runtimeMeters, reachableByPlan) {
1107
1108
  for (const route of routes) {
1108
1109
  if (route.unmetered === true)
1109
1110
  continue;
1110
- if (route.meters && route.meters.length > 0) {
1111
- for (const meter of route.meters)
1111
+ const modernRouteMeters = routeMeterKeys(route);
1112
+ if (modernRouteMeters.length > 0) {
1113
+ for (const meter of modernRouteMeters)
1112
1114
  reachable.add(meter);
1113
1115
  continue;
1114
1116
  }
1115
- for (const m of runtimeMeters)
1116
- reachable.add(m);
1117
1117
  }
1118
1118
  }
1119
1119
  }
1120
+ function routeMeterKeys(route) {
1121
+ const keys = /* @__PURE__ */ new Set();
1122
+ for (const key of Object.keys(route.metering?.defaults ?? {}))
1123
+ keys.add(key);
1124
+ for (const key of route.metering?.reports ?? [])
1125
+ keys.add(key);
1126
+ for (const key of Object.keys(route.metering?.estimates ?? {}))
1127
+ keys.add(key);
1128
+ return [...keys];
1129
+ }
1120
1130
  function extractLimitMeterKey(limit) {
1121
1131
  if (!limit || typeof limit !== "object")
1122
1132
  return null;
@@ -1185,6 +1195,10 @@ var meterDefinitionSchema = z13.object({
1185
1195
  // freely. Old specs with `type: ...` parse cleanly because Zod
1186
1196
  // strips unknown fields by default.
1187
1197
  unit: z13.string().max(20).optional(),
1198
+ /** Reusable pre-request estimate for routes that dynamically report this meter. */
1199
+ estimate: z13.number().finite().nonnegative().optional(),
1200
+ /** Fixed per-request default applied by Product SDK helpers. */
1201
+ routeDefault: z13.number().finite().nonnegative().optional(),
1188
1202
  /**
1189
1203
  * Runtime enforcement semantics for this meter. This is compiled into
1190
1204
  * signed gateway artifacts so the edge chooses reservation, settlement,
@@ -1291,6 +1305,15 @@ var usageMeterSchema = z13.object({
1291
1305
  var usageBlockSchema = z13.object({
1292
1306
  meters: z13.record(z13.string().min(1).max(64).regex(/^[a-z0-9_]+$/), usageMeterSchema)
1293
1307
  });
1308
+ var routeMeteringSchema = z13.object({
1309
+ defaults: z13.record(z13.string().min(1).max(64), z13.number().finite().nonnegative()).optional(),
1310
+ reports: z13.array(z13.string().min(1).max(64)).max(20).optional(),
1311
+ estimates: z13.record(z13.string().min(1).max(64), z13.number().finite().nonnegative()).optional(),
1312
+ onStatusCodes: z13.union([
1313
+ z13.string().min(1).max(100),
1314
+ z13.array(z13.number().int().min(100).max(599)).min(1).max(100)
1315
+ ]).optional()
1316
+ });
1294
1317
  var featureRouteSchema = z13.object({
1295
1318
  method: z13.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "*"]).default("*"),
1296
1319
  // Path is the route under the product's baseUrl. OpenAPI parameter
@@ -1301,35 +1324,12 @@ var featureRouteSchema = z13.object({
1301
1324
  // through. The compiler rejects ambiguous compound segments like
1302
1325
  // `/foo/:a-:b` — parameter names must occupy whole segments.
1303
1326
  path: z13.string().min(1).max(500).regex(/^\/[a-zA-Z0-9_/:.{}*-]*$/, "path must start with / and contain only [a-zA-Z0-9_/:.{}*-]"),
1304
- // Optional per-route meter binding (Option B, v0.41.0). When set,
1305
- // the gateway only emits usage events for these meters on requests
1306
- // matching this route — `requests` and token meters get scoped
1307
- // independently per route. Resolution rules:
1308
- // - omitted → route inherits "all configured product meters"
1309
- // (back-compat with manifests written before v0.41)
1310
- // - non-empty → only these meters increment
1311
- // - [] → REJECTED at parse (`ROUTE_METERS_EMPTY_ARRAY`).
1312
- // Use `unmetered: true` for the explicit opt-out.
1313
- // - null → treated as omitted (PATCH-clear UX)
1314
- //
1315
- // Each entry must resolve to a key in `metering.meters[].key`;
1316
- // otherwise the cross-validation refinement
1317
- // `validateRouteMeters` rejects with `UNKNOWN_METER_IN_ROUTE`.
1318
- meters: z13.array(z13.string().min(1).max(64)).min(1, "Use `unmetered: true` instead of an empty array (typo guard against silently-unmetered routes)").max(20).nullable().optional(),
1319
- // Explicit unmetered route. Mutually exclusive with `meters` (the
1320
- // `superRefine` below catches any builder that sets both).
1321
- // Compiles to `entitlement.fr[i].meters: []` on the wire side so
1322
- // the gateway short-circuits both DO consume and event publish.
1323
- unmetered: z13.boolean().optional()
1324
- }).superRefine((route, ctx) => {
1325
- if (route.unmetered === true && route.meters && route.meters.length > 0) {
1326
- ctx.addIssue({
1327
- code: "custom",
1328
- message: "`unmetered: true` is mutually exclusive with `meters` \u2014 drop one",
1329
- path: ["unmetered"]
1330
- });
1331
- }
1332
- });
1327
+ // Explicit no-usage route. Dynamic/static route metering is declared
1328
+ // exclusively under `metering`.
1329
+ unmetered: z13.boolean().optional(),
1330
+ metering: routeMeteringSchema.optional(),
1331
+ inheritDefaultMeters: z13.boolean().optional()
1332
+ }).strict();
1333
1333
  var featureCatalogEntrySchema = z13.object({
1334
1334
  // Optional human-friendly summary; surfaced in dashboards / settings UI.
1335
1335
  description: z13.string().max(500).optional(),
@@ -1465,7 +1465,7 @@ var productSpecSchema = z13.object({
1465
1465
  gracePeriodDays: z13.number().int().nonnegative().default(3),
1466
1466
  // When true (default), a plan limit INCREASE re-projects onto active
1467
1467
  // subscribers immediately; a DECREASE always defers to period end.
1468
- // Read by migrateActiveSubscribersForRuntimeOnlyChange.
1468
+ // Read by advanceActiveSubscribersToLatestCompiledPlans.
1469
1469
  applyLimitUpgradesInstantly: z13.boolean().optional(),
1470
1470
  subscriberChangePolicy: subscriberChangePolicySchema.default({
1471
1471
  default: "preserve_current_period",
@@ -1834,28 +1834,21 @@ var routeMatchSchema = z17.object({
1834
1834
  });
1835
1835
  var routeDefinitionSchema = z17.object({
1836
1836
  match: routeMatchSchema,
1837
- /**
1838
- * Per-route meter binding. Mirrors the legacy `featureRouteSchema`
1839
- * semantics:
1840
- * - omitted → route inherits "all configured product meters"
1841
- * - non-empty → only these meters increment
1842
- * - [] → REJECTED at parse (typo guard)
1843
- * - null → treated as omitted (PATCH-clear UX)
1844
- */
1845
- meters: z17.array(z17.string().min(1).max(64)).min(1, "Use `unmetered: true` instead of an empty array (typo guard against silently-unmetered routes)").max(20).nullable().optional(),
1837
+ metering: z17.object({
1838
+ defaults: z17.record(z17.string().min(1).max(64), z17.number().finite().nonnegative()).optional(),
1839
+ reports: z17.array(z17.string().min(1).max(64)).max(20).optional(),
1840
+ estimates: z17.record(z17.string().min(1).max(64), z17.number().finite().nonnegative()).optional(),
1841
+ onStatusCodes: z17.union([
1842
+ z17.string().min(1).max(100),
1843
+ z17.array(z17.number().int().min(100).max(599)).min(1).max(100)
1844
+ ]).optional()
1845
+ }).optional(),
1846
1846
  unmetered: z17.boolean().optional(),
1847
+ inheritDefaultMeters: z17.boolean().optional(),
1847
1848
  /** Optional explicit action id. When absent, the compiler derives an
1848
1849
  * implicit action from feature + method + path. */
1849
1850
  action: z17.string().min(1).max(160).regex(/^[a-z0-9_.:-]+$/).optional()
1850
- }).superRefine((route, ctx) => {
1851
- if (route.unmetered === true && route.meters && route.meters.length > 0) {
1852
- ctx.addIssue({
1853
- code: "custom",
1854
- message: "`unmetered: true` is mutually exclusive with `meters` \u2014 drop one",
1855
- path: ["unmetered"]
1856
- });
1857
- }
1858
- });
1851
+ }).strict();
1859
1852
  var routeUpstreamSchema = z17.object({
1860
1853
  override_origin: z17.string().url("override_origin must be a valid URL").nullable().default(null)
1861
1854
  });
@@ -2143,7 +2136,7 @@ var productSpecV2Schema = z20.object({
2143
2136
  envBranchPrefix: z20.string().max(50).nullable().optional()
2144
2137
  }),
2145
2138
  /**
2146
- * Meter catalog. Referenced by /routes/*.yaml via `meters: [...]`.
2139
+ * Meter catalog. Referenced by route `metering` metadata.
2147
2140
  * Same shape as the legacy meterDefinitionSchema (re-exported from
2148
2141
  * product.ts to keep one source of truth during the transition).
2149
2142
  *
@@ -2167,7 +2160,7 @@ var productSpecV2Schema = z20.object({
2167
2160
  gracePeriodDays: z20.number().int().nonnegative().default(3),
2168
2161
  // When true (default), a plan limit INCREASE re-projects onto active
2169
2162
  // subscribers immediately; a DECREASE always defers to period end.
2170
- // Read by migrateActiveSubscribersForRuntimeOnlyChange.
2163
+ // Read by advanceActiveSubscribersToLatestCompiledPlans.
2171
2164
  applyLimitUpgradesInstantly: z20.boolean().optional(),
2172
2165
  subscriberChangePolicy: subscriberChangePolicySchema.default(DEFAULT_SUBSCRIBER_CHANGE_POLICY)
2173
2166
  }).default({
@@ -2360,7 +2353,7 @@ function hashIr(ir) {
2360
2353
  }
2361
2354
 
2362
2355
  // src/version.ts
2363
- var SDK_VERSION = true ? "0.2.1" : "0.0.0-dev";
2356
+ var SDK_VERSION = true ? "0.3.1" : "0.0.0-dev";
2364
2357
 
2365
2358
  // src/business.ts
2366
2359
  var BUSINESS_BRAND = Symbol.for("farthershore.product.business");
@@ -2370,6 +2363,9 @@ function isCapabilityGrant(value) {
2370
2363
  function keyOf(ref) {
2371
2364
  return typeof ref === "string" ? ref : ref.key;
2372
2365
  }
2366
+ function displayFromKey(key) {
2367
+ return key.split("_").filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
2368
+ }
2373
2369
  function parseRouteMatch(match) {
2374
2370
  const trimmed = match.trim();
2375
2371
  const space = trimmed.indexOf(" ");
@@ -2410,6 +2406,7 @@ var Business = class {
2410
2406
  name;
2411
2407
  options;
2412
2408
  graph = new ManifestResourceGraph();
2409
+ defaultMeterCosts = [];
2413
2410
  frontendManifest;
2414
2411
  productPatch = {};
2415
2412
  /** Sugar for binding API routes to features. */
@@ -2566,8 +2563,37 @@ var Business = class {
2566
2563
  }
2567
2564
  meter(key, options) {
2568
2565
  this.assertNewKey("meter", key, "meter");
2569
- this.graph.register("meter", key, { key, ...options });
2570
- return { kind: "meter", key };
2566
+ const { routeDefault, ...meterOptions } = options;
2567
+ this.graph.register("meter", key, {
2568
+ key,
2569
+ display: meterOptions.display ?? displayFromKey(key),
2570
+ ...meterOptions
2571
+ });
2572
+ const ref = this.makeMeterRef(key);
2573
+ if (routeDefault !== void 0) {
2574
+ this.defaultMeterCosts.push(
2575
+ this.normalizeMeterCost(ref.fixed(routeDefault))
2576
+ );
2577
+ }
2578
+ return ref;
2579
+ }
2580
+ requests(options = {}) {
2581
+ return this.meter("requests", {
2582
+ display: options.display ?? "Requests",
2583
+ unit: options.unit ?? "request",
2584
+ aggregation: options.aggregation ?? "COUNT",
2585
+ enforcementType: options.enforcementType ?? "estimated_then_settled",
2586
+ window: options.window,
2587
+ estimate: options.estimate ?? 1,
2588
+ routeDefault: 1
2589
+ });
2590
+ }
2591
+ defaultMeters(costs) {
2592
+ const entries = Array.isArray(costs) ? costs : [costs];
2593
+ for (const cost of entries) {
2594
+ this.defaultMeterCosts.push(this.normalizeMeterCost(cost));
2595
+ }
2596
+ return this;
2571
2597
  }
2572
2598
  resource(name, options = {}) {
2573
2599
  this.assertNewKey("counted_resource", name, "resource");
@@ -2740,6 +2766,7 @@ var Business = class {
2740
2766
  * with structured issues when the declared state is invalid. */
2741
2767
  toIR() {
2742
2768
  this.assertGraphDependenciesSatisfied();
2769
+ this.assertRouteMeteringValid();
2743
2770
  const candidate = {
2744
2771
  irVersion: 1,
2745
2772
  sdkVersion: SDK_VERSION,
@@ -2781,10 +2808,7 @@ var Business = class {
2781
2808
  ...options.upstreamAuth !== void 0 ? { upstreamAuth: options.upstreamAuth } : {}
2782
2809
  },
2783
2810
  metering: {
2784
- meters: this.graph.sortedValues(
2785
- "meter",
2786
- (meter) => meter.key
2787
- ),
2811
+ meters: this.buildMeterDefinitions(),
2788
2812
  ...options.billOn4xx !== void 0 ? { billOn4xx: options.billOn4xx } : {}
2789
2813
  },
2790
2814
  ...options.billing !== void 0 ? { billing: options.billing } : {},
@@ -2815,15 +2839,109 @@ var Business = class {
2815
2839
  this.productPatch
2816
2840
  );
2817
2841
  }
2842
+ buildMeterDefinitions() {
2843
+ const routeValueMeters = this.routeValueMeterKeys();
2844
+ return this.graph.sortedValues("meter", (meter) => meter.key).map((meter) => {
2845
+ if (meter.aggregation !== void 0) return meter;
2846
+ if (!routeValueMeters.has(meter.key)) return meter;
2847
+ return { ...meter, aggregation: "SUM" };
2848
+ });
2849
+ }
2850
+ routeValueMeterKeys() {
2851
+ const keys = /* @__PURE__ */ new Set();
2852
+ for (const file of this.graph.values("feature")) {
2853
+ for (const route of file.routes) {
2854
+ for (const meter of Object.keys(route.metering?.defaults ?? {})) {
2855
+ keys.add(meter);
2856
+ }
2857
+ for (const meter of route.metering?.reports ?? []) {
2858
+ keys.add(meter);
2859
+ }
2860
+ }
2861
+ }
2862
+ return keys;
2863
+ }
2818
2864
  buildRoute(match, options) {
2819
2865
  const parsed = parseRouteMatch(match);
2866
+ const metering = this.buildRouteMetering(options);
2820
2867
  return {
2821
2868
  match: parsed,
2822
- ...options.meters?.length ? { meters: options.meters.map(keyOf) } : {},
2869
+ ...metering ? { metering } : {},
2823
2870
  ...options.unmetered !== void 0 ? { unmetered: options.unmetered } : {},
2871
+ ...options.inheritDefaultMeters !== void 0 ? { inheritDefaultMeters: options.inheritDefaultMeters } : {},
2824
2872
  ...options.action !== void 0 ? { action: keyOf(options.action) } : {}
2825
2873
  };
2826
2874
  }
2875
+ makeMeterRef(key) {
2876
+ return {
2877
+ kind: "meter",
2878
+ key,
2879
+ fixed: (value) => ({ kind: "meter_cost", meter: key, value }),
2880
+ estimate: (value) => ({ kind: "meter_cost", meter: key, value })
2881
+ };
2882
+ }
2883
+ buildRouteMetering(options) {
2884
+ if (options.unmetered === true) return void 0;
2885
+ const defaults = {};
2886
+ if (options.inheritDefaultMeters !== false) {
2887
+ for (const cost of this.defaultMeterCosts) {
2888
+ defaults[cost.meter] = (defaults[cost.meter] ?? 0) + cost.value;
2889
+ }
2890
+ }
2891
+ for (const cost of this.normalizeMeterCosts(options.costs)) {
2892
+ defaults[cost.meter] = (defaults[cost.meter] ?? 0) + cost.value;
2893
+ }
2894
+ const reports = [
2895
+ ...new Set(this.normalizeMeterRefs(options.reports ?? []))
2896
+ ];
2897
+ const estimates = {};
2898
+ for (const meter of reports) {
2899
+ const definition = this.graph.get(
2900
+ "meter",
2901
+ meter
2902
+ )?.value;
2903
+ if (typeof definition?.estimate === "number") {
2904
+ estimates[meter] = definition.estimate;
2905
+ }
2906
+ }
2907
+ for (const [meter, value] of Object.entries(options.estimates ?? {})) {
2908
+ estimates[meter] = value;
2909
+ }
2910
+ const out = {};
2911
+ if (Object.keys(defaults).length) out.defaults = defaults;
2912
+ if (reports.length) out.reports = reports;
2913
+ if (Object.keys(estimates).length) out.estimates = estimates;
2914
+ if (options.onStatusCodes !== void 0)
2915
+ out.onStatusCodes = options.onStatusCodes;
2916
+ return Object.keys(out).length ? out : void 0;
2917
+ }
2918
+ normalizeMeterCost(cost) {
2919
+ if (!cost || cost.kind !== "meter_cost") {
2920
+ throw new ManifestBuilderError(
2921
+ "meter cost must be created by meter.fixed(value)"
2922
+ );
2923
+ }
2924
+ if (!this.graph.has("meter", cost.meter)) {
2925
+ throw new ManifestBuilderError(
2926
+ `meter cost references unknown meter "${cost.meter}"`
2927
+ );
2928
+ }
2929
+ if (!Number.isFinite(cost.value) || cost.value < 0) {
2930
+ throw new ManifestBuilderError(
2931
+ `meter "${cost.meter}" fixed value must be a non-negative finite number`
2932
+ );
2933
+ }
2934
+ return cost;
2935
+ }
2936
+ normalizeMeterCosts(costs) {
2937
+ if (!costs) return [];
2938
+ const entries = Array.isArray(costs) ? costs : [costs];
2939
+ return entries.map((cost) => this.normalizeMeterCost(cost));
2940
+ }
2941
+ normalizeMeterRefs(refs) {
2942
+ const entries = Array.isArray(refs) ? refs : [refs];
2943
+ return entries.map(keyOf);
2944
+ }
2827
2945
  ensureFrontendManifest() {
2828
2946
  this.frontendManifest ??= { version: 1, nav: [], pages: [] };
2829
2947
  this.syncFrontendGraphNode(this.frontendManifest);
@@ -2922,7 +3040,9 @@ var Business = class {
2922
3040
  return this.dependenciesFor("meter", file.compatible_with?.meters);
2923
3041
  }
2924
3042
  featureDependsOn(file) {
2925
- const meterKeys = file.routes.flatMap((route) => route.meters ?? []);
3043
+ const meterKeys = file.routes.flatMap(
3044
+ (route) => this.routeMeterDependencyKeys(route)
3045
+ );
2926
3046
  return [
2927
3047
  ...this.dependenciesFor("policy", file.policies),
2928
3048
  ...this.dependenciesFor("capability", file.capabilities),
@@ -2930,6 +3050,48 @@ var Business = class {
2930
3050
  ...this.dependenciesFor("meter", meterKeys)
2931
3051
  ];
2932
3052
  }
3053
+ routeMeterDependencyKeys(route) {
3054
+ const keys = /* @__PURE__ */ new Set();
3055
+ for (const meter of Object.keys(route.metering?.defaults ?? {})) {
3056
+ keys.add(meter);
3057
+ }
3058
+ for (const meter of route.metering?.reports ?? []) keys.add(meter);
3059
+ for (const meter of Object.keys(route.metering?.estimates ?? {})) {
3060
+ keys.add(meter);
3061
+ }
3062
+ return [...keys];
3063
+ }
3064
+ assertRouteMeteringValid() {
3065
+ for (const file of this.graph.values("feature")) {
3066
+ file.routes.forEach((route, routeIndex) => {
3067
+ if (route.unmetered === true) return;
3068
+ const defaults = new Set(Object.keys(route.metering?.defaults ?? {}));
3069
+ for (const meter of route.metering?.reports ?? []) {
3070
+ if (defaults.has(meter)) {
3071
+ throw new ManifestBuilderError(
3072
+ `feature "${file.feature}" route ${routeIndex}: meter "${meter}" cannot be both a fixed route cost and a dynamic report`
3073
+ );
3074
+ }
3075
+ }
3076
+ for (const meter of route.metering?.reports ?? []) {
3077
+ const definition = this.graph.get(
3078
+ "meter",
3079
+ meter
3080
+ )?.value;
3081
+ const enforcement = definition?.enforcementType ?? "estimated_then_settled";
3082
+ const hasEstimate = route.metering?.estimates && Object.prototype.hasOwnProperty.call(
3083
+ route.metering.estimates,
3084
+ meter
3085
+ );
3086
+ if ((enforcement === "exact_pre_request" || enforcement === "estimated_then_settled") && !hasEstimate) {
3087
+ throw new ManifestBuilderError(
3088
+ `feature "${file.feature}" route ${routeIndex}: reported meter "${meter}" needs an estimate for gateway admission`
3089
+ );
3090
+ }
3091
+ }
3092
+ });
3093
+ }
3094
+ }
2933
3095
  actionDependsOn(featureKey, action) {
2934
3096
  return [
2935
3097
  resourceDependency("feature", featureKey),
package/dist/codegen.js CHANGED
@@ -143,8 +143,16 @@ function generateManifestSource(ir) {
143
143
  options.requiredFlags = file.runtime.required_flags;
144
144
  options.routes = file.routes.map((route) => ({
145
145
  match: `${route.match.method ?? "*"} ${route.match.path}`,
146
- ...route.meters && route.meters.length ? { meters: route.meters } : {},
146
+ ...route.metering?.reports?.length ? { reports: route.metering.reports } : {},
147
+ ...route.metering?.defaults && Object.keys(route.metering.defaults).length ? {
148
+ costs: Object.entries(route.metering.defaults).map(
149
+ ([meter, value]) => ({ kind: "meter_cost", meter, value })
150
+ )
151
+ } : {},
152
+ ...route.metering?.estimates && Object.keys(route.metering.estimates).length ? { estimates: route.metering.estimates } : {},
153
+ ...route.metering?.onStatusCodes !== void 0 ? { onStatusCodes: route.metering.onStatusCodes } : {},
147
154
  ...route.unmetered !== void 0 ? { unmetered: route.unmetered } : {},
155
+ ...route.inheritDefaultMeters !== void 0 ? { inheritDefaultMeters: route.inheritDefaultMeters } : {},
148
156
  ...route.action !== void 0 ? { action: route.action } : {}
149
157
  }));
150
158
  lines.push(`business.feature(${lit(file.feature)}, ${lit(options, 0)});`);
package/dist/index.js CHANGED
@@ -1043,10 +1043,11 @@ function validateRouteMeters(spec, ctx) {
1043
1043
  for (const [featureKey, feature] of Object.entries(spec.features)) {
1044
1044
  const routes = feature.routes ?? [];
1045
1045
  routes.forEach((route, routeIdx) => {
1046
- if (!route.meters || route.meters.length === 0)
1046
+ const routeMeters = routeMeterKeys(route);
1047
+ if (routeMeters.length === 0)
1047
1048
  return;
1048
1049
  anyRouteDeclaresMeters = true;
1049
- route.meters.forEach((meter, meterIdx) => {
1050
+ routeMeters.forEach((meter, meterIdx) => {
1050
1051
  if (meterKeys.has(meter))
1051
1052
  return;
1052
1053
  ctx.addIssue({
@@ -1056,7 +1057,7 @@ function validateRouteMeters(spec, ctx) {
1056
1057
  featureKey,
1057
1058
  "routes",
1058
1059
  routeIdx,
1059
- "meters",
1060
+ "metering",
1060
1061
  meterIdx
1061
1062
  ],
1062
1063
  message: `Route references unknown meter "${meter}". Declare it in metering.meters[].`
@@ -1068,7 +1069,7 @@ function validateRouteMeters(spec, ctx) {
1068
1069
  ctx.addIssue({
1069
1070
  code: "custom",
1070
1071
  path: ["metering", "meters"],
1071
- message: "One or more routes declare `meters` but `metering.meters[]` is empty. Declare meters at the product level first."
1072
+ message: "One or more routes declare metering keys but `metering.meters[]` is empty. Declare meters at the product level first."
1072
1073
  });
1073
1074
  }
1074
1075
  }
@@ -1085,11 +1086,11 @@ function buildReachableMetersByPlan(spec, runtimeMeters) {
1085
1086
  return out;
1086
1087
  }
1087
1088
  for (const feature of Object.values(spec.features)) {
1088
- addFeatureMetersToReachable(feature, runtimeMeters, out);
1089
+ addFeatureMetersToReachable(feature, out);
1089
1090
  }
1090
1091
  return out;
1091
1092
  }
1092
- function addFeatureMetersToReachable(feature, runtimeMeters, reachableByPlan) {
1093
+ function addFeatureMetersToReachable(feature, reachableByPlan) {
1093
1094
  const grantedPlanKeys = feature.plans ?? [];
1094
1095
  const routes = feature.routes ?? [];
1095
1096
  for (const planKey of grantedPlanKeys) {
@@ -1099,16 +1100,25 @@ function addFeatureMetersToReachable(feature, runtimeMeters, reachableByPlan) {
1099
1100
  for (const route of routes) {
1100
1101
  if (route.unmetered === true)
1101
1102
  continue;
1102
- if (route.meters && route.meters.length > 0) {
1103
- for (const meter of route.meters)
1103
+ const modernRouteMeters = routeMeterKeys(route);
1104
+ if (modernRouteMeters.length > 0) {
1105
+ for (const meter of modernRouteMeters)
1104
1106
  reachable.add(meter);
1105
1107
  continue;
1106
1108
  }
1107
- for (const m of runtimeMeters)
1108
- reachable.add(m);
1109
1109
  }
1110
1110
  }
1111
1111
  }
1112
+ function routeMeterKeys(route) {
1113
+ const keys = /* @__PURE__ */ new Set();
1114
+ for (const key of Object.keys(route.metering?.defaults ?? {}))
1115
+ keys.add(key);
1116
+ for (const key of route.metering?.reports ?? [])
1117
+ keys.add(key);
1118
+ for (const key of Object.keys(route.metering?.estimates ?? {}))
1119
+ keys.add(key);
1120
+ return [...keys];
1121
+ }
1112
1122
  function extractLimitMeterKey(limit) {
1113
1123
  if (!limit || typeof limit !== "object")
1114
1124
  return null;
@@ -1177,6 +1187,10 @@ var meterDefinitionSchema = z13.object({
1177
1187
  // freely. Old specs with `type: ...` parse cleanly because Zod
1178
1188
  // strips unknown fields by default.
1179
1189
  unit: z13.string().max(20).optional(),
1190
+ /** Reusable pre-request estimate for routes that dynamically report this meter. */
1191
+ estimate: z13.number().finite().nonnegative().optional(),
1192
+ /** Fixed per-request default applied by Product SDK helpers. */
1193
+ routeDefault: z13.number().finite().nonnegative().optional(),
1180
1194
  /**
1181
1195
  * Runtime enforcement semantics for this meter. This is compiled into
1182
1196
  * signed gateway artifacts so the edge chooses reservation, settlement,
@@ -1283,6 +1297,15 @@ var usageMeterSchema = z13.object({
1283
1297
  var usageBlockSchema = z13.object({
1284
1298
  meters: z13.record(z13.string().min(1).max(64).regex(/^[a-z0-9_]+$/), usageMeterSchema)
1285
1299
  });
1300
+ var routeMeteringSchema = z13.object({
1301
+ defaults: z13.record(z13.string().min(1).max(64), z13.number().finite().nonnegative()).optional(),
1302
+ reports: z13.array(z13.string().min(1).max(64)).max(20).optional(),
1303
+ estimates: z13.record(z13.string().min(1).max(64), z13.number().finite().nonnegative()).optional(),
1304
+ onStatusCodes: z13.union([
1305
+ z13.string().min(1).max(100),
1306
+ z13.array(z13.number().int().min(100).max(599)).min(1).max(100)
1307
+ ]).optional()
1308
+ });
1286
1309
  var featureRouteSchema = z13.object({
1287
1310
  method: z13.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "*"]).default("*"),
1288
1311
  // Path is the route under the product's baseUrl. OpenAPI parameter
@@ -1293,35 +1316,12 @@ var featureRouteSchema = z13.object({
1293
1316
  // through. The compiler rejects ambiguous compound segments like
1294
1317
  // `/foo/:a-:b` — parameter names must occupy whole segments.
1295
1318
  path: z13.string().min(1).max(500).regex(/^\/[a-zA-Z0-9_/:.{}*-]*$/, "path must start with / and contain only [a-zA-Z0-9_/:.{}*-]"),
1296
- // Optional per-route meter binding (Option B, v0.41.0). When set,
1297
- // the gateway only emits usage events for these meters on requests
1298
- // matching this route — `requests` and token meters get scoped
1299
- // independently per route. Resolution rules:
1300
- // - omitted → route inherits "all configured product meters"
1301
- // (back-compat with manifests written before v0.41)
1302
- // - non-empty → only these meters increment
1303
- // - [] → REJECTED at parse (`ROUTE_METERS_EMPTY_ARRAY`).
1304
- // Use `unmetered: true` for the explicit opt-out.
1305
- // - null → treated as omitted (PATCH-clear UX)
1306
- //
1307
- // Each entry must resolve to a key in `metering.meters[].key`;
1308
- // otherwise the cross-validation refinement
1309
- // `validateRouteMeters` rejects with `UNKNOWN_METER_IN_ROUTE`.
1310
- meters: z13.array(z13.string().min(1).max(64)).min(1, "Use `unmetered: true` instead of an empty array (typo guard against silently-unmetered routes)").max(20).nullable().optional(),
1311
- // Explicit unmetered route. Mutually exclusive with `meters` (the
1312
- // `superRefine` below catches any builder that sets both).
1313
- // Compiles to `entitlement.fr[i].meters: []` on the wire side so
1314
- // the gateway short-circuits both DO consume and event publish.
1315
- unmetered: z13.boolean().optional()
1316
- }).superRefine((route, ctx) => {
1317
- if (route.unmetered === true && route.meters && route.meters.length > 0) {
1318
- ctx.addIssue({
1319
- code: "custom",
1320
- message: "`unmetered: true` is mutually exclusive with `meters` \u2014 drop one",
1321
- path: ["unmetered"]
1322
- });
1323
- }
1324
- });
1319
+ // Explicit no-usage route. Dynamic/static route metering is declared
1320
+ // exclusively under `metering`.
1321
+ unmetered: z13.boolean().optional(),
1322
+ metering: routeMeteringSchema.optional(),
1323
+ inheritDefaultMeters: z13.boolean().optional()
1324
+ }).strict();
1325
1325
  var featureCatalogEntrySchema = z13.object({
1326
1326
  // Optional human-friendly summary; surfaced in dashboards / settings UI.
1327
1327
  description: z13.string().max(500).optional(),
@@ -1457,7 +1457,7 @@ var productSpecSchema = z13.object({
1457
1457
  gracePeriodDays: z13.number().int().nonnegative().default(3),
1458
1458
  // When true (default), a plan limit INCREASE re-projects onto active
1459
1459
  // subscribers immediately; a DECREASE always defers to period end.
1460
- // Read by migrateActiveSubscribersForRuntimeOnlyChange.
1460
+ // Read by advanceActiveSubscribersToLatestCompiledPlans.
1461
1461
  applyLimitUpgradesInstantly: z13.boolean().optional(),
1462
1462
  subscriberChangePolicy: subscriberChangePolicySchema.default({
1463
1463
  default: "preserve_current_period",
@@ -1826,28 +1826,21 @@ var routeMatchSchema = z17.object({
1826
1826
  });
1827
1827
  var routeDefinitionSchema = z17.object({
1828
1828
  match: routeMatchSchema,
1829
- /**
1830
- * Per-route meter binding. Mirrors the legacy `featureRouteSchema`
1831
- * semantics:
1832
- * - omitted → route inherits "all configured product meters"
1833
- * - non-empty → only these meters increment
1834
- * - [] → REJECTED at parse (typo guard)
1835
- * - null → treated as omitted (PATCH-clear UX)
1836
- */
1837
- meters: z17.array(z17.string().min(1).max(64)).min(1, "Use `unmetered: true` instead of an empty array (typo guard against silently-unmetered routes)").max(20).nullable().optional(),
1829
+ metering: z17.object({
1830
+ defaults: z17.record(z17.string().min(1).max(64), z17.number().finite().nonnegative()).optional(),
1831
+ reports: z17.array(z17.string().min(1).max(64)).max(20).optional(),
1832
+ estimates: z17.record(z17.string().min(1).max(64), z17.number().finite().nonnegative()).optional(),
1833
+ onStatusCodes: z17.union([
1834
+ z17.string().min(1).max(100),
1835
+ z17.array(z17.number().int().min(100).max(599)).min(1).max(100)
1836
+ ]).optional()
1837
+ }).optional(),
1838
1838
  unmetered: z17.boolean().optional(),
1839
+ inheritDefaultMeters: z17.boolean().optional(),
1839
1840
  /** Optional explicit action id. When absent, the compiler derives an
1840
1841
  * implicit action from feature + method + path. */
1841
1842
  action: z17.string().min(1).max(160).regex(/^[a-z0-9_.:-]+$/).optional()
1842
- }).superRefine((route, ctx) => {
1843
- if (route.unmetered === true && route.meters && route.meters.length > 0) {
1844
- ctx.addIssue({
1845
- code: "custom",
1846
- message: "`unmetered: true` is mutually exclusive with `meters` \u2014 drop one",
1847
- path: ["unmetered"]
1848
- });
1849
- }
1850
- });
1843
+ }).strict();
1851
1844
  var routeUpstreamSchema = z17.object({
1852
1845
  override_origin: z17.string().url("override_origin must be a valid URL").nullable().default(null)
1853
1846
  });
@@ -2135,7 +2128,7 @@ var productSpecV2Schema = z20.object({
2135
2128
  envBranchPrefix: z20.string().max(50).nullable().optional()
2136
2129
  }),
2137
2130
  /**
2138
- * Meter catalog. Referenced by /routes/*.yaml via `meters: [...]`.
2131
+ * Meter catalog. Referenced by route `metering` metadata.
2139
2132
  * Same shape as the legacy meterDefinitionSchema (re-exported from
2140
2133
  * product.ts to keep one source of truth during the transition).
2141
2134
  *
@@ -2159,7 +2152,7 @@ var productSpecV2Schema = z20.object({
2159
2152
  gracePeriodDays: z20.number().int().nonnegative().default(3),
2160
2153
  // When true (default), a plan limit INCREASE re-projects onto active
2161
2154
  // subscribers immediately; a DECREASE always defers to period end.
2162
- // Read by migrateActiveSubscribersForRuntimeOnlyChange.
2155
+ // Read by advanceActiveSubscribersToLatestCompiledPlans.
2163
2156
  applyLimitUpgradesInstantly: z20.boolean().optional(),
2164
2157
  subscriberChangePolicy: subscriberChangePolicySchema.default(DEFAULT_SUBSCRIBER_CHANGE_POLICY)
2165
2158
  }).default({
@@ -2355,7 +2348,7 @@ function canonicalIrJson(ir) {
2355
2348
  }
2356
2349
 
2357
2350
  // src/version.ts
2358
- var SDK_VERSION = true ? "0.2.1" : "0.0.0-dev";
2351
+ var SDK_VERSION = true ? "0.3.1" : "0.0.0-dev";
2359
2352
 
2360
2353
  // src/business.ts
2361
2354
  var BUSINESS_BRAND = Symbol.for("farthershore.product.business");
@@ -2365,6 +2358,9 @@ function isCapabilityGrant(value) {
2365
2358
  function keyOf(ref) {
2366
2359
  return typeof ref === "string" ? ref : ref.key;
2367
2360
  }
2361
+ function displayFromKey(key) {
2362
+ return key.split("_").filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
2363
+ }
2368
2364
  function parseRouteMatch(match) {
2369
2365
  const trimmed = match.trim();
2370
2366
  const space = trimmed.indexOf(" ");
@@ -2405,6 +2401,7 @@ var Business = class {
2405
2401
  name;
2406
2402
  options;
2407
2403
  graph = new ManifestResourceGraph();
2404
+ defaultMeterCosts = [];
2408
2405
  frontendManifest;
2409
2406
  productPatch = {};
2410
2407
  /** Sugar for binding API routes to features. */
@@ -2561,8 +2558,37 @@ var Business = class {
2561
2558
  }
2562
2559
  meter(key, options) {
2563
2560
  this.assertNewKey("meter", key, "meter");
2564
- this.graph.register("meter", key, { key, ...options });
2565
- return { kind: "meter", key };
2561
+ const { routeDefault, ...meterOptions } = options;
2562
+ this.graph.register("meter", key, {
2563
+ key,
2564
+ display: meterOptions.display ?? displayFromKey(key),
2565
+ ...meterOptions
2566
+ });
2567
+ const ref = this.makeMeterRef(key);
2568
+ if (routeDefault !== void 0) {
2569
+ this.defaultMeterCosts.push(
2570
+ this.normalizeMeterCost(ref.fixed(routeDefault))
2571
+ );
2572
+ }
2573
+ return ref;
2574
+ }
2575
+ requests(options = {}) {
2576
+ return this.meter("requests", {
2577
+ display: options.display ?? "Requests",
2578
+ unit: options.unit ?? "request",
2579
+ aggregation: options.aggregation ?? "COUNT",
2580
+ enforcementType: options.enforcementType ?? "estimated_then_settled",
2581
+ window: options.window,
2582
+ estimate: options.estimate ?? 1,
2583
+ routeDefault: 1
2584
+ });
2585
+ }
2586
+ defaultMeters(costs) {
2587
+ const entries = Array.isArray(costs) ? costs : [costs];
2588
+ for (const cost of entries) {
2589
+ this.defaultMeterCosts.push(this.normalizeMeterCost(cost));
2590
+ }
2591
+ return this;
2566
2592
  }
2567
2593
  resource(name, options = {}) {
2568
2594
  this.assertNewKey("counted_resource", name, "resource");
@@ -2735,6 +2761,7 @@ var Business = class {
2735
2761
  * with structured issues when the declared state is invalid. */
2736
2762
  toIR() {
2737
2763
  this.assertGraphDependenciesSatisfied();
2764
+ this.assertRouteMeteringValid();
2738
2765
  const candidate = {
2739
2766
  irVersion: 1,
2740
2767
  sdkVersion: SDK_VERSION,
@@ -2776,10 +2803,7 @@ var Business = class {
2776
2803
  ...options.upstreamAuth !== void 0 ? { upstreamAuth: options.upstreamAuth } : {}
2777
2804
  },
2778
2805
  metering: {
2779
- meters: this.graph.sortedValues(
2780
- "meter",
2781
- (meter) => meter.key
2782
- ),
2806
+ meters: this.buildMeterDefinitions(),
2783
2807
  ...options.billOn4xx !== void 0 ? { billOn4xx: options.billOn4xx } : {}
2784
2808
  },
2785
2809
  ...options.billing !== void 0 ? { billing: options.billing } : {},
@@ -2810,15 +2834,109 @@ var Business = class {
2810
2834
  this.productPatch
2811
2835
  );
2812
2836
  }
2837
+ buildMeterDefinitions() {
2838
+ const routeValueMeters = this.routeValueMeterKeys();
2839
+ return this.graph.sortedValues("meter", (meter) => meter.key).map((meter) => {
2840
+ if (meter.aggregation !== void 0) return meter;
2841
+ if (!routeValueMeters.has(meter.key)) return meter;
2842
+ return { ...meter, aggregation: "SUM" };
2843
+ });
2844
+ }
2845
+ routeValueMeterKeys() {
2846
+ const keys = /* @__PURE__ */ new Set();
2847
+ for (const file of this.graph.values("feature")) {
2848
+ for (const route of file.routes) {
2849
+ for (const meter of Object.keys(route.metering?.defaults ?? {})) {
2850
+ keys.add(meter);
2851
+ }
2852
+ for (const meter of route.metering?.reports ?? []) {
2853
+ keys.add(meter);
2854
+ }
2855
+ }
2856
+ }
2857
+ return keys;
2858
+ }
2813
2859
  buildRoute(match, options) {
2814
2860
  const parsed = parseRouteMatch(match);
2861
+ const metering = this.buildRouteMetering(options);
2815
2862
  return {
2816
2863
  match: parsed,
2817
- ...options.meters?.length ? { meters: options.meters.map(keyOf) } : {},
2864
+ ...metering ? { metering } : {},
2818
2865
  ...options.unmetered !== void 0 ? { unmetered: options.unmetered } : {},
2866
+ ...options.inheritDefaultMeters !== void 0 ? { inheritDefaultMeters: options.inheritDefaultMeters } : {},
2819
2867
  ...options.action !== void 0 ? { action: keyOf(options.action) } : {}
2820
2868
  };
2821
2869
  }
2870
+ makeMeterRef(key) {
2871
+ return {
2872
+ kind: "meter",
2873
+ key,
2874
+ fixed: (value) => ({ kind: "meter_cost", meter: key, value }),
2875
+ estimate: (value) => ({ kind: "meter_cost", meter: key, value })
2876
+ };
2877
+ }
2878
+ buildRouteMetering(options) {
2879
+ if (options.unmetered === true) return void 0;
2880
+ const defaults = {};
2881
+ if (options.inheritDefaultMeters !== false) {
2882
+ for (const cost of this.defaultMeterCosts) {
2883
+ defaults[cost.meter] = (defaults[cost.meter] ?? 0) + cost.value;
2884
+ }
2885
+ }
2886
+ for (const cost of this.normalizeMeterCosts(options.costs)) {
2887
+ defaults[cost.meter] = (defaults[cost.meter] ?? 0) + cost.value;
2888
+ }
2889
+ const reports = [
2890
+ ...new Set(this.normalizeMeterRefs(options.reports ?? []))
2891
+ ];
2892
+ const estimates = {};
2893
+ for (const meter of reports) {
2894
+ const definition = this.graph.get(
2895
+ "meter",
2896
+ meter
2897
+ )?.value;
2898
+ if (typeof definition?.estimate === "number") {
2899
+ estimates[meter] = definition.estimate;
2900
+ }
2901
+ }
2902
+ for (const [meter, value] of Object.entries(options.estimates ?? {})) {
2903
+ estimates[meter] = value;
2904
+ }
2905
+ const out = {};
2906
+ if (Object.keys(defaults).length) out.defaults = defaults;
2907
+ if (reports.length) out.reports = reports;
2908
+ if (Object.keys(estimates).length) out.estimates = estimates;
2909
+ if (options.onStatusCodes !== void 0)
2910
+ out.onStatusCodes = options.onStatusCodes;
2911
+ return Object.keys(out).length ? out : void 0;
2912
+ }
2913
+ normalizeMeterCost(cost) {
2914
+ if (!cost || cost.kind !== "meter_cost") {
2915
+ throw new ManifestBuilderError(
2916
+ "meter cost must be created by meter.fixed(value)"
2917
+ );
2918
+ }
2919
+ if (!this.graph.has("meter", cost.meter)) {
2920
+ throw new ManifestBuilderError(
2921
+ `meter cost references unknown meter "${cost.meter}"`
2922
+ );
2923
+ }
2924
+ if (!Number.isFinite(cost.value) || cost.value < 0) {
2925
+ throw new ManifestBuilderError(
2926
+ `meter "${cost.meter}" fixed value must be a non-negative finite number`
2927
+ );
2928
+ }
2929
+ return cost;
2930
+ }
2931
+ normalizeMeterCosts(costs) {
2932
+ if (!costs) return [];
2933
+ const entries = Array.isArray(costs) ? costs : [costs];
2934
+ return entries.map((cost) => this.normalizeMeterCost(cost));
2935
+ }
2936
+ normalizeMeterRefs(refs) {
2937
+ const entries = Array.isArray(refs) ? refs : [refs];
2938
+ return entries.map(keyOf);
2939
+ }
2822
2940
  ensureFrontendManifest() {
2823
2941
  this.frontendManifest ??= { version: 1, nav: [], pages: [] };
2824
2942
  this.syncFrontendGraphNode(this.frontendManifest);
@@ -2917,7 +3035,9 @@ var Business = class {
2917
3035
  return this.dependenciesFor("meter", file.compatible_with?.meters);
2918
3036
  }
2919
3037
  featureDependsOn(file) {
2920
- const meterKeys = file.routes.flatMap((route) => route.meters ?? []);
3038
+ const meterKeys = file.routes.flatMap(
3039
+ (route) => this.routeMeterDependencyKeys(route)
3040
+ );
2921
3041
  return [
2922
3042
  ...this.dependenciesFor("policy", file.policies),
2923
3043
  ...this.dependenciesFor("capability", file.capabilities),
@@ -2925,6 +3045,48 @@ var Business = class {
2925
3045
  ...this.dependenciesFor("meter", meterKeys)
2926
3046
  ];
2927
3047
  }
3048
+ routeMeterDependencyKeys(route) {
3049
+ const keys = /* @__PURE__ */ new Set();
3050
+ for (const meter of Object.keys(route.metering?.defaults ?? {})) {
3051
+ keys.add(meter);
3052
+ }
3053
+ for (const meter of route.metering?.reports ?? []) keys.add(meter);
3054
+ for (const meter of Object.keys(route.metering?.estimates ?? {})) {
3055
+ keys.add(meter);
3056
+ }
3057
+ return [...keys];
3058
+ }
3059
+ assertRouteMeteringValid() {
3060
+ for (const file of this.graph.values("feature")) {
3061
+ file.routes.forEach((route, routeIndex) => {
3062
+ if (route.unmetered === true) return;
3063
+ const defaults = new Set(Object.keys(route.metering?.defaults ?? {}));
3064
+ for (const meter of route.metering?.reports ?? []) {
3065
+ if (defaults.has(meter)) {
3066
+ throw new ManifestBuilderError(
3067
+ `feature "${file.feature}" route ${routeIndex}: meter "${meter}" cannot be both a fixed route cost and a dynamic report`
3068
+ );
3069
+ }
3070
+ }
3071
+ for (const meter of route.metering?.reports ?? []) {
3072
+ const definition = this.graph.get(
3073
+ "meter",
3074
+ meter
3075
+ )?.value;
3076
+ const enforcement = definition?.enforcementType ?? "estimated_then_settled";
3077
+ const hasEstimate = route.metering?.estimates && Object.prototype.hasOwnProperty.call(
3078
+ route.metering.estimates,
3079
+ meter
3080
+ );
3081
+ if ((enforcement === "exact_pre_request" || enforcement === "estimated_then_settled") && !hasEstimate) {
3082
+ throw new ManifestBuilderError(
3083
+ `feature "${file.feature}" route ${routeIndex}: reported meter "${meter}" needs an estimate for gateway admission`
3084
+ );
3085
+ }
3086
+ }
3087
+ });
3088
+ }
3089
+ }
2928
3090
  actionDependsOn(featureKey, action) {
2929
3091
  return [
2930
3092
  resourceDependency("feature", featureKey),
@@ -34,7 +34,14 @@ export type BusinessOptions = {
34
34
  applyLimitUpgradesInstantly?: boolean;
35
35
  };
36
36
  };
37
- export type MeterOptions = Omit<MeterDefinitionJson, "key">;
37
+ export type MeterOptions = Omit<MeterDefinitionJson, "key" | "display"> & {
38
+ display?: string;
39
+ /** Reusable pre-request estimate for dynamic route reports. */
40
+ estimate?: number;
41
+ /** Fixed value applied to every metered route unless the route opts out. */
42
+ routeDefault?: number;
43
+ };
44
+ export type RequestMeterOptions = Partial<Omit<MeterOptions, "routeDefault">>;
38
45
  export type CapabilityOptions = {
39
46
  title?: string;
40
47
  description?: string;
@@ -43,9 +50,24 @@ export type CapabilityOptions = {
43
50
  includesPolicies?: Array<string | PolicyRef>;
44
51
  includesCapabilities?: Array<string | CapabilityRef>;
45
52
  };
53
+ export type MeterCost = {
54
+ readonly kind: "meter_cost";
55
+ readonly meter: string;
56
+ readonly value: number;
57
+ };
46
58
  export type RouteOptions = {
47
- /** Meter keys this route increments. Omitted = all product meters. */
48
- meters?: Array<string | MeterRef>;
59
+ /**
60
+ * Dynamic meter keys the upstream may report with @farthershore/metering.
61
+ */
62
+ reports?: string | MeterRef | Array<string | MeterRef>;
63
+ /** Fixed gateway-known route costs. */
64
+ costs?: MeterCost | Array<MeterCost>;
65
+ /** Route-specific pre-request estimates for dynamic reports. */
66
+ estimates?: Record<string, number>;
67
+ /** Override the default successful-response range. */
68
+ onStatusCodes?: string | number[];
69
+ /** Disable product.defaultMeters()/product.requests() for this route only. */
70
+ inheritDefaultMeters?: boolean;
49
71
  unmetered?: boolean;
50
72
  action?: string | ActionRef;
51
73
  };
@@ -103,7 +125,7 @@ export type FeatureOptions = {
103
125
  actions?: Array<{
104
126
  id: string;
105
127
  } & ActionOptions>;
106
- /** Routes, e.g. `{ match: "POST /v1/jobs", meters: ["requests"] }`. */
128
+ /** Routes, e.g. `{ match: "POST /v1/jobs", reports: [tokens] }`. */
107
129
  routes?: Array<{
108
130
  match: string;
109
131
  } & RouteOptions>;
@@ -152,6 +174,10 @@ export type PlanOptions = {
152
174
  export type MeterRef = {
153
175
  readonly kind: "meter";
154
176
  readonly key: string;
177
+ /** Fixed gateway-known usage for route/product defaults. */
178
+ fixed(value: number): MeterCost;
179
+ /** Convenience value for APIs that want an explicit estimate helper. */
180
+ estimate(value: number): MeterCost;
155
181
  };
156
182
  export type ResourceRef = {
157
183
  readonly kind: "resource";
@@ -201,6 +227,7 @@ export declare class Business {
201
227
  readonly name: string;
202
228
  private readonly options;
203
229
  private readonly graph;
230
+ private readonly defaultMeterCosts;
204
231
  private frontendManifest?;
205
232
  private productPatch;
206
233
  /** Sugar for binding API routes to features. */
@@ -231,6 +258,8 @@ export declare class Business {
231
258
  };
232
259
  constructor(name: string, options: BusinessOptions);
233
260
  meter(key: string, options: MeterOptions): MeterRef;
261
+ requests(options?: RequestMeterOptions): MeterRef;
262
+ defaultMeters(costs: MeterCost | MeterCost[]): Business;
234
263
  resource(name: string, options?: ResourceOptions): ResourceRef;
235
264
  capability(key: string, options?: CapabilityOptions): CapabilityRef;
236
265
  feature(key: string, options?: FeatureOptions): FeatureRef;
@@ -243,7 +272,14 @@ export declare class Business {
243
272
  * with structured issues when the declared state is invalid. */
244
273
  toIR(): ManifestBuildResult;
245
274
  private buildProductSpec;
275
+ private buildMeterDefinitions;
276
+ private routeValueMeterKeys;
246
277
  private buildRoute;
278
+ private makeMeterRef;
279
+ private buildRouteMetering;
280
+ private normalizeMeterCost;
281
+ private normalizeMeterCosts;
282
+ private normalizeMeterRefs;
247
283
  private ensureFrontendManifest;
248
284
  private normalizeMigrationPlanRef;
249
285
  private normalizeMigrationTargetRef;
@@ -258,6 +294,8 @@ export declare class Business {
258
294
  private capabilityDependsOn;
259
295
  private policyDependsOn;
260
296
  private featureDependsOn;
297
+ private routeMeterDependencyKeys;
298
+ private assertRouteMeteringValid;
261
299
  private actionDependsOn;
262
300
  private planDependsOn;
263
301
  private migrationDependsOn;
@@ -14,7 +14,7 @@ export { price } from "./price.js";
14
14
  export { validateManifestIr, hashIr, canonicalIrJson } from "./validate.js";
15
15
  export { ManifestValidationError, ManifestBuilderError } from "./errors.js";
16
16
  export { SDK_VERSION } from "./version.js";
17
- export type { BusinessOptions, MeterOptions, ResourceOptions, CapabilityOptions, ActionOptions, FrontendNavItemOptions, FrontendPageOptions, FrontendComponentOptions, MigrationOptions, FeatureOptions, RouteOptions, PolicyOptions, PlanOptions, MeterRef, ResourceRef, ActionRef, PolicyRef, PlanRef, FeatureRef, CapabilityRef, PlanCapabilityGrant, ProductModule, } from "./business.js";
17
+ export type { BusinessOptions, MeterOptions, RequestMeterOptions, MeterCost, ResourceOptions, CapabilityOptions, ActionOptions, FrontendNavItemOptions, FrontendPageOptions, FrontendComponentOptions, MigrationOptions, FeatureOptions, RouteOptions, PolicyOptions, PlanOptions, MeterRef, ResourceRef, ActionRef, PolicyRef, PlanRef, FeatureRef, CapabilityRef, PlanCapabilityGrant, ProductModule, } from "./business.js";
18
18
  export type { ManifestResourceGraphSnapshot, ManifestResourceKind, ManifestResourceUrn, } from "./resource-graph.js";
19
19
  export type { PriceSpec } from "./price.js";
20
20
  export type { ManifestIssue } from "./errors.js";
@@ -5,6 +5,8 @@ export type MeterDefinitionJson = {
5
5
  key: string;
6
6
  display: string;
7
7
  unit?: string;
8
+ estimate?: number;
9
+ routeDefault?: number;
8
10
  enforcementType?: "exact_pre_request" | "estimated_then_settled" | "postpaid" | "strict_concurrency";
9
11
  aggregation?: "SUM" | "COUNT" | "MAX" | "UNIQUE_COUNT" | "LATEST";
10
12
  window?: "minute" | "hour" | "day" | "month" | "billing_period";
@@ -71,8 +73,14 @@ export type RouteDefinitionJson = {
71
73
  method?: HttpMethod;
72
74
  path: string;
73
75
  };
74
- meters?: string[] | null;
76
+ metering?: {
77
+ defaults?: Record<string, number>;
78
+ reports?: string[];
79
+ estimates?: Record<string, number>;
80
+ onStatusCodes?: string | number[];
81
+ };
75
82
  unmetered?: boolean;
83
+ inheritDefaultMeters?: boolean;
76
84
  action?: string;
77
85
  };
78
86
  export type ActionSpecJson = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farthershore/product",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Farther Shore product-as-code SDK — declare your business in TypeScript",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",